Customers are not getting emails, SMS or push
Using the notification console to tell a dead worker from a dead mailer — the queue counts, the health components, the test senders, and the traps that fail silently.
Notification delivery is what a customer sees after a withdrawal decision, a KYC decision, a password reset or a one-time code. A silent mailer does not look like an outage — it looks like every decision you make turning into a support ticket, and it can run for days before anyone connects the two.
The console that answers this is System → Communication Tools → Notification
Service (/admin/system/notification). The active tab is in the URL as
?tab=, so a link to one is shareable.
The Test tab sends real messages through the real transport, on demand, to your own account. One click separates "the transport is broken" from "the platform never tried" — and those two have completely different fixes.
Every other diagnosis on this page is faster once you know which half you are in.
The seven tabs, and the four that matter in an incident
| Tab | Answers | Endpoint |
|---|---|---|
| Overview | Is the service up, which channels registered, what has it sent | GET /api/admin/system/notification |
| Health | Redis, the registered channels, the queue and the cache hit rate | GET .../notification/health |
| Test | Does each channel actually deliver | four POST .../notification/test/* |
| Queue | What is waiting, and how much has failed | GET .../queue/stats, .../queue/items |
| Metrics | Sent, failed and success rate per channel | GET .../notification/metrics |
| PWA | The installable-app manifest — not a delivery surface | GET/PUT /api/admin/system/pwa |
| Settings | What the service registered at boot | GET .../notification/settings |
Overview polls every 30 seconds; the Queue tab refreshes every 5.
Every endpoint behind the six notification tabs gates on the single key
access.notification.settings. The PWA tab is the exception, and it is
not really part of this service: it reads and writes /api/admin/system/pwa
(view.settings and edit.settings), uploads screenshots through
/api/admin/system/pwa/screenshot and app icons through
/api/admin/system/settings/logo (both edit.settings). A role that can work
this console can therefore be unable to open that one tab, and vice versa.
Reading the queue
The Queue tab shows five counts, from GET .../queue/stats:
| Count | Means |
|---|---|
| waiting | Queued, nothing has picked it up yet |
| active (shown as Processing) | A worker is sending it right now |
| completed | Delivered |
| failed | The transport rejected it after every retry |
| delayed | In retry backoff, or held by the rate limiter |
Beside them: a failureRate percentage, and a verdict that reads degraded
once failures exceed 10% of completions.
Three shapes are worth recognising on sight:
waitingclimbing whileactivestays at zero. Nothing is consuming the queue. That is a process problem, not a mail problem — the backend or cron process that registers the queue processor is down, or Redis is unreachable. Do not touch SMTP settings for this one.failedclimbing alongsidecompleted. The transport is rejecting sends. Read a queue item'sproviderandattemptsMade, then reproduce it from the Test tab on that channel.- A large
delayedcount with almost nothing failed. Retries backing off, or the rate limiter doing its job. It resolves itself.
MAIL_QUEUE_RATE_MAX defaults to 10 per MAIL_QUEUE_RATE_WINDOW_MS
(default 60,000 ms), and the limiter is Redis-backed so the cap is counted
across every worker — the backend and cron processes both register a
processor on this queue, so a per-process cap would have let two polite workers
send twenty a minute at one mailbox.
Rate-limited jobs are moved to delayed, not failed: they go out later. So a
broadcast to a few thousand customers legitimately shows a large delayed count
and a slow drain, and "nobody got the email yet" is the expected state for a
while. MAIL_QUEUE_MAX_ATTEMPTS (default 5) is the retry ceiling.
Raise MAIL_QUEUE_RATE_MAX only once you know what your provider's real ceiling
is. The default is deliberately well under every provider's.
The queue is created lazily and only while Redis is connected. While Redis is unreachable:
queue/statsreturns 0 waiting, 0 active, 0 completed, 0 failed, 0 delayed, and ahealthyverdict, because zero failures over zero completions is 0%.queue/itemsreturns an empty list.- Emails are not dropped —
addEmailJobfalls back to sending inline, so mail still goes out, one message at a time, in the request that produced it.
An empty, green queue is therefore ambiguous: it means either "nothing to send"
or "no queue at all". The Health tab's Redis component is what tells the two
apart, and so does the notification service's overall status, which reads
degraded whenever Redis is not connected.
The items list does not show failures
Up to 200 (50 by default), each with the user, the notification id, the
title, the type, the channel, the template, the provider, attemptsMade, when it
was queued and its age. Its status is only ever pending or
processing — active jobs first, then waiting, then delayed.
Completed and failed jobs are deliberately excluded. Bull retains the last
100 completed and 500 failed, which would bury the handful still in flight. So a
non-zero failed count on the stats card has no corresponding row anywhere in
this console; to identify which notifications failed you need the backend log
(pm2 logs backend), where each failure is written with its provider and
notification id.
The clean action
Takes olderThan in milliseconds, defaulting to 24 hours, and returns how
many were removed with the first ten ids.
It removes completed and failed jobs older than the grace period, and nothing else. It does not cancel anything waiting, does not retry anything failed, and cannot lose a message that has not been delivered. It is safe to run at any time — but it is housekeeping, not a fix: clearing a failure count does not clear its cause, and once cleared you have lost the evidence. Read the log first, clean second.
There is no admin control to pause or resume the queue. (paused appears in the
stats endpoint's documented response schema; the handler does not return it.)
The health components
The response has an overall status plus a components object:
| Component | What it reports |
|---|---|
redis |
connected true/false, and the cache hit rate |
channels |
The list of channels registered at boot, and their count |
emailQueue |
The same five counts as the Queue tab |
metrics |
Total sent, total failed, success rate |
uptime |
Process uptime — i.e. when the backend last restarted, and therefore how recently the channels were registered |
The overall status is healthy when Redis is connected and degraded when it
is not. It is not a statement about SMTP: a perfectly connected Redis with a
dead mail server still reads healthy. That is what makes "send a test first" the
right first move.
The card headed Channel status reads a per-channel shape (available,
configured, lastCheck, error) that this endpoint does not return — the
registered channels come back nested under components.channels.available as a
plain list. The card therefore renders "No detailed channel data available" on
a healthy install.
Read the registered channels on the Overview tab (the Notification channels card) or on Settings instead. The Redis card on the Health tab does work: it falls back to the overview payload.
A channel that never registered sends nothing, forever
Channels are registered once, during startup:
- IN_APP and EMAIL are always registered.
- SMS registers only if the resolved provider has credentials and the channel's own validation passes.
- PUSH registers only if FCM or VAPID is configured and the channel validates.
A channel that failed to register is simply absent from the list, and every send to it fails. Changing an environment variable re-registers nothing: the service reads the environment at boot, so a mail, SMS or push change needs a backend restart before any of these screens can tell you the truth about it.
Boot also writes three settings rows — emailChannelStatus, smsChannelStatus
and pushChannelStatus — so the rest of the platform can see which channels are
live without asking this endpoint.
The test senders
Four buttons, four endpoints, and every one of them sends a real message. Each
takes a userId, defaulting to your own account.
Email and SMS accept an override recipient and both refuse to relay: the address must match the calling administrator's own account email, and the phone must match their own account phone after normalisation. 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. That string is the diagnosis — read it rather than the red or green.
There is a fifth, older test worth knowing about:
It always goes to the calling administrator's own account email and it goes through the queue rather than inline — so it also proves the queue is draining, which the Test tab's email button does not.
SMS: a successful send is not evidence of delivery
The SMS comparison screen is System → Communication Tools → SMS Providers
(/admin/system/notification/sms).
The routing rule is fixed and 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.
Use this before writing a key into .env and restarting. It builds a
throwaway provider from the values you supply, checks them against the vendor and
discards it — nothing persisted, no SMS sent, and any field you omit falls back to
the configured environment so you can test just the one value you are changing.
A presence check cannot tell a real MSG91 server authkey from an OTP-Widget
tokenAuth, and MSG91's send endpoints accept both — and accept no key at
all. The call succeeds, nothing is delivered, and every screen in this console
reports a healthy channel.
Only a network call distinguishes them, which is exactly what the provider test
route above makes. Presence of MSG91_AUTH_KEY proves nothing.
Templates: the two silent stoppers
System → Communication Tools → Notification Templates
(/admin/system/notification/template). The screen itself — both the menu entry
and the page — is gated on access.notification.template;
view.notification.template and edit.notification.template are the keys the
endpoints behind it enforce. A role granted only view and edit never sees the
menu entry and cannot open the page, so grant all three together.
Each template carries a subject, an emailBody, an smsBody and a pushBody,
plus three independent booleans — email, sms, push — saying which
channels it is used for.
fetchAndProcessEmailTemplate refuses a template whose email flag is false or
whose emailBody is empty, with 404 Email template not found or email not enabled. Nothing is queued, nothing is retried, and the Queue tab shows no
failure — because no job was ever created.
So one flipped switch silently stops one kind of message — say, every withdrawal confirmation — while every other email on the platform keeps working. If exactly one notification type has gone missing, check its template's channel switches before anything else.
Templates can be edited and enabled but not created or deleted: the set is fixed by what the platform actually sends.
The wrapper is the other one, and it fails in the opposite direction.
Every outgoing email is a template body substituted into one shared wrapper —
backend/email/templates/generalTemplate.html — which supplies the header, the
logo, the footer and the unsubscribe link through the placeholders %SITE_URL%,
%SITE_NAME%, %LOGO_URL%, %HEADER%, %MESSAGE%, %SUBJECT%, %FOOTER%,
%YEAR% and %UNSUBSCRIBE_URL%.
If that file is missing or unreadable, prepareEmailTemplate throws 500
General email template not found and every template on the platform stops at
once. The endpoint is read-only — the console shows you the wrapper, it does not
edit it — so this is a file-on-disk problem, usually a bad deploy or an
overwritten backend/email/ directory.
The environment decides the transport, and this console never writes it
Nothing about delivery is saved from this console: there is no PUT behind the Settings tab and no write behind the SMS screen. (The PWA tab does save — but what it writes is the installable-app manifest, its icons and its screenshots, none of which touch a transport.) Everything below is read from the process environment at boot.
| Channel | Variables |
|---|---|
| Email — which transport | APP_EMAILER: nodemailer-service, nodemailer-smtp, nodemailer-sendgrid or local |
| Gmail/Outlook by name | APP_NODEMAILER_SERVICE, APP_NODEMAILER_SERVICE_SENDER, APP_NODEMAILER_SERVICE_PASSWORD |
| Plain SMTP | APP_NODEMAILER_SMTP_HOST, _PORT, _SENDER, _PASSWORD, _ENCRYPTION |
| SendGrid | APP_SENDGRID_API_KEY, APP_SENDGRID_SENDER |
| The box's own sendmail | APP_SENDMAIL_PATH (default /usr/sbin/sendmail) |
| Kill switch | MAIL_DISABLED=true — the platform attempts no delivery at all |
| SMS — code routing | SMS_OTP_PROVIDER (twilio | msg91) |
| Twilio | APP_TWILIO_ACCOUNT_SID (must start AC), APP_TWILIO_AUTH_TOKEN, and one of APP_TWILIO_PHONE_NUMBER / APP_TWILIO_MESSAGING_SERVICE_SID |
| MSG91 | MSG91_AUTH_KEY, MSG91_OTP_TEMPLATE_ID |
| Push — web | VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY |
| Push — native | FCM_PROJECT_ID, FCM_CLIENT_EMAIL, FCM_PRIVATE_KEY, or FCM_SERVICE_ACCOUNT_PATH |
Two behaviours that produce "nothing was even attempted":
MAIL_DISABLED=truedrops every notification email with a debug log line and returns a synthetic job id, so nothing appears in the queue at all.- Reserved test domains are skipped. A recipient ending
.invalid,.test,.exampleor.localhostis dropped before queueing. It only skips when every recipient is undeliverable, so a mixed list still goes out. If your staging accounts use@example.testaddresses, this is why they receive nothing.
The Email Service entry on the system health card is a presence check on
these variables — it never talks to a mail server. up there means "the right
strings exist in the environment", nothing more.
Full variable reference, including the naming families that configure nothing, is in Environment variables.
Fault to first screen
| Symptom | Look here first |
|---|---|
| Nothing at all is delivered on any channel | Test tab — one click tells you whether the transport is the problem |
| One notification type went missing | That template's email / sms / push switches |
| Every email stopped at once, all types | The wrapper file, then APP_EMAILER, then the Test tab |
waiting rising, active zero |
The backend/cron processes and Redis — not the mailer |
failed rising |
Queue item provider + attemptsMade, then pm2 logs backend |
Big delayed count after a broadcast |
The rate limiter. Normal; wait |
| SMS codes arrive, notifications do not | Routing: codes follow SMS_OTP_PROVIDER, everything else is Twilio only |
| Push silent for one customer | They never accepted the browser prompt — the push test reports userHasTokens |
| Everything green, still nothing arrives | Redis down and the queue reading all zeros; check the Health tab's Redis component |
Related
- The notification service — the full console reference, the four mail transports and the SMS/push provider setup.
- Environment variables — every mail, SMS and push variable.
- Troubleshooting — "Emails are not being sent", from the infrastructure side.