Web Notification Inbox
Add a notification bell with an unread badge and a popup inbox panel to any web site — plain JS, React or Next.js. List, unread count, read/unread, delete and deep links for each browser's inbox.
The web bell + inbox is a drop-in widget for your site. A bell with an unread badge, and an inbox panel: list, read/unread, delete, deep links. It reads the inbox API for one browser device. Plain JS, React and Next.js. No dependencies, no build step.
Pair it with browser push:
The widget is the in-app history. Web Push wakes the browser — set it up first: one service worker, one registration call.
A Pro plan feature:
The notification inbox is included in Free, Pro and Team. On Premium every inbox call answers 403 plan_upgrade_required, so the widget shows nothing. Sends keep writing inbox entries on every plan — they appear after an upgrade. See what each plan includes.
Quick start — React / Next.js
The widget is three files — native-notify/web/native-notify-bell.js (plain JS), native-notify/web/NativeNotifyBell.jsx (React + the headless hook) and native-notify/web/nativeNotifyBell.css. Drop them into your site, or ask Agent Notify to add the bell to your repo and review the pull request it opens.
// app/site-header.tsx
"use client";
import NativeNotifyBell from "../native-notify/web/NativeNotifyBell";
export function SiteHeader() {
return (
<header>
{/* your header… */}
<NativeNotifyBell appId={123} webKey="nnweb_…" title="Notifications" />
</header>
);
}
Import the stylesheet once for the whole site — in the App Router's root layout (app/layout.tsx), or pages/_app.tsx in the Pages Router:
import "../native-notify/web/nativeNotifyBell.css";
For your own UI instead of the built-in panel, use the headless hook — the same data and actions. It loads nothing on its own: call refresh() for the first page (it also sets unreadCount):
"use client";
import { useEffect } from "react";
import { useNativeNotifyInbox } from "../native-notify/web/NativeNotifyBell";
export function InboxList() {
const inbox = useNativeNotifyInbox({ appId: 123, webKey: "nnweb_…", take: 20 });
const { refresh } = inbox;
// Fetch page 1 — this re-runs once the per-browser device id has resolved.
useEffect(() => { refresh(); }, [refresh]);
if (inbox.error) return <p role="alert">{inbox.error}</p>;
return (
<ul>
{inbox.entries.map((entry) => (
<li key={entry.entryId}>
<button type="button" onClick={() => inbox.markRead(entry)}>
{entry.read ? "" : "• "}{entry.title}
</button>
</li>
))}
</ul>
);
}
The hook returns deviceId, entries, total, unreadCount, loading, error and the actions refresh, refreshUnread, markRead(entry), markAllRead, remove(entry), clearInbox and loadMore.
Quick start — plain JS (any site)
<link rel="stylesheet" href="/native-notify/web/nativeNotifyBell.css" />
<script type="module">
import { mountNativeNotifyBell } from "/native-notify/web/native-notify-bell.js";
mountNativeNotifyBell({
appId: window.NN_CONFIG.appId, // your app id
webKey: window.NN_CONFIG.webKey, // the publishable web key — safe in page source
title: "Notifications",
position: "bottom-right", // or mount into your own header
});
</script>
mount accepts an element or a selector, so the bell can live in your header instead of floating in a corner:
mountNativeNotifyBell({ appId, webKey, mount: "#header-notifications" });
The inbox API behind it
The widget is a thin client of the inbox — one inbox per device, keyed by the same stable deviceId registration uses. All endpoints authenticate with your app id + publishable web key (the web key rides where an app token would go: the GETs carry it in the URL, the POSTs in the body):
| Call | Endpoint |
|---|---|
| List | GET /api/universal/inbox/:appId/:appToken?deviceId=&take=&skip= |
| Unread count | GET /api/universal/inbox/:appId/:appToken/unread-count?deviceId= |
| Mark one read | POST /api/universal/inbox/read — { appId, appToken, deviceId, entryId } |
| Mark all read | POST /api/universal/inbox/read-all — { appId, appToken, deviceId } |
| Delete one | POST /api/universal/inbox/delete — { appId, appToken, deviceId, entryId } (soft, idempotent) |
| Clear | POST /api/universal/inbox/clear — { appId, appToken, deviceId } (soft, idempotent) |
The list answers 200 with the device's own entries, newest first:
{
"ok": true,
"deviceId": "device-abc",
"take": 50,
"skip": 0,
"total": 12,
"unread": 3,
"entries": [
{
"entryId": 4181,
"appId": 123,
"deviceId": "device-abc",
"subscriberId": null,
"environment": "production",
"title": "New service times",
"body": "This Sunday: 9am and 11am.",
"data": { "url": "/news/service-times" },
"audienceType": "all",
"source": "api",
"sentAt": "2026-09-24T14:03:11.000Z",
"readAt": null,
"read": false
}
]
}
takedefaults to 50 and must be an integer 1..200 (a larger value is a400, never a silent cap);skipdefaults to 0.totalandunreadcover the device's whole visible inbox, so "load more" is exact.- Only the requesting device's own entries are ever returned.
- Every universal send writes one entry per targeted device, in the background — an entry can appear a moment after the send response.
The other calls answer 200 too:
| Call | Response |
|---|---|
| Unread count | { "ok": true, "deviceId", "unread" } |
| Mark one read | { "ok": true, "entryId", "read": true, "readAt" } — marking it again keeps the first readAt |
| Mark all read | { "ok": true, "deviceId", "marked" } — how many entries changed |
| Delete one | { "ok": true, "entryId", "removed" } — removed: false when it was already deleted |
| Clear | { "ok": true, "deviceId", "removed" } — how many entries were cleared |
Errors are machine-readable: { "error": { "code", "message", "field"? } } — 400 for missing_field / invalid_field / invalid_device_id / invalid_entry_id / invalid_body, 403 plan_upgrade_required, 404 entry_not_found (an entry that does not exist for this device, or marking a deleted one read) and 404 app_not_found. The owner's side of the same data — every send with read counts, and who is registered — is in the dashboard's Inbox and Audience tabs.
Which deviceId does a browser use?
A browser registers itself as a web device when the site runs the Web Push subscribe snippet. That snippet stores its device id under nn_web_device_id, the same key this widget defaults to — the bell shows exactly the pushes this browser receives. To show the same inbox as your app instead, pass the device id your backend knows for the logged-in user. Plain JS can resolve it with getDeviceId:
mountNativeNotifyBell({
appId, webKey,
getDeviceId: async () => (await fetch("/api/me/device-id").then((r) => r.json())).deviceId,
});
The React component and hook take the resolved value as deviceId (there is no getDeviceId prop). The per-browser default id is persisted in localStorage under nn_web_device_id — change the key with storageKey.
Options
The React component takes these as props; the plain-JS mountNativeNotifyBell / createNativeNotifyBell take the same names in their options object.
| Option | Default | What it does |
|---|---|---|
appId / webKey | — | Required. webKey is the app's publishable web key (dashboard → App keys) — safe in page source. |
deviceId | per-browser key | Whose inbox to show. |
getDeviceId | — | Plain JS only: async () => deviceId, resolved before the first request. |
storageKey | "nn_web_device_id" | localStorage key of the per-browser default id. |
apiBase | https://app.nativenotify.com | Point the widget at your own proxy. |
take | 20 | Rows per page (max 200). |
title | "Notifications" | Panel header. |
emptyText | "You are all caught up." | Empty state. |
showCount | true | Numeric badge; false shows a plain dot. |
maxCount | 99 | Badge cap (99+). |
allowDelete | true | Show the per-row delete button. |
pollMs | 30000 | Unread poll interval; 0 disables polling (minimum 10000). |
theme | light/dark defaults | Color override — theme: { accent: "#2563eb", dot: "#ef4444", radius: "14px" } maps onto CSS custom properties like --nn-accent. |
position | "bottom-right" | Floating corner: bottom-right, bottom-left, top-right or top-left. |
mount | <body> | Plain JS only: element or selector to mount into (your header). |
showLoadMore | true | Plain JS only: a Load more button when more pages exist. |
onNotificationPress / onNavigate / onUnreadChange / onError | — | Callbacks: every row click, a custom deep-link handler (url, entry), the unread count on change, and request errors (plain JS). |
className | — | React only: extra class on the root element. |
Deep links
Put a url inside the notification's pushData — the same key the mobile SDKs use (see Push Data & Taps):
{
"title": "New service times",
"message": "This Sunday: 9am and 11am.",
"pushData": { "url": "/news/service-times" }
}
Clicking the row marks it read and then opens the URL — same-origin paths and http(s) URLs only; anything else is ignored. Pass onNavigate when your app prefers client-side routing.
The credential on the client
The widget calls the API from the browser, so its credential is visible in your page — that is exactly why the publishable web key exists: it can only register/deregister one browser device and read or mark that device's own inbox. It can never send, list groups or subscribers, or touch another device. Read it on the dashboard under App Settings → App keys, or:
GET https://app.nativenotify.com/api/apps/<APP_ID>/web-key
If your policy requires even the web key to stay server-side, proxy the six calls above through your own API — the widget accepts apiBase. Full details: App Keys & Token Rotation.