Channels
The three ways a notification reaches someone — how each finds an address, how each sends, and what every failure code means.
A channel is a way of reaching a person. There are exactly three, and a notification type can use any combination of them.
in_app (the bell) | email | whatsapp | |
|---|---|---|---|
| Who it reaches | Staff using the Hub | Anyone with an email address | Guardians only |
| Where the address comes from | Nowhere — no address needed | The user's email field | The guardian's phone field |
| Sends immediately? | Yes | Yes, handed to a queue | No — a background job |
| Gets a delivery row? | No | Yes | Yes |
| Retries on failure? | No | No | Yes, 3 times |
The rest of this page takes them one at a time.
in_app — the bell
The simplest channel, because nothing leaves our system.
How it works. The notification is already a row in the database. The bell reads from that table. So the message has technically already "arrived" the moment it is saved — there is nothing to deliver and nothing to track. This is why in-app is the one channel with no delivery row.
What the system does on top of that is push it to the browser so it appears immediately rather than on next refresh:
await this.realtime.publishTo(
'personal',
{ userId: n.recipientUserId },
'notification',
{ notificationId: n.id, type, title, body, data },
);This sends a message over a websocket to that one user's private channel. If they have the Hub open, a toast appears.
Why the full text is safe to send. The personal channel is locked to a single user by their connection token — nobody can subscribe to someone else's. So it is safe to put the actual title and body on the wire, instead of just an id that the client would have to fetch.
If the websocket push fails, nothing is lost. The row was saved first. The user simply sees the notification next time the bell loads, instead of as a pop-up. See How realtime works.
Finding the address. The system looks up the email field on each recipient's user record. All recipients are looked up in one query, not one at a time.
If a user has no email address, their delivery row is written as failed with the error NO_ADDRESS, and nothing is attempted.
What gets sent. Email does not use the shared title and body. It uses its own builder from the EMAILS object, so it can have a proper subject line, a designed template, and a link into the Hub. See step 4 of Sending a notification.
If the matrix says EMAIL but no builder exists for that type, the delivery is marked failed with NO_EMAIL_TEMPLATE. Nothing throws, so this is only visible in the database.
Sending. The payload is handed to EmailService, which puts it on the email queue. A worker picks it up and sends it through Resend.
'sent' means 'queued', not 'delivered'
The delivery row is marked sent the moment the email is handed to EmailService — not when Resend accepts it, and not when it lands in an inbox. The email worker does not report back yet.
So a sent email delivery tells you we handed it off successfully. It is not proof the person received anything.
The most involved channel, and the one with real preconditions.
Finding the number
The system looks for a guardian profile belonging to the recipient, and reads its phone field. That number is then normalised into E.164 format (the international +256… form):
export function normalizePhoneE164(raw, defaultCountryCode = '256'): string | nullIt strips out spaces and punctuation, then applies a few rules: a number starting 00 has that removed, a number starting 0 has it swapped for 256, and a bare 9-digit number gets 256 added. Anything that ends up shorter than 10 or longer than 15 digits is rejected as unusable.
The default of 256 is Uganda. This is a deliberate shortcut, marked as such in the source — if guardians outside Uganda ever appear, it needs replacing with a real phone-number library.
WhatsApp cannot reach staff — at all
The lookup only searches guardian profiles. A teacher or admin does not have one, so no number is ever found for them, and their delivery row is written failed / NO_ADDRESS before anything is queued.
This is the single most common mistake when choosing channels. If your notification goes to staff, WhatsApp will silently do nothing. That is why guardian.welcome is the only WhatsApp type today.
Sending the message
Unlike the other two channels, WhatsApp does not send during the request. It creates a background job instead.
- A job is added to the
whatsappqueue, one per delivery. The job's id is set to the delivery id, so the same delivery can never be queued twice. - The job is allowed 3 attempts, waiting longer between each one, starting at 5 seconds.
- A worker process picks the job up. It is limited to 30 jobs per minute — this is pacing to avoid the number being banned by WhatsApp, not a performance setting.
- The worker looks up the school's connected WhatsApp account, finds the chat for that phone number, and sends the message.
The message text is the title and body joined by a blank line — the same words every other channel uses. This is why WhatsApp-only notifications write their body with *asterisks*, which WhatsApp renders as bold.
The school must have WhatsApp connected
Sending needs the school to have linked a WhatsApp account — specifically a social_connection record with provider = 'whatsapp' and status = 'connected'.
Without it, every WhatsApp delivery fails with NO_WHATSAPP_CONNECTION. Staff connect it themselves in the Hub; the guide is Connect social channels.
What a delivery row records
Every channel except the bell writes a delivery row before any sending is attempted, so an attempt is never invisible. Each row ends in one of three states:
enum NotificationDeliveryStatus { QUEUED = 'queued', SENT = 'sent', FAILED = 'failed' }When something goes wrong, the reason is written to the row's error field:
error | Channel | What it means | What to do |
|---|---|---|---|
NO_ADDRESS | email, whatsapp | The recipient has no email address, or no usable guardian phone number | Check the user's record. For WhatsApp, check they are actually a guardian |
NO_EMAIL_TEMPLATE | The matrix says email, but no builder exists for this type | Add the EMAILS entry | |
NO_WHATSAPP_CONNECTION | The school has not connected a WhatsApp account | Connect it in the Hub | |
NOT_ON_WHATSAPP | The number is valid but has no WhatsApp account | Nothing — that person cannot be reached this way | |
| a provider message | The send itself failed upstream | Check the message; it will have been retried 3 times |
The first four are not retried. None of them would succeed on a second attempt — a missing template is still missing 5 seconds later — so the job is stopped immediately rather than wasting the rate limit. Only a genuine upstream failure is retried.
On success, a WhatsApp row also records the provider's message id, how many attempts it took, and when it was sent.
Finding out why someone was not notified
Work through this in order:
1. Is there a notification row at all?
SELECT id, type, channels, created_at FROM notification
WHERE recipient_user_id = '<user-id>' ORDER BY created_at DESC LIMIT 5;Nothing here means emit was never called, or the person was removed as the actor.
2. Does channels on that row include the channel you expected? Remember the row stores the channels used at the time it was sent, so an old row shows the old matrix.
3. Is there a notification_delivery row?
SELECT channel, status, address, error, attempts FROM notification_delivery
WHERE notification_id = '<id>';Match error against the table above.
4. Bell only: the row exists but no toast appeared. That is a realtime problem, not a notification one — the notification is fine and will show when the bell is next opened.