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.
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:
?all=true returns every row unpaginated, name-ascending, with only id, name, subject and the three channel flags.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
emailflag is off; - the row's
emailBodyis empty.
All three produce the same outcome: no mail, and no signal an operator would notice.
- Sends that go through
emailQueueare 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 namedemailQueue. Two queues, one console, and this one is not on it. - Sends that call
sendEmaildirectly 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:
-
The
shortCodescolumn, 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. -
The seeded body, which uses the same set.
-
The call site in the backend, which is the only thing that is definitely true. Search the backend for the template's
nameas a string. You will find an object literal whose keys are the variable names, for example theWithdrawalStatussender: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.TOis the recipient address and is consumed by the mailer rather than rendered, andUSER_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:
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 inlinestyleattribute 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.
-
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.
-
Open
/admin/system/notification→ Testing. Four channels, each defaulting to your own account. -
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.
-
For a template-rendered email specifically, use the older test route:
Queues theEmailTesttemplate to your own account addressIt 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
namequery 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. -
To test a template other than
EmailTestwithout waiting for its event, editEmailTest's body to the markup you are trying, send, look at the result, then putEmailTestback. 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
emailistrue, 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.