Skip to main content
“Your export is ready.” “The invoice failed.” “Someone mentioned you.” These are addressed to one named person, they are not marketing, and they belong inside your product rather than in an inbox.

The words live in Userkit, the call sends the values

A notification no longer carries the subject and the body. It names a template — written in the panel, under Messages → Templates — and sends the values that fill its blanks. That is the reason for the change: the sentence your customer reads is the most visible thing your product says, and it lived inside a deploy. The people who write, review and translate those sentences are rarely the people who deploy.
The template holds the title and the body, with the blanks in braces: Note what is not in data: the person’s name. The contact, their team, their plan and what it allows are already in every template’s context — data is only what your backend alone knows.

Variables are declared, and that is what saves you

{{.Data.invoice_id}} compiles only when invoice_id is in the template’s variable list. A typo is refused on the screen of the person who typed it, rather than rendering as half a sentence in ten thousand inboxes. From the other side, the same list is the send’s contract: a missing value is a 400 naming it.
Extra keys in data are ignored, so one payload can serve three templates that read different parts of it.

An edit becomes a version

Editing a template’s words writes a new version, and the previous one stays on the shelf. Going back is PATCH { "version": 2 } — the newer one is kept, and going forward again is the same call with the higher number. That is what makes editing this text safe from the panel, from the API and from MCP: the worst outcome of an unfortunate edit is a restore rather than a sentence you lost. Renaming or archiving writes no version — only words do.

The template has to be transactional

This endpoint sends receipts, and a receipt is what nobody unsubscribes from. A product_news template here would be marketing ignoring an opt-out, so it is refused — and a transactional template, in turn, cannot be a campaign step. Two halves of one rule.

One template per channel, under the same key

invoice_due is a row for in_app, one for email and one for whatsapp, each written for the medium it goes out on. That is what lets one call reach three doors — and a channel whose row you have not written yet answers skipped naming the one to write, without costing you the channels that exist. Name the recipient with contact_id or with your own external_id — one of the two. Both is a 400: a request naming two people is one we would have to guess about, and the guess surfaces weeks later as a notification that reached the wrong user. Either way the key’s environment scopes it, so a live external_id asked with a test key answers 404. There is no browser-reachable write, and there will not be one. A page that could create a notification is a page that can tell any of your users anything.

Where it leads

url is optional and is what turns a notice into an errand: the person clicks the row and lands on the invoice that failed, the export that finished. Leave it out for an announcement — most notifications are one. Two shapes are accepted: Everything else is a 400, and the two worth naming are javascript: — a scheme that executes, in a value that ends up in an href on your own page — and the protocol-relative //host/path, which reads as a path in every review and is an absolute URL to somebody else’s host. The refusal is at the write and in the database under it, so what you read back is already safe to render as a link without sanitising it again. In a single-page app, give <NotificationBell /> an onNavigate so your own router handles the path instead of the browser reloading the page:
The row is marked read either way, before your handler runs: acting on a notification is what reading it means. A modified click — ⌘, Ctrl, Shift — is left to the browser, so middle-clicking a row still opens a tab. It is transactional by construction. There is no category field on this request and there is no field that could be one: nothing anybody switches off in their notification preferences suppresses one of these, in the same way nothing suppresses a receipt. That is the shape of the endpoint rather than a rule it applies. It sits on the machine surface, so it inherits both of that surface’s promises by having been mounted there: an Idempotency-Key replays the first response instead of telling somebody the same thing twice, and the per-key rate limit counts the call and reports itself in the headers of every answer.

Which team it is about

