The notification service
The seven-tab console at /admin/system/notification — which channels are actually registered, the four mail transports, sending a real test on each channel, the queue, and push.
Every password reset, KYC decision, withdrawal approval and one-time code leaves the platform through one service. When a customer says "I never got the email", this console is where you find out whether the platform tried.
It lives at System → Communication Tools → Notification Service,
/admin/system/notification, and it is seven tabs over one API surface. The tab
is in the URL as ?tab=, so a link to a specific tab is shareable.
Permissions
Every endpoint behind this console — the dashboard, health, metrics, analytics,
settings, all four test channels, all three queue routes and the SMS provider
test — gates on the single key access.notification.settings.
/admin/system/notification has no entry in the URL permission map, so the page
itself opens for anyone holding the catch-all access.admin. Every request it
makes is refused without access.notification.settings, so an under-privileged
operator gets an empty console rather than data — but they can reach it. The
child screens are mapped: /admin/system/notification/sms to
access.notification.settings, /admin/system/notification/template to
access.notification.template.
The PWA tab is the exception: it reads through view.settings and saves through
edit.settings.
The seven tabs
| Tab | What it answers | Endpoint |
|---|---|---|
| Overview | Is the service up, and what has it sent | GET /api/admin/system/notification |
| Health | Redis, channels, the queue and cache hit rate | GET .../notification/health |
| Test | Does each channel actually deliver | four POST .../notification/test/* |
| Queue | What is waiting, and what failed | GET .../queue/stats, .../queue/items, POST .../queue/clean |
| Metrics | Sent, failed and success rate per channel | GET .../notification/metrics |
| PWA | The installable-app manifest | /api/admin/system/pwa |
| Settings | What is configured, truthfully | GET .../notification/settings |
Overview polls every 30 seconds. It reports the service status, whether Redis is
connected, the registered channels, total sent and failed, the success rate, the
cache hit rate, the queue counts and a per-channel send breakdown across
IN_APP, EMAIL, SMS and PUSH.
Settings: the only truthful view of what is switched on
This is the tab to open first, because it does not report what you configured — it reports what the service registered at boot.
Channels are registered once, during startup:
- IN_APP and EMAIL are always registered.
- SMS is registered only if the resolved provider has credentials and the channel's own validation passes.
- PUSH is registered only if FCM or VAPID is configured and the channel validates.
A channel that failed to register is simply absent, and every send to it fails. Changing an environment variable does not re-register anything: the service reads the environment at boot, so a mail or SMS change needs a backend restart before this tab can tell you the truth about it.
Boot also writes three settings rows — emailChannelStatus, smsChannelStatus
and pushChannelStatus — so the rest of the frontend can see which channels are
live without asking this endpoint.
The response also names the resolved SMS configuration (base provider, OTP
provider, and whether each is configured), the active push provider, and a
per-provider "configured" flag for SendGrid, nodemailer, Twilio, MSG91, VAPID
and FCM. It ends with the service's fixed capabilities: idempotency with a
30-day TTL, user preferences cached for an hour, delivery tracking kept 30 days,
the four priority levels LOW/NORMAL/HIGH/URGENT, and the eighteen
notification types.
The email provider line reads EMAIL_PROVIDER and falls back to the literal
string nodemailer. The transport is actually selected by APP_EMAILER, and
this endpoint never looks at it. Likewise the from line reads EMAIL_FROM,
and the SendGrid "configured" flag reads SENDGRID_API_KEY rather than
APP_SENDGRID_API_KEY.
So this tab can show "nodemailer" and "SendGrid: not configured" on an install
that is happily sending through SendGrid. The nodemailer flag is the one that
behaves: it accepts either APP_NODEMAILER_SMTP_HOST/_PORT or the bare
SMTP_HOST/SMTP_PORT. Trust the Test tab over this line, and see
Environment variables, which lists those bare names among
the duplicate naming families that configure nothing.
Email: the four transports
APP_EMAILER selects one transport, and it is the only thing that selects it.
The value is read at boot and by the mailer on every send.
APP_EMAILER |
Transport | Variables it uses |
|---|---|---|
nodemailer-service |
A well-known provider by name (Gmail, Outlook…) | APP_NODEMAILER_SERVICE, APP_NODEMAILER_SERVICE_SENDER, APP_NODEMAILER_SERVICE_PASSWORD |
nodemailer-smtp |
A plain SMTP server | APP_NODEMAILER_SMTP_HOST, _PORT, _SENDER, _PASSWORD, _ENCRYPTION |
nodemailer-sendgrid |
SendGrid's API | APP_SENDGRID_API_KEY, APP_SENDGRID_SENDER |
local |
The box's own sendmail binary |
APP_SENDMAIL_PATH (default /usr/sbin/sendmail) |
Those four are the only values both mail paths understand, and the two paths
disagree about everything else. nodemailer-gmail is the one to watch: the
notification queue accepts it — its resolver maps nodemailer-gmail onto the
service transport — but the direct mail path in utils/mailer.ts does not. That
one switches on the raw value with cases for local, nodemailer-service,
nodemailer-smtp and nodemailer-sendgrid only, and everything else falls to
throw ... "Unsupported email provider". Set APP_EMAILER=nodemailer-gmail and
queued notifications go out over Gmail while password resets, KYC decisions and
every other directly sent mail fail with a 500. Write nodemailer-service.
The same split governs any other unrecognised value, including the bare
nodemailer: the queue falls back to the historical inference — service if
APP_NODEMAILER_SERVICE is non-empty, SMTP otherwise — while the direct path
throws. An unset variable is the only value neither rejects, and even there the
two differ: the direct path substitutes nodemailer-service, while the queue
applies that same inference. Set the variable explicitly.
The From header is built as "{name}" <{sender}>, where the name is
APP_EMAIL_SENDER_NAME, then NEXT_PUBLIC_SITE_NAME, then the literal
Support.
local additionally signs with DKIM when APP_NODEMAILER_DKIM_PRIVATE_KEY,
APP_NODEMAILER_DKIM_DOMAIN and APP_NODEMAILER_DKIM_SELECTOR are all set —
the private key is a path to a file, read at send time.
APP_EMAILER used not to be consulted by the notification queue, which inferred
the transport from whichever block was filled in and preferred the service
block. An install that moved to SMTP but left the Gmail lines behind kept
authenticating against Gmail for every queued notification while password resets
went out over SMTP — until a broadcast ran Gmail's login limit into
454 4.7.0 Too many login attempts. Both paths now honour APP_EMAILER, but
leaving a stale block behind is still an invitation. Clear the block you are not
using.
Full variable reference, including APP_NODEMAILER_SMTP_USERNAME,
MAIL_DISABLED and the port/encryption pairing, is in
Environment variables.
The Test tab sends real messages
Four buttons, four endpoints, and every one of them sends an actual message.
This is not a dry run. Each takes a userId, defaulting to your own account.
Email and SMS accept an override recipient, and both refuse to relay:
- The email override must match the calling administrator's own account email, or the call is refused 403 "Test email can only be sent to your own account email".
- The phone override must match the calling administrator's own account phone after normalisation, with the same refusal.
That is deliberate — an unguarded test endpoint is an open relay.
Each response names the channels delivered and the channels failed, with the
channel's own error string when it failed. The push test is the most informative:
before sending anything it reports fcmAvailable, webPushAvailable and
userHasTokens, and short-circuits with a plain sentence when there is no
provider ("No push providers configured. Set VAPID_* or FCM_* environment
variables.") or the target has never subscribed ("User has no push notification
subscriptions. Enable push notifications in your browser first.").
There is a fifth, older test that is worth knowing about:
It always sends to the calling administrator's own account email — the recipient
cannot be chosen — and it goes through the email queue rather than sending
inline, so it also proves the queue is draining. An optional name query
parameter fills the template's first-name placeholder.
SMS: two providers, one split
The comparison screen is System → Communication Tools → SMS Providers,
/admin/system/notification/sms. It shows Twilio and MSG91 side by side with
coverage, cost, setup effort, what each is best for, the India DLT position, the
required and optional credentials, and which of them are currently set.
The routing rule is fixed and worth understanding, because it is not symmetric:
- One-time codes go to
SMS_OTP_PROVIDER—twilioormsg91, defaulting to Twilio. That covers sign-in codes, phone verification, withdrawal codes, P2P codes and password-change codes. - Everything else goes to Twilio, always. There is no switch.
MSG91 cannot carry free-text notifications. Its current send API rejects a message with no template, and a template is not free text: DLT caps a variable at roughly 30 characters and requires about 30% fixed wording, so an arbitrary notification body cannot be expressed as one. For Indian destinations — the main reason to reach for MSG91 — free text is blocked by the telco regardless of endpoint.
Credentials, and what each provider needs before its channel registers:
- Twilio —
APP_TWILIO_ACCOUNT_SID(must start withAC),APP_TWILIO_AUTH_TOKEN, and one ofAPP_TWILIO_PHONE_NUMBERorAPP_TWILIO_MESSAGING_SERVICE_SID. - MSG91 —
MSG91_AUTH_KEYandMSG91_OTP_TEMPLATE_ID.MSG91_SENDER_IDis deliberately not required; MSG91's OTP API falls back to its shared sender and your own is only needed past roughly 2,000 messages a month in a country.
An unknown SMS_OTP_PROVIDER value warns once in the log and falls back to
Twilio rather than throwing — an SMS typo must not be able to stop the API from
booting.
This is the one to use before you write a key into .env and restart. It
builds a throwaway provider from the values you supply, checks them against the
vendor and discards it. Nothing is persisted, no SMS is sent, and any field you
omit falls back to the configured environment — so you can test just the one
value you are about to change.
It matters most for MSG91, because a presence check cannot tell a real server
authkey from an OTP-Widget tokenAuth: MSG91's send endpoints accept both, and
accept no key at all. Only a network call distinguishes them. This route makes
that call.
It is deliberately kept off the admin audit trail — it tests values that have not been saved, so there is no record for an audit entry to point at. Credential values are never written to the log context either; only the field names appear.
Push: web and native
Two independent providers, and either alone is enough for the channel to register.
- Web push needs
VAPID_PUBLIC_KEYandVAPID_PRIVATE_KEY(generate a pair withpnpm vapid:generate). It works in Chrome, Firefox, Edge and Safari with no Firebase involved, and the Settings tab reports it asvapid (Web Push). - Native push needs
FCM_PROJECT_ID,FCM_CLIENT_EMAILandFCM_PRIVATE_KEY, or aFCM_SERVICE_ACCOUNT_PATHpointing at a service-account JSON file.
When both are present the Settings tab names VAPID as the active provider, since it is checked first.
Copying .env.example and filling in only the keys you care about leaves
FCM_PROJECT_ID non-empty. Every presence check then passes: the Settings tab
reports fcm (Firebase) as the active push provider, and the channel advertises
itself.
The FCM provider refuses it — it recognises example values in the project id, client email and private key and logs
FCM_PROJECT_ID still holds the example value shipped in .env.example — FCM disabled.
Fill in real Firebase service-account credentials, or clear FCM_PROJECT_ID to turn FCM off.Without that guard the Admin SDK fails with OpenSSL's
error:1E08010C:DECODER routines::unsupported, four frames deep. If you are
not using Firebase, leave FCM_PROJECT_ID blank. Do not leave the example
value in place.
The push channel also requires the recipient to have subscribed. Push tokens
and web-push subscriptions live on the user record, so a correctly configured
channel still delivers nothing to a customer who never accepted the browser
prompt — which is what the test endpoint's userHasTokens flag is telling you.
The queue
Notifications are queued rather than sent inline, so the queue is where a delivery problem becomes visible.
Returns waiting, active, completed, failed and delayed, plus a
failureRate percentage and a status that reads degraded once failures
exceed 10% of completions.
Up to 200 (50 by default), each with the user, the notification id, the title,
the type, the channels, the template, the provider, how many attempts have been
made, when it was queued, its age, and whether it is pending or processing.
This is the list that tells you what is stuck, not just how much.
Reading a growing queue. waiting climbing while active stays at zero
means nothing is draining it — check that the backend process is up and Redis is
reachable, since the queue lives in Redis. failed climbing alongside
completed means the transport is rejecting sends; open a queue item, read its
provider and attempt count, then use the Test tab on that channel to reproduce
it. A large delayed count is retries backing off, which resolves itself if the
underlying provider recovers.
Takes olderThan in milliseconds, defaulting to 24 hours, and returns how
many were removed with the first ten ids for reference. It is a housekeeping
action on finished jobs — it does not cancel anything waiting, and it does not
retry anything failed. Clearing the failures does not fix the cause; read the
items first.
Health, metrics and analytics
Returns an overall status with components: Redis connectivity and its cache hit rate, the registered channels and their count, the queue's five counts, and the service's own metrics — plus process uptime, which tells you when the backend last restarted and therefore how recently the channels were registered.
Accepts a period query parameter and returns an overview (total sent, total
failed, success rate, cache hit rate) plus a byChannel breakdown for IN_APP,
EMAIL, SMS and PUSH. byType is returned empty on purpose: per-type
counters are not tracked, and the endpoint returns nothing rather than a
fabricated split.
Takes timeframe — 24h, 7d (the default), 30d or 90d — and returns KPIs
and chart series.
The PWA tab
The last tab is not about notifications at all; it edits the installable-app manifest, and it is here because web push and "add to home screen" are the same browser feature set.
It reads and writes real files in the frontend's public/ directory —
manifest.json and site.webmanifest — rather than database rows. Screenshot
uploads take base64 image data, a type of screenshot or splash, a filename,
optional target width and height for resizing, and a formFactor of wide or
narrow, which is what decides whether a store or browser shows the image on a
desktop or a phone.
The manifest itself is fine, but be aware of the general rule for
frontend/public/: production serves what was there at build time. If an
uploaded screenshot 404s in production while working in dev, that is the cause,
not the upload.
Templates are a separate screen
System → Communication Tools → Notification Templates,
/admin/system/notification/template, gated on view.notification.template and
edit.notification.template.
Each template carries a subject, an emailBody, an smsBody and a
pushBody, three independent booleans — email, sms, push — saying which
channels it is used for, and its list of short codes: the placeholders it can
substitute.
Templates can be edited and enabled, but not created or deleted. The set is fixed by what the platform actually sends; a template with no sender behind it would never fire.
Related
- Environment variables — every mail, SMS and push variable, and the duplicate names that configure nothing.
- Settings reference — the platform settings screen.
- Troubleshooting — "Emails are not being sent", from the infrastructure side.