# In-app notifications

> Send a notification to one of your users or to everyone, show it in a bell, post changelog entries to it, and the monthly limits per plan.

In-app notifications are the messages behind a bell in your product: "Your export is ready" for one person, "New: dark mode" for everyone. Your server sends them with a secret key; the browser reads them with a publishable key, for the signed-in user it proves it is. Every plan includes them.

## How it works

1. Your server calls `POST /v1/notifications` with a secret key: to one `user_id`, or to everyone with `audience: "all"`.
2. The page asks for the signed-in user's notifications with a publishable key, the `user_id` and its `user_hash`, a signature your server computes so one user cannot read another's. See [Identity verification](/guides/notifications-identity/).
3. When the user opens a notification, the page marks it read for that user.

`user_id` is your own id for the person, any string up to 256 characters. Launch keeps no user list: it knows a user id only when you send to it or when that user's bell asks.

## The bell widget

The quickest way to show notifications is one tag. It draws a bell with the unread count, opens a panel with the user's notifications, marks them read when they are clicked, and shows reaction buttons on the ones that take reactions (1.4.0). Nothing to build.

```html
<script
  src="https://cdn.nordva.dev/v1/notifications.js"
  data-key="nv_pub_live_YOUR_KEY"
  data-user-id="user_123"
  data-user-hash="HEX_HMAC_FROM_YOUR_SERVER"
  async
></script>
```

`data-user-id` is the signed-in user's id and `data-user-hash` its HMAC, both written into the page by your server. How to compute the hash, and create the signing secret it needs, is in [Identity verification](/guides/notifications-identity/). Without a matching hash the bell stays quiet and logs a warning in the console saying what to fix.

With no other attribute you get a round bell floating in the bottom right corner. To put it in your own header instead, give an element and point `data-target` at it:

```html
<header>
  <a href="/">Acme</a>
  <nav>…</nav>
  <div id="bell"></div>
</header>

<script
  src="https://cdn.nordva.dev/v1/notifications.js"
  data-key="nv_pub_live_YOUR_KEY"
  data-user-id="user_123"
  data-user-hash="HEX_HMAC_FROM_YOUR_SERVER"
  data-target="#bell"
  async
></script>
```

| Attribute | Default | What it does |
|---|---|---|
| `data-key` | required | Your publishable key (`nv_pub_live_...`). Never the secret key. |
| `data-user-id` | required | The signed-in user's id, the same `user_id` your server sends to. |
| `data-user-hash` | required | Hex HMAC-SHA256 of the user id with the project's signing secret, made on your server. |
| `data-target` | none | A CSS selector. The bell is added inside that element, for example a slot in your header. Without it the bell floats. |
| `data-position` | `bottom-right` | Where the floating bell sits: `bottom-right`, `bottom-left`, `top-right` or `top-left`. Ignored with `data-target`. |
| `data-bell` | shown | `none` draws no button at all: open the panel from your own button with the JavaScript API below. |
| `data-accent` | `#4f46e5` | A hex, `rgb()` or `hsl()` colour, or a colour name, for the badge, the unread dot and the "new" pill. Anything else falls back to the default. |
| `data-theme` | `light` | `light`, `dark`, or `auto` to follow the visitor's system setting. |
| `data-badge` | `count` | `count` shows the number (`9+` past nine); `dot` shows a dot only. |
| `data-title` | `Notifications` | The panel's heading. |
| `data-empty` | `You're all caught up` | What the panel says when there is nothing to show. |
| `data-mark-all-label` | `Mark all read` | The label of the button that marks everything read. |
| `data-whats-new-label` | `What's new` | The small label above a notification that a changelog entry posted. |
| `data-pinned-label` | `Pinned` | The small label on a pinned notification (1.3.0). |
| `data-limit` | `20` | How many notifications the panel loads, 1 to 50. |
| `data-categories` | none | Tabs after **All**, one per category: `billing:Billing,updates:Product updates` (a name, then its label). |
| `data-archived-label` | `Archived` | The label of the filter that lists archived notifications. |
| `data-archive-label` | `Archive` | The archive button's label, for screen readers and its tooltip. |
| `data-restore-label` | `Move back` | The same for the button that brings an archived notification back. |

How it behaves:

- On load it asks for the unread count, then again every `poll_interval_seconds` the API returns (60 seconds on Free, 20 on Indie, 15 on Builder), and only while the tab is visible. When the tab or the window comes back into view it asks at once, so a new notification shows without waiting. The full list loads when the panel opens.
- On a desktop the panel opens under the bell, up to 392px wide. Under 640px of screen width it opens as a sheet from the bottom of the screen.
- The panel has **All**, **Unread** and **Archived** filters and a **Mark all read** button. Clicking a notification, or one of its buttons, marks it read, counts the click and follows its link: a path on your site opens in the same tab, another site in a new one. Each row has an archive button; an archived notification leaves that person's list and count, and **Archived** lists them with a button to bring one back.
- A click fires a `nordva:notification-click` event on `window`, with `detail` = `{ id, category, action, data }` (`action` is the clicked button's `{ label, url }`, or null for the notification itself), so your page can act on the notification's `data`.
- Escape closes the panel and puts focus back on the bell; the bell is a real button, and each notification with a link is a real link.
- A `429` makes it wait as long as `Retry-After` says. A `403 USER_HASH_INVALID` stops it for the page, with the console warning.
- Titles and bodies are shown as plain text, never as HTML.

### Taking the bell off the page

The script tag's `window.NordvaNotifications.destroy()` (1.2.0) removes the bell and its listeners, so a new tag can start one for another user.

### Your own bell button

If your design already has a bell, keep it: add `data-bell="none"` and drive the panel from your button. The widget tells you the unread count with an event, so your button can show it.

```html
<button id="my-bell" type="button">Notifications <span id="my-count"></span></button>

<script
  src="https://cdn.nordva.dev/v1/notifications.js"
  data-key="nv_pub_live_YOUR_KEY"
  data-user-id="user_123"
  data-user-hash="HEX_HMAC_FROM_YOUR_SERVER"
  data-bell="none"
  async
></script>
<script>
  const bell = document.getElementById("my-bell");
  bell.addEventListener("click", () => window.NordvaNotifications.toggle(bell));
  window.addEventListener("nordva:notifications", (e) => {
    document.getElementById("my-count").textContent = e.detail.unread_count || "";
  });
</script>
```

| Call | What it does |
|---|---|
| `NordvaNotifications.open(anchor?)` | Opens the panel, next to `anchor` (an element) when you pass one, else next to the widget's own bell. |
| `NordvaNotifications.close()` | Closes it. |
| `NordvaNotifications.toggle(anchor?)` | Opens or closes it. |
| `NordvaNotifications.refresh()` | Asks for the count (and the list, when open) now, for example right after your server sent one. |

The `nordva:notifications` event fires on `window` whenever the unread count changes, with `event.detail.unread_count`.

### Content Security Policy

The widget styles itself with a constructed stylesheet inside its own shadow root, so it works under `style-src 'self'` with no `'unsafe-inline'`. A strict policy needs `script-src https://cdn.nordva.dev` and `connect-src https://api.nordva.dev`. See [Content Security Policy](/guides/content-security-policy/).

To build the bell yourself instead, the endpoints are below, and [A notification bell in React](/guides/notifications-polling/) has a complete component.

## Send to one user

```typescript
// Server-side, with the secret key
await fetch('https://api.nordva.dev/v1/notifications', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NORDVA_SECRET_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    user_id: 'user_abc123',
    title: 'Export ready',
    body: 'Your CSV export has been generated.',
    action_url: '/exports',
    icon: 'check',
  }),
});
```

| Field | Required | Description |
|---|---|---|
| `user_id` | yes, unless `audience` is `"all"` | Your app's id for the person. |
| `audience` | no | `"user"` (the default) or `"all"`. |
| `title` | yes | Up to 200 characters. |
| `body` | yes | Up to 2,000 characters, plain text. |
| `action_url` | no | An `http(s)` URL, or a path on your own site such as `/billing`. Other schemes are refused. |
| `icon` | no | `check`, `warning`, `info` (the default) or `error`. |
| `category` | no | A short lower-case name, such as `billing`. The bell widget can show a tab per category (`data-categories`). |
| `actions` | no | Up to two buttons, `[{ "label": "View invoice", "url": "/invoices/42" }]`. Each `url` follows the `action_url` rule; labels up to 40 characters. |
| `data` | no | A JSON object your page gets with the notification (at most 2,048 bytes). Never shown. |
| `send_at` | no | Show it from this ISO time, at most 60 days ahead. See [Schedule a notification](#schedule-a-notification). |
| `reactions` | no | Let people react in the bell, one reaction each: `true` for your changelog reaction set (Settings, or `PATCH /v1/changelog/settings`), or the names in order from `like`, `celebrate`, `love`, `laugh`, `fire`, `eyes`, `confused`, `dislike` (at most 5). Counts come back in `stats.reactions`. A post a changelog entry makes takes the entry's reactions, and the bell and the changelog widget count together. |
| `pinned` | no | `true` keeps it at the top of the bell, read or not, until each person archives it: for maintenance or an outage. A pinned broadcast is not dropped after 30 days; like every notification it is deleted after 90. Delete it to take it down for everyone. |

The answer (201) is the notification, with `meta.usage` showing the month so far.

## Send to a group

Most of the time you know people by email, not by user id. Send any mix of these, and Launch works out who gets it:

| Field | What it is |
|---|---|
| `user_ids` | Your own user ids, up to 1,000. |
| `emails` | Email addresses, up to 1,000. |
| `contact_ids` | Contacts from `GET /v1/contacts`, up to 1,000. |
| `contacts` | A Contacts filter, with the same names and values as the `GET /v1/contacts` query: `{ "stage": "customer" }`, `{ "consent": "email_marketing" }`, `{ "detail": "plan=pro" }`, `{ "paying": "true" }`. |

```json
{ "contacts": { "stage": "customer" }, "title": "Thanks for being a customer", "body": "Here is what is new this month.", "category": "updates" }
```

A bell is found by your user id, so an email or a contact reaches someone only when Launch knows their user id. It learns it from three places: `identify()` called with both the user id and the email ([`nordva('identify', { user_id, email })`](/guides/web-analytics/#from-visit-to-paying-customer) in the browser, or `POST /v1/analytics/identify` from your server), feedback sent with both, and a paying customer's `user_id` metadata. The answer's `meta.audience` says how many people you asked for, how many were reached, and up to 20 addresses that could not be. When nobody can be reached the answer is `NO_REACHABLE_RECIPIENTS` (422) and nothing is sent. A group is at most 10,000 people (`TOO_MANY_RECIPIENTS`); for everyone, send a broadcast.

To see the numbers before sending, `POST /v1/notifications/audience` takes the same four fields and answers `people`, `reachable`, `unreachable` and `unreachable_sample`, without sending anything. In the dashboard, **Pick people** searches your contacts (someone with no user id is shown greyed out) and **A group** shows the numbers as you choose the filter.

A group is one notification, like a broadcast: it counts once against the month, and each person reads, clicks and archives it separately.

## Schedule a notification

Add `send_at` (an ISO time, at most 60 days ahead) to any of the three kinds. It stays out of every bell until then and shows as `scheduled: true` with its `visible_at`; `GET /v1/notifications?status=scheduled` lists the ones waiting. It counts toward the month in which you scheduled it. Deleting it before its time cancels it and gives that count back. A time in the past means now.

## Send to everyone

```json
{ "audience": "all", "title": "Scheduled maintenance", "body": "Sunday 02:00 to 03:00 UTC." }
```

Leave `user_id` out: sending both is a validation error, and so is sending neither, so a message meant for one person can never go to everyone by mistake.

A broadcast is one notification, not a copy per person. Each person reads it separately: marking it read for one user leaves it unread for the rest. It shows in a bell for **30 days** after it was sent, including for someone who signs in for the first time during those days. A user's own notifications stay until they are read or deleted.

## Changelog entries in the bell

A changelog entry with `notify_in_app: true` posts one broadcast when it publishes, whether at once or at its `scheduled_at`: the entry's title, the first 140 or so characters of its text, a link to the entry, and the `info` icon. It happens once per entry, however many times the entry is saved.

```json
{ "title": "Dark mode is here", "body_markdown": "You can now switch to dark mode in Settings.", "category": "feature", "notify_in_app": true }
```

When an entry leaves `notify_in_app` out, the project's `changelog_to_bell_default` setting decides (off unless you switch it on with `PATCH /v1/notifications/settings`). The broadcast counts toward the monthly limit like any other; if the month's limit is used up, the entry still publishes and no broadcast is made.

## The bell

These accept a publishable key with `user_id` and `user_hash`, or a secret key from your server.

| Endpoint | What it returns |
|---|---|
| `GET /v1/notifications/inbox?user_id=` | Newest first, read and unread: the user's own notifications and the broadcasts of the last 30 days, with `unread_count` and `poll_interval_seconds`. `limit` up to 50 (default 20), `since` for only newer ones. |
| `GET /v1/notifications/unread?user_id=` | The same, unread only. |
| `GET /v1/notifications/unread/count?user_id=` | `unread_count` alone, for the number on the bell. |
| `PATCH /v1/notifications/{id}/read` | Marks one read for this user. A notification that is not theirs answers 404. |
| `PATCH /v1/notifications/{id}/click` | This user clicked it or one of its buttons. Also marks it read. |
| `PATCH /v1/notifications/{id}/archive` | Archives it for this user; `{ "archived": false }` brings it back. |
| `PATCH /v1/notifications/read-all` | Marks everything read for this user, or one category with `category`. |

`inbox` also takes `category` (one tab) and `archived=only` (the archived ones; they are left out otherwise, and out of `unread_count`).

Each item in the list is small:

```json
{
  "id": "ntf_01J...",
  "title": "Export ready",
  "body": "Your CSV export has been generated.",
  "action_url": "/exports",
  "icon": "check",
  "category": null,
  "actions": [],
  "data": null,
  "created_at": "2026-09-26T10:00:00.000Z",
  "read": false,
  "read_at": null,
  "clicked": false,
  "archived": false,
  "broadcast": false
}
```

`user_id` goes in the query string (or, for the two `PATCH` calls, in the JSON body); `user_hash` in the `X-Nordva-User-Hash` header or a `user_hash` parameter. A React bell that uses all of this is in [A notification bell in React](/guides/notifications-polling/).

## What was sent, and who read it

With a secret key: `GET /v1/notifications` lists what was sent, newest first (filters `user_id`, which includes the groups that user was in, `audience`, `status` (`read`, `unread` or `scheduled`), `category`, `limit`, `cursor`), `GET /v1/notifications/{id}` returns one, and `DELETE /v1/notifications/{id}` deletes one, which removes it from every bell. The dashboard has the same operations under `/v1/dashboard/notifications`.

Each notification carries `stats`:

- `reached`: 1 for a notification to one user, and the group's size for a group. For a broadcast, the number of people whose bell asked for notifications after it showed. A visit is recorded at most once an hour per person, so this can lag by up to an hour; it is never lower than `read`. 0 while it is scheduled.
- `read`: how many people read it (a click or an archive counts as read).
- `clicked`: how many people clicked it or one of its buttons.
- `read_rate` and `click_rate`: over `reached`, to three decimals, or `null` while nobody has been reached.
- `reactions`: reaction name to count, for a notification that takes reactions; `null` otherwise.
- `active_7d`: for a broadcast, how many people's bell asked in the last 7 days. Compare `reached` with this, not with all your users: "reached 5 of 40 active" (the dashboard shows it beside Everyone). `null` for a direct or group notification.

## Plan limits

Notifications are counted per project per calendar month (UTC). A group or a broadcast counts once, however many people see it, and a deleted notification still counts for the month it was sent in (a scheduled one deleted before its time does not).

| Plan | Notifications a month, per project | Bell polls every | Bell reads per user per hour |
|---|---|---|---|
| Free | 1,000 | 60 seconds | 180 |
| Indie | 20,000 | 20 seconds | 540 |
| Builder | 200,000 | 15 seconds | 720 |

Past the monthly limit, `POST /v1/notifications` answers `PLAN_LIMIT_REACHED` (403) until the 1st. `GET /v1/notifications/usage` shows `month`, `sent`, `limit` and `remaining`.

The bell reads (`inbox`, `unread`, `unread/count`) share the hourly budget per user, which leaves room for a second open tab at the advertised `poll_interval_seconds`; past it they answer `RATE_LIMITED` (429) with `Retry-After`. Browser calls also count against 120 requests a minute per IP address rather than your key's budget, so many users polling do not use up your server's requests.

## Retention

Notifications, each person's read, click and archive record, the people a group went to, and the user ids whose bell asked are deleted 90 days after the notification showed. For a user id Launch keeps only the id you chose, when their bell last asked, and what they read, clicked or archived: no email, IP address or browser details. An email you send to reach someone is used to find their user id and is not stored with the notification.

## Common errors

| Error | Cause | Fix |
|---|---|---|
| `403 USER_HASH_INVALID` | The browser sent no `user_hash`, a wrong one, or the project has no signing secret yet | See [Identity verification](/guides/notifications-identity/). |
| `403 PLAN_LIMIT_REACHED` | The month's notifications are used up | Wait for the 1st (UTC) or upgrade at [launch.nordva.dev/pricing](https://launch.nordva.dev/pricing). |
| `422 NO_REACHABLE_RECIPIENTS` | Nobody in the group has a user id Launch knows | Call `identify()` with the user id and the email when people sign in, or send `user_ids`. |
| `422 TOO_MANY_RECIPIENTS` | The group is over 10,000 people | Narrow the filter, or send a broadcast. |
| `403 KEY_INSUFFICIENT_PERMISSIONS` | A publishable key was used to send, list or delete | Send from your server with the secret key. |
| `400 VALIDATION_ERROR` | `user_id` and `audience: "all"` together, or neither | Send one of them. |

Source: https://docs.nordva.dev/guides/notifications/