A person in two teams is one person with two accounts, and “a fatura da Acme falhou” is a sentence about one of them. Say which:
customer_id is our id and customer_external_id is yours — the same pair POST /v1/track takes per line, and the same refusal for both at once (400 ambiguous_customer). A team that does not resolve in the key’s environment is a 400 unknown_customer rather than a message stored without one: a caller who said which team meant it, and filing it under “the person’s own” would show it inside every team of theirs forever. Membership is not checked. Your key is the authority on which of your teams a message belongs to, exactly as it already is on who the recipient is; what is checked is the environment, which is never a caller’s to assert. Leaving it out is an answer, not an omission. “Your password was changed” is addressed to the person, so it belongs in every team of theirs — and that is what a null here means. A product with no teams never fills the field, and every notification written before this field existed is that case too: nothing was attributed retroactively, because dating an old message to today’s memberships would file it under a relationship that may not have existed then.

Asking or telling

A notification does one of two things: it asks somebody for something, or it tells them what already happened. kind is you saying which.
action is the declined card, the document to sign, the limit reached. update is the export that finished, the report that arrived. The panel groups the two under tabs of their own, and the “Action needed” tab is why anybody opens a bell. It defaults to update, and the asymmetry is deliberate: a notification filed wrongly as an update sits in a list, and one filed wrongly as an action puts a demand on somebody who has nothing to do. A backend written before this field existed keeps working and lands in the half that costs nothing when it is wrong. A third value is a 400 — a caller who sent important believes their panel is grouping by it, and a 201 would leave them believing it for months. It is not category or source spelled differently. Those two say how the row was produced — which consent governed it, which machinery wrote it; this says what the person reading it has to do about it.

Three channels, one call

channels says where the message goes. Omitted is ["in_app"] — what this endpoint did before the field existed, so no backend written earlier starts sending mail on its own.
The trunk (title, body, url) is what every channel says; the email and whatsapp blocks are what one of them needs and the others have no use for. An email has an envelope and a subject; WhatsApp has neither and is one block of text. The response carries a verdict per channel, not one status code for all three:
A contact with no phone number is not a malformed request, and refusing the whole call over it would cost that person the notification they could have had. So each channel answers created (the feed row, written before the response), queued (handed to the queue — the delivery log is where “did it arrive” is answered) or skipped with the reason to repair. Nothing leaving by any door is a 422 naming the first reason: a 201 there would be a lie about the only thing this endpoint is for. Consent is the same on all three: a transactional message is exempt in the feed, in the inbox and on the phone, because the exemption is a property of what the message is rather than of how it travels.

WhatsApp: the server is yours

The whatsapp channel talks to an Evolution API server you host, on a number you own. There is deliberately no platform account in the middle: a shared sender would make one tenant’s reputation everybody’s, and on WhatsApp the cost of that is not throttling — it is the number being banned. Connect it in Settings → WhatsApp, per environment: address, instance and the api key. The key is stored encrypted and never returned to the screen. The address must be https — a key travelling in a header over plaintext is a key anybody on the path can read, and that failure has no symptom. Three things must be true for a message to leave this way, and each absence is a different repair: The number arrives through identify, in international form:
It is an identity of the contact, beside the email and your own external_id — so the merge and the erasure already know about it. But it never resolves a contact: operators recycle numbers, and whoever gets a reused one is not the person who had it. A number is attached when it is free and ignored when it already belongs to somebody — a message that does not go out, rather than a message that goes to the wrong stranger.

Your user reads it

The feed is read inside one team

