Notification templates

Editing the 118 shipped message templates at /admin/system/notification/template — the per-channel switches that silently stop a send, the %VARIABLE% substitution, the email wrapper, and how to test one end to end.

9 min readUpdated 6 August 2026notifications, email, templates, sms, push

These are your words reaching your customers. A password reset, a KYC decision, a withdrawal confirmation, a liquidation warning — every one of them is a row in notification_template, and every one is editable.

They are also the quietest failure in the platform. A template with its email switch off, or with an empty email body, makes the send throw a 404 that no screen in the admin panel reports. The customer simply never gets the mail. Nothing turns red. Read The template can silently stop a send before you edit anything.

The editor is System → Communication Tools → Notification Templates, /admin/system/notification/template, gated on access.notification.template. The console it sits under — channels, transports, the queue and the test buttons — is The notification service.

The set is fixed

There are 118 templates and you cannot add or remove one. That is not an oversight: a template exists because a specific line of backend code sends it by name, so a template you invented would be sent by nothing, and one you deleted would take a real notification with it.

The API reflects that exactly — there is a list, a read, an update and a status toggle, and no POST and no DELETE:

Lists templates. ?all=true returns every row unpaginated, name-ascending, with only id, name, subject and the three channel flags.
One template with all its bodies and its shortCodes
Saves subject, all three bodies and all three channel flags
Sets the three channel flags only. Body requires all three.

The seeder is additive: on every update it inserts only templates whose name is not already in the table. Two consequences, both worth knowing. Your edits are never overwritten by an update — and a shipped revision to a template you already have is never delivered either. If release notes say a template's wording changed, the new wording is in the seeder, not in your database.

What a template row holds

Field Type What it is
id integer Auto-increment. The URL carries it as ?selected=.
name string(191) The key the backend sends by — WithdrawalStatus, KycApproved, EmailVerification. Not editable.
subject string(191) Email subject. Variables are substituted here too. Cannot be empty.
emailBody longtext The HTML body
smsBody longtext The SMS text
pushBody longtext The push text
shortCodes text JSON array of the variable names this template receives
email boolean Send this template by email
sms boolean Send it by SMS
push boolean Send it as a push notification

The editor is three panes: a searchable sidebar grouping templates into fourteen categories, the editor in the middle with a WYSIWYG for the email body and tabs for SMS body and Push notification, and a variables panel on the right. Categories are derived from the template name by substring match — Binary… lands under Trading, P2P… under P2P Trading, and anything matching no pattern falls into System, which is the declared catch-all. Empty categories are not rendered.

Save posts subject, all three bodies and all three flags in one PUT. Selecting a different template with unsaved changes asks for confirmation first.

The template can silently stop a send

fetchAndProcessEmailTemplate is the function every direct email send goes through. It throws 404 "Email template not found or email not enabled" when any of these is true:

  • there is no row with that name;
  • the row's email flag is off;
  • the row's emailBody is empty.

All three produce the same outcome: no mail, and no signal an operator would notice.

  • Sends that go through emailQueue are retried three times on an exponential backoff and then logged as permanently failed. That line is in the backend log and nowhere else.
  • The Queue tab on the notification console does not show it. That tab reads the notification service's own Bull queue (notification-emails); the template-driven mail path uses a separate queue named emailQueue. Two queues, one console, and this one is not on it.
  • Sends that call sendEmail directly re-throw, so the surrounding route may fail — or may swallow it, depending on the caller.

There is no "send this one by SMS instead" fallback in the email path. Clearing the email flag on PasswordReset or EmailVerification means those mails stop being produced at all, and the customer is left at a screen telling them to check their inbox. If you genuinely want a notification off, be sure the platform has another route to the customer for it — and remember the same is true of an emailBody you emptied while rewriting.

The safe editing order is: write the new body, save, then use the test path below to prove it renders — before you touch any channel switch.

Variables

Substitution is literal string replacement of %NAME%, globally, once per variable. A variable whose value is undefined is left in the output as %NAME% rather than being blanked, which is exactly what a customer sees if you invent a placeholder the sender does not supply.

URL is added to every template automatically and resolves to your site URL. Everything else comes from the call site.

Finding out which variables a template gets

Three sources, in increasing order of authority:

  1. The shortCodes column, shown in the variables panel beside the editor. Click one to insert it at the cursor. This is the seeded declaration of what the template receives.

  2. The seeded body, which uses the same set.

  3. The call site in the backend, which is the only thing that is definitely true. Search the backend for the template's name as a string. You will find an object literal whose keys are the variable names, for example the WithdrawalStatus sender:

    const emailType = "WithdrawalStatus";
    const emailData = {
      TO: user.email,
      FIRSTNAME: user.first_name,
      STATUS: status,
      REASON: reason,
      TRANSACTION_ID: transactionId,
      AMOUNT: amount,
      CURRENCY: currency,
    };

    Every key there is available as %KEY% in both the subject and the body. TO is the recipient address and is consumed by the mailer rather than rendered, and USER_ID — where a sender supplies it — is what generates the unsubscribe link.

