# Web notification inbox

Add a notification bell with an unread badge and an inbox panel to a website — plain JS, React or Next.js.

> **Note:**
>
> The bell is the in-app history; [web push](/docs/push/web-push) wakes the browser. Set up web push first — the bell stays empty until the browser registers.

## Add the bell

The widget is three files in `native-notify/web/`: `native-notify-bell.js` (plain JS), `NativeNotifyBell.jsx` (React and the headless hook) and `nativeNotifyBell.css`. Drop them into your site, or ask Agent Notify to add them in a pull request.

<!-- tabs -->

#### React

```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 — in `app/layout.tsx` (App Router) or `pages/_app.tsx` (Pages Router):

```tsx
import "../native-notify/web/nativeNotifyBell.css";
```

#### Plain JS

```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` takes an element or a selector. The stylesheet pins `.nn-bell` to a corner with `position: fixed`; override it in your CSS to place the bell inline:

```js
mountNativeNotifyBell({ appId, webKey, mount: "#header-notifications" });
```

<!-- /tabs -->

## Build your own UI

The headless hook returns the same data and actions. It loads the unread count itself — call `refresh()` for the first page of entries:

```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>
  );
}
```

It returns `deviceId`, `entries`, `total`, `unreadCount`, `loading`, `error` and the actions `refresh`, `refreshUnread`, `markRead(entry)`, `markAllRead`, `remove(entry)`, `clearInbox` and `loadMore`.

## Choose the device

The bell shows one device's inbox — by default this browser's, whose id lives in `localStorage` under `nn_web_device_id`, the key the [web push](/docs/push/web-push) snippet registers under. To show another device, pass its id; plain JS resolves it with `getDeviceId`:

```js
mountNativeNotifyBell({
  appId, webKey,
  getDeviceId: async () => (await fetch("/api/me/device-id").then((r) => r.json())).deviceId,
});
```

The web key reads only devices it registered, so a native device answers `403 web_key_scope`.

## Options

React takes these as props. Plain JS (`mountNativeNotifyBell`, `createNativeNotifyBell`) takes the same names as options.

| Option                                                           | Default                        | What it does                                                                                                                       |
| ---------------------------------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| `appId`, `webKey`                                                | —                              | Required. `webKey` is the publishable web key.                                                                                     |
| `deviceId`                                                       | per-browser key                | Whose inbox to show. React takes the resolved id here.                                                                             |
| `getDeviceId`                                                    | —                              | Plain JS only. `async () => deviceId`.                                                                                             |
| `storageKey`                                                     | `"nn_web_device_id"`           | `localStorage` key of the 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`                         | `false` shows a plain dot.                                                                                                         |
| `maxCount`                                                       | `99`                           | Badge cap, shown as `99+`.                                                                                                         |
| `allowDelete`                                                    | `true`                         | Show the delete button on each row.                                                                                                |
| `pollMs`                                                         | `30000`                        | Unread poll interval in milliseconds. `0` disables polling; use `10000` or more.                                                   |
| `theme`                                                          | light and dark defaults        | Color overrides, like `{ accent: "#2563eb", dot: "#ef4444", radius: "14px" }`. Each key sets a CSS variable such as `--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.                                                                                  |
| `showLoadMore`                                                   | `true`                         | Plain JS only. A **Load more** button when more pages exist.                                                                       |
| `onNotificationPress`, `onNavigate`, `onUnreadChange`, `onError` | —                              | Callbacks: every row click, a deep-link handler `(url, entry)`, the unread count on change, and request errors (plain JS only).    |
| `className`                                                      | —                              | React only. Extra class on the root element.                                                                                       |

## Deep links

Put a `url` in the send's `pushData`:

```json
{
  "title": "New service times",
  "message": "This Sunday: 9am and 11am.",
  "pushData": { "url": "/news/service-times" }
}
```

A click marks the row read, then opens the URL. Only same-origin paths and `http(s)` URLs open; anything else is ignored. Pass `onNavigate` to route client-side.

## The inbox API

The widget calls the six [inbox endpoints](/docs/push/mobile-inbox#endpoints) with the web key where an app token would go — in the URL for GETs, in the body for POSTs. Your side of the same data — every send with its read counts, and who is registered — is in the dashboard's **Inbox** and **Audience** tabs.

> **Plans:**
>
> On Premium every inbox call answers `403 plan_upgrade_required`, and the panel shows that message to your visitors. Sends still write entries, so they appear after an upgrade. See [what each plan includes](/docs/billing#what-each-plan-includes).

A browser that hasn't registered with the web key answers `403 web_key_scope`; the widget shows an empty inbox. Every other code is in [Mobile notification inbox](/docs/push/mobile-inbox#errors).

## The web key

The widget's credential is visible in your page, so it uses the publishable web key: it can register one browser device and read or mark that device's own inbox — never send, list groups or subscribers, or touch another device. Copy it from **App Settings → App keys**, or read it with a dashboard session or MCP token:

```text
GET https://app.nativenotify.com/api/apps/<APP_ID>/web-key
```

To keep even the web key out of your page, proxy the six calls through your own API and set `apiBase`. See [App keys & token rotation](/docs/push/app-keys).
