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.
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.
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 isPATCH { "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. Aproduct_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:
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.
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:
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
Thewhatsapp 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:
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.
Opening it from anywhere
The panel is commanded from anywhere on the page, with no provider in between: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 anexternal_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
Anin_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.