# 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](/docs/push/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](/docs/billing#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.

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

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

```tsx
"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)

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

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

```json
{
  "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
    }
  ]
}
```

- `take` defaults to 50 and must be an integer 1..200 (a larger value is a `400`, never a silent cap); `skip` defaults to 0. `total` and `unread` cover 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](/docs/push/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`:

```js
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](/docs/setup/push-data)):

```json
{
  "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:

```text
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](/docs/push/app-keys).