The wrapper around your body

Your emailBody is not the whole email. It is dropped into backend/email/templates/generalTemplate.html, which supplies the masthead, the layout and the footer. The editor fetches it so the preview matches what is actually sent:

Returns the wrapper HTML

The wrapper is substituted with nine placeholders:

Placeholder Filled with
%SITE_URL% NEXT_PUBLIC_SITE_URL
%SITE_NAME% NEXT_PUBLIC_SITE_NAME, or the literal Bicrypto if unset
%LOGO_URL% {site url}/img/logo/logo-text.webp
%HEADER% Your processed subject
%MESSAGE% Your processed body
%SUBJECT% Your processed subject
%FOOTER% The site name
%YEAR% The current year
%UNSUBSCRIBE_URL% {site url}/{locale}/unsubscribe?token=…, or the bare unsubscribe page when no token could be generated

%LOGO_URL% is built as a fixed path — /img/logo/logo-text.webp — rather than read from the fullLogo settings key. To change the logo in email, replace that file. Editing Branding on the settings screen changes the site and not the mail. Likewise %SITE_NAME% comes from the environment, not from a setting.

CSS classes become inline styles, and unknown ones vanish

Email clients — Gmail in particular — strip <style> blocks, so before your body is placed in the wrapper each class="…" is rewritten into an inline style="…" from a fixed map. The map covers the classes the shipped templates use:

transaction-card, transaction-row, transaction-row-last, transaction-label, transaction-value (plus positive / negative), info-card, info-card-title, info-card-content, highlight-box, highlight-value, highlight-label, btn, btn-secondary, btn-success, alert (plus alert-info / alert-success / alert-warning / alert-error), code-block, stats-grid, stat-card, stat-value, stat-label, divider, security-badge.

Matching is on the whole attribute value, longest first — class="alert alert-warning" is one entry in the map, not two classes being combined. So:

  • A class that is not in the map is simply dropped. The class="…" attribute is left as-is and no stylesheet exists to interpret it, so the element renders unstyled. Invented class names produce plain, unformatted email with no warning.
  • Adding a class alongside a mapped one breaks the match. class="btn btn-large" matches nothing and loses the button styling entirely. Copy a class string from an existing template exactly, or use an inline style attribute of your own — those are never touched.

<h1>, <h2>, <h3> and <p> are given default inline styling only when they carry no style attribute already, so your own inline styles win.

Testing a template end to end

Waiting for a real event is the slow way and it does not prove much. Use the Testing tab on the notification console, /admin/system/notification.

Every button on the Testing tab performs an actual delivery. It is not a dry run.

  1. Edit and save the template you are working on. Confirm the channel switch you intend to use is on and its body is not empty.

  2. Open /admin/system/notification → Testing. Four channels, each defaulting to your own account.

  3. Send. Email and SMS accept an override recipient, and both refuse to relay: the address or phone number must match the calling administrator's own account, or the call is refused 403. The response names the channels delivered and the channels failed, with each channel's own error string.

  4. For a template-rendered email specifically, use the older test route:

    Queues the EmailTest template to your own account address

    It always sends to the calling administrator's own account email, goes through the email queue rather than sending inline — so it also proves the queue is draining — and takes an optional name query parameter that fills the template's first-name placeholder. This is the one that exercises the template path, the wrapper and the inline-style conversion together.

  5. To test a template other than EmailTest without waiting for its event, edit EmailTest's body to the markup you are trying, send, look at the result, then put EmailTest back. Only two things in the product read that template: this route and the system health check, so borrowing it costs nothing.

If the mail does not arrive, the fault is one of three things and they are distinguishable: a 404 from the template (flag off, empty body, wrong name), a transport failure (wrong APP_EMAILER, bad credentials — the Testing tab reports the provider's error verbatim), or delivery (SPF, DKIM, the recipient's spam folder). The console's Health and Settings tabs answer the second one; see The notification service.

SMS and push bodies

The same row carries smsBody and pushBody, edited on their own tabs and saved by the same PUT.

Two things to keep in mind. SMS is metered and the shipped bodies are short on purpose — expand one and every send of it costs more. And MSG91 cannot carry free-text notification bodies at all; only Twilio can, and one-time codes are the only category MSG91 handles. The routing rule and its asymmetry are documented in The notification service.

A second renderer exists, with different syntax

The shipped templates all use %VARIABLE%, and the mail path described above is what renders them. There is a second reader of the same table — the notification service's own TemplateEngine, used when a caller passes a templateName into the service rather than sending directly. It behaves differently in three ways:

  • it only loads rows where email is true, so a disabled template is invisible to it as well;
  • it renders {{ path.to.value }} placeholders, not %VARIABLE%, and HTML- escapes what it substitutes;
  • it refuses outright any body containing <% or %>, which would be a server-side template injection.

When it cannot render, it falls back to a plain title-and-message email rather than failing the send. The practical rule for an operator: write %VARIABLE%, because that is what the shipped bodies and the shipped senders use, and never put <% or %> into a body.