Skip to content

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.

  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.
  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 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>
AttributeDefaultWhat it does
data-keyrequiredYour publishable key (nv_pub_live_...). Never the secret key.
data-user-idrequiredThe signed-in user’s id, the same user_id your server sends to.
data-user-hashrequiredHex HMAC-SHA256 of the user id with the project’s signing secret, made on your server.
data-targetnoneA CSS selector. The bell is added inside that element, for example a slot in your header. Without it the bell floats.
data-positionbottom-rightWhere the floating bell sits: bottom-right, bottom-left, top-right or top-left. Ignored with data-target.
data-bellshownnone draws no button at all: open the panel from your own button with the JavaScript API below.
data-accent#4f46e5A 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-themelightlight, dark, or auto to follow the visitor’s system setting.
data-badgecountcount shows the number (9+ past nine); dot shows a dot only.
data-titleNotificationsThe panel’s heading.
data-emptyYou're all caught upWhat the panel says when there is nothing to show.
data-mark-all-labelMark all readThe label of the button that marks everything read.
data-whats-new-labelWhat's newThe small label above a notification that a changelog entry posted.
data-pinned-labelPinnedThe small label on a pinned notification (1.3.0).
data-limit20How many notifications the panel loads, 1 to 50.
data-categoriesnoneTabs after All, one per category: billing:Billing,updates:Product updates (a name, then its label).
data-archived-labelArchivedThe label of the filter that lists archived notifications.
data-archive-labelArchiveThe archive button’s label, for screen readers and its tooltip.
data-restore-labelMove backThe 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.

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.

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

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.

// 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',
}),
});
FieldRequiredDescription
user_idyes, unless audience is "all"Your app’s id for the person.
audienceno"user" (the default) or "all".
titleyesUp to 200 characters.
bodyyesUp to 2,000 characters, plain text.
action_urlnoAn http(s) URL, or a path on your own site such as /billing. Other schemes are refused.
iconnocheck, warning, info (the default) or error.
categorynoA short lower-case name, such as billing. The bell widget can show a tab per category (data-categories).
actionsnoUp to two buttons, [{ "label": "View invoice", "url": "/invoices/42" }]. Each url follows the action_url rule; labels up to 40 characters.
datanoA JSON object your page gets with the notification (at most 2,048 bytes). Never shown.
send_atnoShow it from this ISO time, at most 60 days ahead. See Schedule a notification.
reactionsnoLet 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.
pinnednotrue 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.

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:

FieldWhat it is
user_idsYour own user ids, up to 1,000.
emailsEmail addresses, up to 1,000.
contact_idsContacts from GET /v1/contacts, up to 1,000.
contactsA 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.

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.

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

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.

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

EndpointWhat 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}/readMarks one read for this user. A notification that is not theirs answers 404.
PATCH /v1/notifications/{id}/clickThis user clicked it or one of its buttons. Also marks it read.
PATCH /v1/notifications/{id}/archiveArchives it for this user; { "archived": false } brings it back.
PATCH /v1/notifications/read-allMarks 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.

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.

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

PlanNotifications a month, per projectBell polls everyBell reads per user per hour
Free1,00060 seconds180
Indie20,00020 seconds540
Builder200,00015 seconds720

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.

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.

ErrorCauseFix
403 USER_HASH_INVALIDThe browser sent no user_hash, a wrong one, or the project has no signing secret yetSee Identity verification.
403 PLAN_LIMIT_REACHEDThe month’s notifications are used upWait for the 1st (UTC) or upgrade at launch.nordva.dev/pricing.
422 NO_REACHABLE_RECIPIENTSNobody in the group has a user id Launch knowsCall identify() with the user id and the email when people sign in, or send user_ids.
422 TOO_MANY_RECIPIENTSThe group is over 10,000 peopleNarrow the filter, or send a broadcast.
403 KEY_INSUFFICIENT_PERMISSIONSA publishable key was used to send, list or deleteSend from your server with the secret key.
400 VALIDATION_ERRORuser_id and audience: "all" together, or neitherSend one of them.