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.

11 min readUpdated 6 August 2026notifications, email, sms, push, queue

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

Reports registered channels, resolved SMS routing and the active push provider

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.

Sends a real in-app notification
Sends a real email through the configured transport
Sends a real SMS
Sends a real push notification

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:

Queues the EmailTest template to your own account address

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

Lists the SMS providers with credential status and setup steps

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_PROVIDERtwilio or msg91, 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:

  • TwilioAPP_TWILIO_ACCOUNT_SID (must start with AC), APP_TWILIO_AUTH_TOKEN, and one of APP_TWILIO_PHONE_NUMBER or APP_TWILIO_MESSAGING_SERVICE_SID.
  • MSG91MSG91_AUTH_KEY and MSG91_OTP_TEMPLATE_ID. MSG91_SENDER_ID is 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.

Validates candidate credentials against the vendor without saving or sending

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_KEY and VAPID_PRIVATE_KEY (generate a pair with pnpm vapid:generate). It works in Chrome, Firefox, Edge and Safari with no Firebase involved, and the Settings tab reports it as vapid (Web Push).
  • Native push needs FCM_PROJECT_ID, FCM_CLIENT_EMAIL and FCM_PRIVATE_KEY, or a FCM_SERVICE_ACCOUNT_PATH pointing 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.

Counts by state, with a failure rate and a health verdict

Returns waiting, active, completed, failed and delayed, plus a failureRate percentage and a status that reads degraded once failures exceed 10% of completions.

Lists the jobs that have not been delivered yet

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.

Removes old completed and failed jobs

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

Redis, channels, the queue and cache hit rate in one call

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.

Per-channel sent, failed and success rate

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.

Time-series data for the charts

Takes timeframe24h, 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.

Reads the manifest, its icons and its screenshots
Writes the manifest back
Uploads a screenshot or splash image
Removes one

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.