> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# In-app notifications

> Your backend telling one of your own users something inside your product — transactional by construction, and read by them with their own session.

"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.

```bash theme={null}
curl -X POST https://api.userkit.dev/v1/notifications \
  -H "Authorization: Bearer uk_sk_live_…" \
  -H "Idempotency-Key: card-declined-8421-2026-08" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "user_8421",
    "template": "invoice_due",
    "kind": "action",
    "data": {
      "invoice_id": "inv_5512",
      "amount": "$149.00",
      "due": "Sep 5"
    }
  }'
```

The template holds the title and the body, with the blanks in braces:

| Field         | What it holds                                                                                                                   |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| **Key**       | `invoice_due` — what your backend calls this message. It survives the day somebody renames the template, which a name does not. |
| **Title**     | `Invoice {{.Data.invoice_id}} is due {{.Data.due}}`                                                                             |
| **Body**      | `Hi{{if .Contact.Name}} {{.Contact.Name}}{{end}}. It is {{.Data.amount}}.`                                                      |
| **Variables** | `invoice_id, amount, due`                                                                                                       |

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.

```json theme={null}
{ "error": { "code": "missing_template_data", "message": "data is missing: due" } }
```

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](/en/guides/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:

| Shape                          | Example                               | What happens                                                                                                     |
| ------------------------------ | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| A path rooted at `/`           | `/invoices/inv_123`                   | Opens in place, wherever the person already is.                                                                  |
| An absolute `http`/`https` URL | `https://billing.example.com/inv_123` | Same origin as the page opens in place; **another origin opens in a new tab**, with `rel="noopener noreferrer"`. |

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:

```tsx theme={null}
import { useRouter } from "next/navigation";

const router = useRouter();

<NotificationBell onNavigate={(url) => router.push(url)} />;
```

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](/en/guides/messages) 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](/en/api-reference/rate-limits) 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:

```json theme={null}
{
  "external_id": "user_8421",
  "customer_external_id": "team-acme",
  "template": "invoice_due",
  "data": { "invoice_id": "inv_5512", "amount": "$149.00", "due": "Sep 5" }
}
```

`customer_id` is our id and `customer_external_id` is yours — the same pair
[`POST /v1/track`](/en/guides/analytics) 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.

```json theme={null}
{ "external_id": "user_8421", "title": "Your card was declined", "kind": "action" }
```

`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.

```bash theme={null}
curl -X POST https://api.userkit.dev/v1/notifications \
  -H "Authorization: Bearer uk_sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "user_8421",
    "title": "Your card was declined",
    "body": "The August charge did not go through.",
    "url": "https://app.example.com/invoices/inv_5512",
    "kind": "action",
    "channels": ["in_app", "email", "whatsapp"],
    "email": { "subject": "Update your card" },
    "whatsapp": { "text": "Hi! The August charge did not go through — update your card at https://app.example.com/invoices/inv_5512" }
  }'
```

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:

```json theme={null}
{
  "notification": { "id": "…", "kind": "action", "…": "…" },
  "channels": {
    "in_app": { "status": "created", "id": "…" },
    "email": { "status": "queued" },
    "whatsapp": { "status": "skipped", "reason": "the contact has no phone number" }
  }
}
```

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:

| What is missing                       | What the response says                              | The repair                               |
| ------------------------------------- | --------------------------------------------------- | ---------------------------------------- |
| A server connected on the environment | `this environment has no whatsapp server connected` | Connect it in Settings → WhatsApp        |
| `phone` on the contact                | `the contact has no phone number`                   | Send `phone` on `POST /v1/contacts`      |
| The phone still paired                | the send fails at delivery and is recorded          | Scan the QR code again on your Evolution |

The number arrives through identify, in international form:

```json theme={null}
{ "external_id": "user_8421", "email": "ana@example.com", "phone": "5511987654321", "customer": { "external_id": "team-1", "name": "Team" } }
```

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

```ts theme={null}
const { notifications, unread } = await userkit.listNotifications();

await userkit.markNotificationRead(id);
await userkit.markAllNotificationsRead();
```

```tsx theme={null}
import { NotificationBell } from "@userkit/react";

<NotificationBell />;
```

```json theme={null}
{
  "notifications": [
    {
      "id": "…",
      "title": "Your export is ready",
      "body": "The Q3 export finished and is available for the next seven days.",
      "url": "/exports/exp_5512",
      "category": "transactional",
      "source": "transactional",
      "kind": "update",
      "customer_id": null,
      "read": false,
      "read_at": null,
      "created_at": "2026-08-01T14:02:00Z"
    }
  ],
  "unread": 3
}
```

### 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:

```ts theme={null}
userkit.setActiveCustomer(acmeId);
const { notifications, unread } = await userkit.listNotifications();
```

`<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](/en/guides/changelog). 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.

```tsx theme={null}
<NotificationBell variant="icon" />
```

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:

```tsx theme={null}
import { useNotificationBell } from "@userkit/react";

function MyBell() {
  const bell = useNotificationBell();
  if (!bell.available) return null;
  return (
    <button onClick={bell.show}>
      Alerts {bell.unread > 0 ? `(${bell.unread})` : ""}
    </button>
  );
}
```

Outside React — a keyboard shortcut, a link inside an empty state — the same
methods are on the exported object:

```ts theme={null}
import { notificationBell } from "@userkit/react";

notificationBell.show();
notificationBell.hide();
notificationBell.toggle();
```

`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:

```tsx theme={null}
<NotificationBell bell={false} />
```

`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:

```html theme={null}
<div data-userkit="NotificationBell" data-bell="false"></div>
<button id="alerts" hidden>Alerts <span></span></button>
<script>
  UserKit.notifications.subscribe(({ unread, available }) => {
    const button = document.getElementById("alerts");
    button.hidden = !available;
    button.querySelector("span").textContent = unread > 0 ? unread : "";
  });
  document.getElementById("alerts").onclick = () => UserKit.notifications.show();
</script>
```

`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](/en/customer-auth/session-tokens) 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](/en/guides/campaigns) 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.