listNotifications() answers the active team’s messages plus the ones addressed to the person themselves. Switching teams is a different read, not a filter over the same rows:
<NotificationBell /> follows the switch on its own — the feed restarts and the badge with it. The team travels in X-Customer-Id, and absent it the contact’s oldest membership answers, exactly as it does everywhere else on this plane; naming a team the person does not belong to is a 404 rather than a shorter list, because an answer that is quietly narrow is worse than an error. A contact who belongs to no team reads their whole feed. markAllNotificationsRead() is scoped the same way, and that is the point rather than a detail: somebody clearing the badge on a screen showing one team must not mark another team’s messages read, because nothing will ever draw them as unread again. The teamless ones are cleared with it — they were on the screen that was just read. customer_id on each row is what labels it, since the filtering already happened upstream. source says which machinery produced the row and category says which consent governed it. They read as one field today and are not: a campaign step can write here too, and a dunning notice is a campaign nobody may unsubscribe from. unread is always everything unread, never the length of this page. The list is capped at 50, so a badge derived from it would say 50 to somebody who has 300 — and ?unread=true filters the list while leaving the count alone, which is what lets one request draw both the bell and the panel. Marking is idempotent by the statement rather than by a check made first: reading twice is reading once and the first timestamp stands. read-all answers 204 and deliberately not a count — “how many did I just clear” is a fact about the race with whatever arrived while the request was in flight, and a UI rendering it would be rendering that race. A notification addressed to somebody else answers 404 rather than being ignored. The contact is part of the write’s own condition, so there is no id from another person’s feed that could match. url is null when the notification leads nowhere — null rather than absent, so “leads nowhere” and “this version of the API does not say” stay different answers to a client choosing between a link and a line of text. Opening the panel does not mark anything read, which is the difference from the changelog badge. A release note is one announcement everybody gets, so opening the list is honestly the act of reading it; a notification is addressed to one person about one thing, and clearing ten of them because somebody glanced at a dropdown loses exactly the state they came to check.

The bell, shaped like your header

<NotificationBell /> comes two ways. The default is the button with the word and the count; variant="icon" is a bell glyph with no border, a dot when something is unread and nothing when it is not.
The dot carries no number on purpose: in a header, the question somebody scanning has is “is there something?”, and a number inside a 1rem circle is either unreadable or “9+”, which answers it worse. The count stays on the button’s accessible name, where it costs nothing.

Opening it from anywhere

The panel is commanded from anywhere on the page, with no provider in between:
Outside React — a keyboard shortcut, a link inside an empty state — the same methods are on the exported object:
available is what you check before drawing a button of your own: it is false with no bell mounted and false for somebody with no session, which is exactly when opening the panel would open an empty one.

Your header already has a bell

Then hide ours and keep the panel:
showBell is the INITIAL answer; notificationBell.hideBell() and showBell() outrank it from the moment they are called, so the next render of a component that still passes the prop cannot undo a decision somebody made. resetBell() hands it back to the prop, and it is the only way back. From a script tag:
subscribe is called at once with what is known now — the empty reading before the bundle has landed — and then on every change; it returns the unsubscribe synchronously, and it is the only method here that is not a promise. Build a button of your own on it rather than on a clock: getState() is one reading, and polling it is a timer for the life of the tab to mirror a store that already publishes every change. Calling before anything is mounted does not lose the command: the bundle is fetched and the request is recorded, so the panel opens as soon as a bell mounts. UserKit.notifications is on both scripts — userkit.js and widget.js install the same window.UserKit, so swapping one for the other takes no method off the page.

The gate

This read needs a verified session — the sharpest version of the reason the changelog and the survey give. Those leak whether somebody is in a segment; this leaks what your product told one named person. An unverified identified session is an assertion anybody who knows an external_id can make from a browser, and this endpoint’s whole answer is somebody else’s mail. An anonymous session passes the gate and reads an empty list, which is true rather than a refusal: nothing addresses a visitor, because a notification names a contact_id or an external_id and a visitor has neither. In proxy mode the three routes are forwarded by @userkit/nextjs like the rest of the contact surface, so the same client code works with the session in an httpOnly cookie on your own origin.

Campaigns land here too

An in_app campaign step writes into the same feed, and it is the one difference worth knowing about: a campaign-produced row is governed by the consent its template declares, so somebody who switched that category off does not receive it. The row takes the campaign’s name as its title, because an in-app template has no subject.

No webhook

notification.created is published internally and is deliberately not delivered. It would be an echo: the fact exists because your own backend asked for it a moment ago, so a delivery would hand your request back to you with nothing added — and the first thing anybody builds on that is a loop. Push is not here. It arrives when there is a mobile SDK to receive it, not before.