In-app notifications
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
Section titled “How it works”- Your server calls
POST /v1/notificationswith a secret key: to oneuser_id, or to everyone withaudience: "all". - The page asks for the signed-in user’s notifications with a publishable key, the
user_idand itsuser_hash, a signature your server computes so one user cannot read another’s. See Identity verification. - 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
Section titled “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.
<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. 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:
<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_secondsthe 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-clickevent onwindow, withdetail={ id, category, action, data }(actionis the clicked button’s{ label, url }, or null for the notification itself), so your page can act on the notification’sdata. - 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
429makes it wait as long asRetry-Aftersays. A403 USER_HASH_INVALIDstops 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
Section titled “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
Section titled “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.
<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
Section titled “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.
To build the bell yourself instead, the endpoints are below, and A notification bell in React has a complete component.
Send to one user
Section titled “Send to one user”// Server-side, with the secret keyawait 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. |
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
Section titled “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" }. |
{ "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 }) 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
Section titled “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
Section titled “Send to everyone”{ "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
Section titled “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.
{ "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
Section titled “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:
{ "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.
What was sent, and who read it
Section titled “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 thanread. 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_rateandclick_rate: overreached, to three decimals, ornullwhile nobody has been reached.reactions: reaction name to count, for a notification that takes reactions;nullotherwise.active_7d: for a broadcast, how many people’s bell asked in the last 7 days. Comparereachedwith this, not with all your users: “reached 5 of 40 active” (the dashboard shows it beside Everyone).nullfor a direct or group notification.
Plan limits
Section titled “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
Section titled “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
Section titled “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. |
403 PLAN_LIMIT_REACHED | The month’s notifications are used up | Wait for the 1st (UTC) or upgrade at 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. |