# Overview

> Understand how better-push fits into your application and choose the right setup path.

Canonical documentation: /docs



better-push is an open-source TypeScript library for push and in-app
notifications. It runs in your application, uses your user IDs and sessions,
and stores its state in your Postgres database.

## How it fits together [#how-it-fits-together]

```mermaid
flowchart LR
  A[Your server code] -->|notify| B[Better Push]
  C[Browser or native client] -->|client requests| D[Mounted HTTP routes]
  D --> B
  B <--> E[Your Postgres database]
  B --> F[Push providers]
  F --> C
  B -.->|optional queue| G[Worker]
  G --> F
```

Postgres is authoritative for devices, notification content, preferences,
delivery attempts, jobs, and operational records. With no queue, `notify()`
sends inline. With a queue, it writes jobs for a worker. The client-facing API
continues to use the same routes and database in either configuration.

## Choose where to start [#choose-where-to-start]

| If you want to…                    | Follow this path                                                                                                           |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| See the full delivery loop locally | [Quickstart](/docs/quickstart) → [Emulator](/docs/emulator)                                                                |
| Integrate a specific server stack  | [Framework walkthroughs](/docs/walkthroughs) → [Sessions](/docs/sessions)                                                  |
| Add browser notifications          | [Web Push setup](/docs/web-push-setup) → [Test on devices](/docs/testing-on-devices)                                       |
| Add native mobile notifications    | [Choose a provider](/docs/providers) → [React Native](/docs/react-native)                                                  |
| Build a notification center        | [Typed notifications](/docs/typed-notifications) → [In-app feed](/docs/in-app-feed) → [Preferences](/docs/preferences)     |
| Prepare a production deployment    | [Deployment](/docs/deployment) → [Async delivery](/docs/async-delivery) → [Security](/docs/security)                       |
| Investigate delivery issues        | [CLI doctor](/docs/cli#diagnose-with-doctor) → [Studio](/docs/studio) → [Retries and failures](/docs/retries-and-failures) |

## Supported stack [#supported-stack]

| Layer     | Supported choices                                              |
| --------- | -------------------------------------------------------------- |
| Runtime   | Node.js 22 or newer                                            |
| Database  | PostgreSQL through `pg`, Drizzle, or Prisma                    |
| Framework | Next.js, TanStack Start, Express, Hono, NestJS, or `node:http` |
| Provider  | Web Push, FCM, APNs, Expo, or the local emulator               |
| Queue     | Inline delivery, Postgres database queue, or BullMQ            |
| Cache     | None, in-process memory, or Redis                              |
| Client    | Browser, React, React Native, or Expo                          |

## Core capabilities [#core-capabilities]

* Typed and ad-hoc sends through one `notify()` pipeline.
* Browser and native device registration with token rotation and dead-token
  handling.
* Paginated in-app feed, unread state, user preferences, React hooks, and
  copy-in UI components.
* Optional queues, workers, retries, scheduled sends, cancellation, and
  digests.
* Optional caching, cross-instance realtime signals, and shared rate limits.
* Local emulator, diagnostics CLI, and an operator Studio with server-side
  redaction and audited actions.

## Application responsibilities [#application-responsibilities]

better-push does not host an API or subscriber store. Your application remains
responsible for:

* authenticating requests and returning the correct `userId` from the session
  resolver;
* authorizing every server-side `notify()` call;
* managing provider credentials and deployment secrets;
* applying schema changes and setting data-retention policy;
* configuring proxy-level controls such as IP rate limits and CORS when the
  application architecture requires them.

See [Security](/docs/security) for the complete trust boundary and
[Configuration](/docs/configuration) for server options and package entry
points.


---

# APNs setup

> The p8 auth key, the topic, sandbox vs production, and direct HTTP/2 delivery.

Canonical documentation: /docs/apns-setup



The APNs provider delivers to iOS device tokens registered by a native app. It
talks to Apple directly - there is no third-party SDK in the path - and
authenticates with a p8 auth key rather than a certificate.

## 1. Create the p8 auth key [#1-create-the-p8-auth-key]

You need a paid Apple Developer account and an app bundle id.

1. In the Apple Developer portal, open **Certificates, Identifiers & Profiles →
   Keys** and create a key with &#x2A;*Apple Push Notifications service (APNs)**
   enabled.
2. Download the `.p8` file. **Apple lets you download it once**; if you lose it,
   revoke the key and create a new one.
3. Note the **Key ID** shown next to the key, and your **Team ID** from the
   membership page.

One p8 key works for every app in the team, in both sandbox and production, and
does not expire the way a push certificate does.

## 2. Configure the provider [#2-configure-the-provider]

```ts title="src/push.ts"
import { apns } from "@better-push/core/providers/apns";

apns({
  keyId: process.env.APNS_KEY_ID!, // the p8 key id
  teamId: process.env.APNS_TEAM_ID!, // Apple developer team id
  key: process.env.APNS_KEY!, // the PEM contents of the .p8 file
  topic: process.env.APNS_BUNDLE_ID!, // the app bundle id
  production: process.env.APNS_PRODUCTION === "true", // default false
});
```

| Option       | Meaning                                                         |
| ------------ | --------------------------------------------------------------- |
| `keyId`      | Key ID of the p8 auth key. Signed into the JWT header as `kid`. |
| `teamId`     | Apple developer team id. The JWT's `iss` claim.                 |
| `key`        | The **contents** of the `.p8` file, a PKCS#8 PEM. Not a path.   |
| `topic`      | The app bundle id, sent as the `apns-topic` header.             |
| `production` | `true` for the production gateway. Default `false` (sandbox).   |
| `ttl`        | Optional seconds APNs stores the push for an offline device.    |
| `host`       | Full gateway origin override. Only useful in tests.             |

Missing or empty values throw `INVALID_CONFIG` at construction, so a
misconfigured deployment fails at boot rather than on the first send.

### Putting the key in an environment variable [#putting-the-key-in-an-environment-variable]

`key` is the literal PEM text, newlines included:

```
-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg...
-----END PRIVATE KEY-----
```

Some hosting dashboards collapse newlines into a literal `\n`. If yours does,
restore them when reading the variable:

```ts
key: process.env.APNS_KEY!.replace(/\\n/g, "\n"),
```

## 3. Sandbox vs production [#3-sandbox-vs-production]

APNs has two gateways, and a device token is only valid against one of them:

| `production`      | Gateway                      | Tokens that work                |
| ----------------- | ---------------------------- | ------------------------------- |
| `false` (default) | `api.sandbox.push.apple.com` | Development and debug builds    |
| `true`            | `api.push.apple.com`         | TestFlight and App Store builds |

> **A dev build's token only works against sandbox:** Sending a development-build token to the production gateway (or the reverse)
> returns `400 BadDeviceToken`, which is indistinguishable from a genuinely
> dead token - so the device gets disabled and every later send skips it until
> it re-registers. When you test with an Expo dev client, leave `production` at
> `false`.

The `apns-topic` must match the bundle id of the build that produced the token
too. A mismatch returns `400 DeviceTokenNotForTopic`, which is mapped to
`provider_error` and deliberately does **not** disable the device - it is your
configuration that is wrong, not the token.

## 4. Get a device token [#4-get-a-device-token]

APNs tokens come from a native app: `Notifications.getDevicePushTokenAsync()` in
the Expo app returns the raw APNs token as a hex string, registered as
`{ platform: "ios", provider: "apns", token }`. See
[Native / Mobile Testing](/docs/native-testing).

`validateToken` strips whitespace and requires a hex string, so the bracketed
form some tooling prints (`<a1b2 c3d4 ...>`) is rejected at registration rather
than silently failing to deliver.

## How the provider talks to Apple [#how-the-provider-talks-to-apple]

better-push speaks **APNs HTTP/2 directly**, using Node's built-in `node:http2`
and `jose` for signing. There is no `node-apn` or Firebase in the path.

* **Provider token.** An ES256 JWT signed with your p8 key, with header
  `{ alg: "ES256", kid: keyId }` and claims `{ iss: teamId, iat }`. Apple
  accepts a provider token for about 60 minutes and **rate-limits token
  generation**, so the signed token is cached and reused for \~50 minutes, then
  regenerated.
* **Request.** `POST /3/device/<token>` with `authorization: bearer <jwt>`,
  `apns-topic`, `apns-push-type: alert`, `apns-priority: 10`, and
  `apns-expiration` (`0` unless you set `ttl`).
* **Body.** `{ aps: { alert: { title, body }, "mutable-content": 1 } }` plus
  your notification `data` alongside it. A key named `aps` inside `data` is
  dropped, because that name is reserved by Apple.
* **Connection.** One HTTP/2 session per batch, with the batch multiplexed over
  it at up to 10 requests in flight, closed when the batch finishes.

Responses are mapped to normalized codes: `200` is `ok`, `410` and
`400 BadDeviceToken` mark the token dead, `429` is `rate_limited`, `413` is
`payload_too_large`, and `403` credential faults are `provider_error`. Full
table in [Token Lifecycle](/docs/token-lifecycle).


---

# Expo push setup

> Deliver through the Expo push service - tickets, receipts, and why receipts need a queue.

Canonical documentation: /docs/expo-setup



The `expo` provider relays notifications through Expo's push service, which
forwards them to APNs and FCM on your behalf. The app registers an
`ExponentPushToken[...]` instead of a raw device token, and you configure no
Apple or Firebase credentials in better-push at all.

```ts title="src/push.ts"
import { expo } from "@better-push/core/providers/expo";

export const push = betterPush({
  providers: [expo()],
  // ...
});
```

## Is this the right choice [#is-this-the-right-choice]

It is a genuine trade, not an easier version of the same thing.

|                   | Raw APNs/FCM (`apns()`, `fcm()`)  | Expo (`expo()`)                     |
| ----------------- | --------------------------------- | ----------------------------------- |
| Delivery path     | Your server to Apple/Google       | Your server to Expo to Apple/Google |
| Credentials       | Your p8 key, your service account | None, or one Expo access token      |
| Dead-token signal | Immediate, in the send response   | Later, in a receipt                 |
| `delivered_at`    | Never set                         | Set from receipts                   |

`@better-push/core/native` registers **raw tokens by default**. Reach for Expo when
your app is distributed with Expo's push credentials, which is the case it
exists for.

Both can be configured at once: a device row names its own provider, so one
backend serves an app that registers either.

## Credentials [#credentials]

A token-less Expo project needs nothing. If your project has enhanced push
security enabled, pass the access token:

```ts
expo({ accessToken: process.env.EXPO_ACCESS_TOKEN });
```

Expo's own credentials for APNs and FCM are configured in your Expo project, not
here. That is the point of the relay.

## Registering a token [#registering-a-token]

```tsx
import { usePushRegistration } from "@better-push/core/native";

const registration = usePushRegistration({
  mode: "expo",
  projectId: Constants.expoConfig?.extra?.eas?.projectId,
});
```

`projectId` is required in a bare or EAS build. A token minted against the
wrong project registers successfully and then silently never delivers, which is
among the harder failures to diagnose - so pass it.

See [React Native](/docs/react-native) for the rest of the client.

## Tickets and receipts [#tickets-and-receipts]

**This is the part that is different from every other provider.** Expo answers
a send with a *ticket*, not an outcome:

```json
{ "data": [{ "status": "ok", "id": "XXXX-XXXX" }] }
```

`"ok"` means Expo accepted the message for delivery. It does not mean the device
got it, and it does not mean the token is alive. `DeviceNotRegistered` - the
entire dead-token signal for this transport - arrives later, in a **receipt**
fetched by ticket id.

So better-push schedules a receipt check:

```mermaid
sequenceDiagram
  participant C as Notify call
  participant X as Expo
  participant Q as Queue
  participant D as Postgres
  C->>X: Send push batches
  X-->>C: Return push tickets
  C->>D: Mark delivery sent
  C->>Q: Enqueue receipt job
  Note over C,Q: Without a queue tickets are final
  Q->>X: Request push receipts
  X-->>Q: Return receipt results
  Q->>D: Update delivery and device
```

The job id is derived from the notification and the provider, so a retry of the
delivery job cannot schedule the check twice.

### Receipts require a queue [#receipts-require-a-queue]

With no queue there is nothing to run fifteen minutes later, so the tickets are
the final word:

| Capability                  | DB only                                                | + dbQueue                                | + bullmq                      |
| --------------------------- | ------------------------------------------------------ | ---------------------------------------- | ----------------------------- |
| Expo receipts               | tickets are final; dead tokens pruned on the next send | fetched on the next poll after the delay | fetched at the delay, exactly |
| `delivered_at` on push rows | never set                                              | set from receipts                        | set from receipts             |

Nothing breaks without a queue. A token that has gone away simply fails its
*next* send with `DeviceNotRegistered` and is disabled then, one notification
later than it could have been.

```ts
export const push = betterPush({
  providers: [expo()],
  queue: dbQueue(),
  receiptDelay: "15m",   // the default, and Expo's own guidance
});
```

A receipt Expo does not have yet is left alone and the job retries on the
queue's own backoff, bounded by `maxAttempts` like every other job.

## Error mapping [#error-mapping]

| Expo `details.error`  | better-push code                         | Device disabled |
| --------------------- | ---------------------------------------- | --------------- |
| `DeviceNotRegistered` | `invalid_token`                          | **Yes**         |
| `MessageTooBig`       | `payload_too_large`                      | No              |
| `MessageRateExceeded` | `rate_limited`                           | No              |
| `MismatchSenderId`    | `provider_error`                         | No              |
| `InvalidCredentials`  | `provider_error`                         | No              |
| HTTP 429 for a chunk  | `rate_limited` for every message in it   | No              |
| HTTP 5xx for a chunk  | `provider_error` for every message in it | No              |

The last two rows matter: a chunk rejected at the HTTP level produces no tickets
at all, and every message in it still gets a result. `MismatchSenderId` and
`InvalidCredentials` are configuration faults, so the device stays enabled - the
fix is in your Expo project, not on the phone.

## Limits [#limits]

Expo documents 100 messages per send and 1000 ids per receipt request; the
provider chunks at both and neither is worth raising. The payload cap is 4 KiB
including `data`.

```ts
expo({ maxBatch: 100, maxReceipts: 1000 });
```


---

# FCM setup

> Firebase project, service account, FCM for web, and Android device tokens.

Canonical documentation: /docs/fcm-setup



Firebase Cloud Messaging is the transport for Android, and an alternative
transport for web and iOS. This page covers the Firebase project, the server
credential, the browser client, and where Android tokens come from.

## 1. Create a Firebase project [#1-create-a-firebase-project]

1. Open the [Firebase console](https://console.firebase.google.com) and create a
   project (or reuse one you already have).
2. Under **Project settings → Cloud Messaging**, confirm the &#x2A;*Firebase Cloud
   Messaging API (V1)** is enabled. better-push uses the V1 API exclusively; the
   deprecated legacy server key is not used anywhere.

## 2. Create the server credential [#2-create-the-server-credential]

The server side authenticates with a **service account**, not with a client key.

1. **Project settings → Service accounts → Generate new private key.**
2. Firebase downloads a JSON file. Treat it as a secret: it can send push to
   every device in the project.
3. Store the whole file as one environment variable, e.g.
   `FIREBASE_SERVICE_ACCOUNT`.

`firebase-admin` is an optional peer dependency, so install it in any app that
uses the FCM provider:

```bash
pnpm add firebase-admin
```

Then add the provider:

```ts title="src/push.ts"
import { fcm } from "@better-push/core/providers/fcm";

fcm({
  serviceAccount: JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT!),
});
```

`serviceAccount` accepts a **parsed object or the raw JSON string**, so this is
equivalent and saves you the `JSON.parse`:

```ts
fcm({ serviceAccount: process.env.FIREBASE_SERVICE_ACCOUNT! });
```

An invalid JSON string throws `INVALID_CONFIG` at construction rather than
failing on the first send. The provider initializes its own named Firebase app,
so it never collides with a Firebase app your own code already created.

> **Never ship the service account to the client:** The service-account JSON is a server-only secret. The browser needs the
> *client* config and the web VAPID key from step 3 - both public values - and
> nothing else.

## 3. FCM for web [#3-fcm-for-web]

Web push through Firebase needs three things in the browser: the client config,
the web VAPID key, and Firebase's own messaging service worker.

### Client config and the web VAPID key [#client-config-and-the-web-vapid-key]

1. **Project settings → General → Your apps → Web app** gives you the config
   object (`apiKey`, `projectId`, `messagingSenderId`, `appId`, ...). These are
   public identifiers, safe to expose.
2. **Project settings → Cloud Messaging → Web configuration → Web Push
   certificates** gives you the **web VAPID key pair**. Copy the public key - it
   is the `vapidKey` argument to `getToken`. This is a Firebase key pair, not
   the one you generated for the `web-push` provider; the two are unrelated.

### The messaging service worker [#the-messaging-service-worker]

Firebase requires its own service worker, served from the root of your origin
and named exactly `firebase-messaging-sw.js`:

```js title="public/firebase-messaging-sw.js"
importScripts(
  "https://www.gstatic.com/firebasejs/12.0.0/firebase-app-compat.js",
);
importScripts(
  "https://www.gstatic.com/firebasejs/12.0.0/firebase-messaging-compat.js",
);

firebase.initializeApp({
  apiKey: "...",
  projectId: "...",
  messagingSenderId: "...",
  appId: "...",
});

firebase.messaging();
```

Match the `firebasejs` version in those URLs to the `firebase` package you
installed. This worker is separate from better-push's `public/sw.js`, which
serves the `web-push` provider; the two can coexist on one origin.

> **The filename is not a convention, it is a lookup:** `getToken()` registers `/firebase-messaging-sw.js` by default. If the file is
> missing, renamed, or nested in a subdirectory, token retrieval fails with a
> registration error and no push ever arrives.

### Get a token and register the device [#get-a-token-and-register-the-device]

```ts title="src/firebase-push.ts"
import { initializeApp } from "firebase/app";
import { getMessaging, getToken } from "firebase/messaging";

const app = initializeApp({
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID!,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID!,
});

export async function registerFcmWeb() {
  if ((await Notification.requestPermission()) !== "granted") return;

  const token = await getToken(getMessaging(app), {
    vapidKey: process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY!,
  });
  if (!token) return;

  await fetch("/api/push/devices", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ platform: "web", provider: "fcm", token }),
  });
}
```

Call it from a button click, never on load - the permission prompt must follow a
user gesture, exactly as with [web push](/docs/testing-on-devices).

## 4. Android [#4-android]

Android device tokens come from a native app, not from a browser.

1. **Project settings → Your apps → Add app → Android**, using the same package
   name as the app.
2. Download `google-services.json` and place it where the native build expects
   it. For the Expo app in this repo that is `apps/native`, wired through
   `app.json`.
3. On the device, `Notifications.getDevicePushTokenAsync()` returns the **raw
   FCM registration token**, which is registered as
   `{ platform: "android", provider: "fcm", token }`.

The full walkthrough is in [Native / Mobile Testing](/docs/native-testing).

## What the provider sends [#what-the-provider-sends]

For each device in the FCM batch, better-push builds one message:

* `notification: { title, body }` from the notification.
* `data` - your `data` object with every value stringified, because FCM only
  accepts string values. Non-string values are `JSON.stringify`d.
* `webpush.fcmOptions.link` - set from `data.url` when it is a string, so a
  click on a web notification opens the right page.

Messages go out with `sendEach`, which returns one result per message in order.
Failures are mapped to normalized codes and recorded per delivery, never thrown.
`registration-token-not-registered` and `invalid-argument` disable the device;
credential errors do not. See [Token Lifecycle](/docs/token-lifecycle).

## Testing without Firebase [#testing-without-firebase]

`fcm()` takes an optional `messaging` client, which must implement only the
`sendEach` shape the provider uses:

```ts
fcm({
  serviceAccount: { project_id: "test" },
  messaging: {
    sendEach: async (messages) => ({
      responses: messages.map(() => ({ success: true })),
    }),
  },
});
```

That is how better-push's own test suite exercises the provider without touching
Firebase or the network.


---

# Test native mobile delivery

> Register APNs, FCM or Expo push tokens from an Expo dev client and verify delivery on real hardware.

Canonical documentation: /docs/native-testing



Web push is verified from a browser or an installed PWA
([Testing On Devices](/docs/testing-on-devices)). The `apns` and `fcm` providers
need a **native** app, because only a native app can obtain a raw APNs or FCM
device token. This repo ships an Expo app at `apps/native` for exactly that,
built on [`@better-push/core/native`](/docs/react-native).

This page is about the **hardware and credentials** side: dev clients, tokens,
and what to check when nothing arrives. For the client API - the provider, the
hooks, and Metro resolution - see [React Native](/docs/react-native).

## Why a development build [#why-a-development-build]

The app must be built as an Expo **development build** (a dev client), not run
inside Expo Go. Expo Go cannot deliver custom native push on iOS: it carries
Expo's own bundle id and entitlements, so a token obtained inside it does not
belong to your app and your `apns-topic` will not match it.

A dev client is a real build of your bundle id with your entitlements, installed
on the device, that still loads JavaScript from the Metro dev server. Building
one requires an Apple Developer account (iOS) and `google-services.json`
(Android), so it is an owner-run step. The toolchain, credentials, and
`expo prebuild` commands are in `apps/native/README.md` in the repo.

> **Simulators cannot receive push:** The iOS Simulator cannot obtain an APNs device token, and an Android emulator
> without Play Services cannot obtain an FCM token. Native push verification
> needs a physical phone.

## Raw device tokens by default [#raw-device-tokens-by-default]

`mode: "raw"` - the default - calls `Notifications.getDevicePushTokenAsync()`,
which returns the **native** token: the raw APNs token on iOS and the raw FCM
registration token on Android. That exercises the real
[`apns`](/docs/apns-setup) and [`fcm`](/docs/fcm-setup) providers, with no third
party between your server and Apple or Google.

`mode: "expo"` calls `getExpoPushTokenAsync()` instead and registers against the
[`expo`](/docs/expo-setup) provider, which relays through Expo's push service.
It is the right choice for an app distributed with Expo's push credentials.

The demo app at `apps/native` has a toggle for both, so **one build proves both
paths** against the same backend. Unregister before switching: the two modes
register different tokens, and the old device row otherwise stays behind.

> **Expo mode still needs a dev client:** A token from inside Expo Go carries Expo's own project, not yours. Pass your
> `projectId` and build a dev client, exactly as for raw tokens.

## Register the device [#register-the-device]

```tsx title="EnableNotifications.tsx"
import { usePushRegistration } from "@better-push/core/native";

function EnableNotifications() {
  const { status, register } = usePushRegistration();
  return (
    <Pressable onPress={() => register({ deviceName: "Alice's iPhone" })}>
      <Text>{status === "subscribed" ? "Enabled" : "Enable notifications"}</Text>
    </Pressable>
  );
}
```

The hook does the permission request, the Android channel, the
`getDevicePushTokenAsync()` read, and the POST. It picks the pair by platform:
`{ platform: "ios", provider: "apns" }` or
`{ platform: "android", provider: "fcm" }`.

The endpoint is the same `POST {basePath}/devices` a browser calls. It rejects a
mismatched pair such as `{ platform: "android", provider: "apns" }` with a
`400`, so a wrong pairing fails loudly at registration rather than at delivery.

## Pointing the app at your backend [#pointing-the-app-at-your-backend]

`{API}` is a base URL the app reads from its config, so the same build can talk
to a deployed backend or to your laptop:

* **A deployed HTTPS URL** is the simplest. Native push has no service-worker or
  secure-context requirement of its own, but iOS App Transport Security blocks
  plain `http://` by default, so HTTPS avoids a whole class of confusion.
* **A LAN URL** (`http://192.168.x.x:3000`) works for fast iteration if you
  allow cleartext for that host in the dev build.

Your backend also has to authenticate the request. Better-push calls your
`session` resolver with the incoming `Request`, and the native client sends its
token as `Authorization: Bearer`, so accept one there in addition to your normal
cookie:

```ts title="src/push.ts"
session: async (request) => {
  const bearer = request.headers.get("authorization")?.replace(/^Bearer /, "");
  const user = bearer
    ? await getUserFromToken(bearer)
    : await getUserFromCookie(request);
  return user ? { userId: user.id } : null;
},
```

The demo app does this behind a clearly marked dev-only login endpoint; do not
copy that shortcut into production auth.

## The device walkthrough [#the-device-walkthrough]

Run this once per platform. It is the acceptance test for the mobile providers.

1. Install the dev client on the phone and start Metro.

2. Sign in, then tap **Enable notifications** and accept the system prompt. The
   prompt must follow a tap; never request permission on launch.

3. Confirm the app shows the registered platform and provider, and that a new
   `bp_device` row exists with `platform: "ios"` / `provider: "apns"` (or
   `android` / `fcm`).

4. **Background the app** - go to the home screen and lock the phone. A push
   that only arrives with the app in the foreground proves nothing.

5. Send from your server:

   ```ts
   await push.notify({
     userId: user.id,
     title: "Hello from better-push",
     body: "Sent to a real device token.",
     data: { url: "/orders/123" },
   });
   ```

6. The notification appears on the lock screen. Check the `bp_delivery` row for
   that device reads `sent`.

Then repeat with the app in the foreground to verify your
`expo-notifications` handler renders it in-app.

7. Open your inbox screen and confirm the notification is there and unread, then
   tap the push itself: with `markReadOnPushClick` on, `useNotificationFeed()`
   acknowledges it, including when the tap cold-started the app.
8. Turn the type's push channel off in your preferences screen and send again.
   Nothing should arrive, and the `bp_delivery` row should read `suppressed`.

## When nothing arrives [#when-nothing-arrives]

* **iOS, `400 BadDeviceToken` on the delivery row** - the gateway and the build
  disagree. A dev-client token needs `production: false`. See
  [APNs Setup](/docs/apns-setup).
* **iOS, `400 DeviceTokenNotForTopic`** - `topic` is not the bundle id of the
  installed build.
* **iOS, `403`** - the p8 key, key id, or team id is wrong. The token is fine;
  the device is not disabled.
* **Android, `messaging/registration-token-not-registered`** - the app was
  reinstalled or cleared its data. The device is disabled automatically; the app
  registers a fresh token on next launch.
* **Android, nothing at all** - check that `google-services.json` belongs to the
  same Firebase project as the service account on the server.

Failure codes never throw; they land on the delivery row, which makes this
debuggable from your own database. See
[Token Lifecycle](/docs/token-lifecycle).


---

# Providers

> The provider model, routing by device.provider, and the web/Android/iOS matrix.

Canonical documentation: /docs/providers



A **provider** is a push transport. better-push ships four of them - web push,
FCM, APNs, and Expo - behind one interface, and picks the right one for each
device automatically. Your application code never branches on platform.

## The provider model [#the-provider-model]

Every transport implements the same `PushProvider` interface. Its stable `key`
is stored on each device, `platforms` constrains registration,
`validateToken` performs a cheap synchronous shape check, and `send` returns one
result per payload. Providers such as Expo may also implement receipt checks
for outcomes that are not available when the send is first accepted. The
generated interface reference is at the end of this page.

A provider never throws for a per-device failure. It returns a normalized
`ProviderResult` per payload carrying a `code` - one of `ok`, `invalid_token`,
`expired_token`, `rate_limited`, `payload_too_large`, `provider_error`,
`network_error` - and a `tokenDead` flag. That uniformity is what lets a single
send record comparable delivery rows across three very different transports.
See [Token Lifecycle](/docs/token-lifecycle) for what `tokenDead` triggers.

## Configuring providers [#configuring-providers]

Pass every transport your app uses in one array:

```ts title="src/push.ts"
import { betterPush } from "@better-push/core";
import { drizzleAdapter } from "@better-push/core/adapters/drizzle";
import { webPush } from "@better-push/core/providers/web-push";
import { fcm } from "@better-push/core/providers/fcm";
import { apns } from "@better-push/core/providers/apns";
import { db } from "@/db";

export const push = betterPush({
  database: drizzleAdapter(db),
  providers: [
    webPush({
      vapid: {
        subject: "mailto:you@example.com",
        publicKey: process.env.VAPID_PUBLIC_KEY!,
        privateKey: process.env.VAPID_PRIVATE_KEY!,
      },
    }),
    fcm({
      serviceAccount: JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT!),
    }),
    apns({
      keyId: process.env.APNS_KEY_ID!,
      teamId: process.env.APNS_TEAM_ID!,
      key: process.env.APNS_KEY!,
      topic: process.env.APNS_BUNDLE_ID!,
      production: process.env.APNS_PRODUCTION === "true",
    }),
  ],
  session: async (request) => getSession(request),
});
```

Configure only the providers you actually use. Each factory validates its
options eagerly and throws `INVALID_CONFIG` when credentials are missing, so
build the array conditionally if some environments only have VAPID keys.

## Routing by `device.provider` [#routing-by-deviceprovider]

Every row in `bp_device` carries a `platform` and a `provider`. The `provider`
column is written at registration time and is the **only** thing the send
pipeline routes on.

When you call `push.notify()`, better-push:

1. Loads the user's active devices.
2. Groups them by `device.provider`.
3. Sends **one batch per provider**.
4. Folds each provider's results back into the matching `bp_delivery` rows.

So a user with a browser, an Android phone, and an iPhone is reached by one
call:

```ts
await push.notify("orderShipped", {
  userId: user.id,
  payload: { orderId: "1024", eta: "tomorrow" },
});
```

That is one web push batch, one FCM batch, and one APNs batch, with one delivery
row per device. There is no per-platform branching in your code and no second
API to learn.

## The platform and provider matrix [#the-platform-and-provider-matrix]

| Platform         | Provider   | Token stored on the device row                            |
| ---------------- | ---------- | --------------------------------------------------------- |
| `web`            | `web-push` | The browser's Push API subscription, as JSON              |
| `web`            | `fcm`      | An FCM web registration token                             |
| `android`        | `fcm`      | The device's FCM registration token                       |
| `ios`            | `apns`     | The raw APNs device token (hex)                           |
| `ios`            | `fcm`      | An FCM registration token, when iOS goes through Firebase |
| `ios`, `android` | `expo`     | An `ExponentPushToken[...]`, relayed by Expo              |

`expo()` serves `["ios", "android"]`; `webPush()` serves `["web"]`,
`apns()` serves `["ios"]`, and `fcm()` serves
`["android", "ios", "web"]`.

## Choosing [#choosing]

* **Web** - prefer `web-push`. It is a standard (RFC 8291/8292), needs no Google
  account, and works in every browser with the Push API. Choose `fcm` for web
  only if you already run the Firebase SDK on the client and want one pipeline.
  See [Web Push Setup](/docs/web-push-setup) and [FCM Setup](/docs/fcm-setup).
* **Android** - `fcm`. There is no alternative transport on Android.
* **Expo** - `expo` relays through Expo's push service to both Apple and Google,
  with no credentials of your own. It is the right answer for an app
  distributed with Expo's push credentials, and a third party in the delivery
  path for everyone else. See [Expo push setup](/docs/expo-setup).
* **iOS** - `apns` talks to Apple directly with your p8 key and is the shortest
  path. `fcm` for iOS routes the same push through Firebase, which still needs
  the p8 key uploaded to your Firebase project; pick it only if you want a
  single Firebase pipeline for both mobile platforms.

A browser can hold both a `web-push` and an `fcm` registration at once, but then
it is two device rows and the user gets the notification twice. Register one
provider per client.

## Registration rejects impossible pairs [#registration-rejects-impossible-pairs]

`POST {basePath}/devices` takes the platform, the provider key, and the token:

```json
{ "platform": "ios", "provider": "apns", "token": "a1b2c3d4e5f6" }
```

The endpoint resolves the named provider from your config and returns
`400 INVALID_BODY` when the pair cannot work:

* an unconfigured `provider` - the message lists the providers you did configure.
* a platform the provider does not serve, e.g.
  `{ "platform": "android", "provider": "apns" }` - the message names the
  provider and the platforms it serves.
* a token that fails the provider's `validateToken`, e.g. a non-hex string for
  APNs.

This means a client bug can never file an iOS token under the FCM provider and
silently break sends months later.

## Optional dependencies [#optional-dependencies]

`firebase-admin` is an **optional peer dependency**, and `fcm` and `apns` live
on their own subpaths:

```ts
import { webPush } from "@better-push/core/providers/web-push";
import { fcm } from "@better-push/core/providers/fcm";
import { apns } from "@better-push/core/providers/apns";
```

Importing the package root never pulls in Firebase, and the `fcm` entry imports
`firebase-admin` lazily on its first send. A web-push-only app installs nothing
extra. Only apps that call `fcm()` need:

```bash
pnpm add firebase-admin
```

APNs has no extra dependency at all: the provider speaks APNs HTTP/2 through
Node's built-in `node:http2` and signs its provider token with `jose`, which is
already a dependency of better-push.

## Next [#next]

* [FCM Setup](/docs/fcm-setup) - Firebase project, service account, FCM for web.
* [APNs Setup](/docs/apns-setup) - p8 key, topic, sandbox vs production.
* [Expo push setup](/docs/expo-setup) - tickets, receipts, and when to pick it.
* [Native / Mobile Testing](/docs/native-testing) - real APNs and FCM tokens
  from an Expo dev-client app.
* [Token Lifecycle](/docs/token-lifecycle) - dead tokens, rotation, staleness.

## Provider interfaces [#provider-interfaces]

**PushProvider type reference:** Generated from `../../packages/better-push/src/providers/types.ts`. See the canonical documentation page for the field table.

**ProviderResult type reference:** Generated from `../../packages/better-push/src/providers/types.ts`. See the canonical documentation page for the field table.

**DeliveryPayload type reference:** Generated from `../../packages/better-push/src/providers/types.ts`. See the canonical documentation page for the field table.


---

# React Native

> Register device tokens, read the in-app feed, and edit preferences from an Expo app with @better-push/core/native.

Canonical documentation: /docs/react-native



`@better-push/core/native` is the React Native client: the same hooks as
[`@better-push/core/react`](/docs/in-app-feed), returning the same types, bound to
React Native instead of the DOM.

It is a &#x2A;*subpath of `better-push`**, not a separate package. Client and server
share a wire protocol, so shipping them as one version means a mismatch is
impossible - there is no `native@1.2` to point at a `server@1.0`.

> **Hooks only:** The headless components (`NotificationBell`, `NotificationInbox`,
> `NotificationPreferences`) render DOM elements and are web-only. On native you
> build the screens; the hooks give you the state.

## Install [#install]

```bash
npm install @better-push/core expo-notifications
# optional: persists the device id across launches
npm install expo-secure-store
```

`expo-notifications`, `expo-secure-store`, and `react-native` are **optional
peer dependencies**. Nothing on the server pulls them in, and without
`expo-secure-store` the device id simply lives in memory for the session.

## Wrap your app [#wrap-your-app]

```tsx title="App.tsx"
import { BetterPushProvider } from "@better-push/core/native";

export default function App() {
  const [token, setToken] = useState<string | null>(null);

  return (
    <BetterPushProvider
      baseURL="https://app.example.com"
      auth={{ getToken: () => token }}
    >
      <Screens />
    </BetterPushProvider>
  );
}
```

* `baseURL` is **required** here. A browser can fall back to "same origin"; a
  phone has no origin to fall back to.
* `basePath` defaults to `"/api/push"` and must match your server's.
* `getToken` is called before every request and may be async, so a token kept in
  secure storage does not have to be mirrored into React state first. Returning
  `null` sends the request unauthenticated and your server answers `401`.

Every hook also accepts `baseURL` / `basePath` for a one-off override, so the
hook shape matches the web exactly - you just do not repeat the configuration.

## Authentication [#authentication]

Native auth is a **bearer header**, not a cookie. Your `session` resolver
receives the raw `Request`, so accept both:

```ts title="src/push.ts"
session: async (request) => {
  const bearer = request.headers.get("authorization")?.replace(/^Bearer /, "");
  const user = bearer
    ? await getUserFromToken(bearer)
    : await getUserFromCookie(request);
  return user ? { userId: user.id } : null;
},
```

No other server change is needed. `POST /devices` already accepts
`web | ios | android`, and the feed, preference, and read routes are transport
agnostic.

## Register the device [#register-the-device]

```tsx
import { usePushRegistration } from "@better-push/core/native";

function EnableNotifications() {
  const { status, error, register, unregister } = usePushRegistration();

  return (
    <Pressable onPress={() => register({ deviceName: "Alice's iPhone" })}>
      <Text>{status === "subscribed" ? "Enabled" : "Enable notifications"}</Text>
    </Pressable>
  );
}
```

`register()` requests permission, creates the Android channel, reads the token,
posts it, and stores the returned device id. `status` is the same union the web
hook uses: `unsupported | idle | registering | subscribed | denied | error`.

Call it from a tap. Asking for notification permission on launch is the fastest
route to a permanent denial, so the hook never prompts on its own.

### Raw tokens or Expo tokens [#raw-tokens-or-expo-tokens]

`mode` decides which token is registered. It defaults to `"raw"`.

|                 | `mode: "raw"` (default)           | `mode: "expo"`                      |
| --------------- | --------------------------------- | ----------------------------------- |
| Token           | `getDevicePushTokenAsync()`       | `getExpoPushTokenAsync()`           |
| Registers as    | ios/`apns`, android/`fcm`         | ios or android /`expo`              |
| Server provider | `apns()`, `fcm()`                 | `expo()`                            |
| Delivery path   | your server to Apple/Google       | your server to Expo to Apple/Google |
| Credentials     | your p8 key, your service account | none, or one Expo access token      |

```tsx
const registration = usePushRegistration({
  mode: "expo",
  projectId: Constants.expoConfig?.extra?.eas?.projectId,
});
```

`projectId` is required in a bare or EAS build: a token minted against the wrong
project registers successfully and then silently never delivers.

Raw is the default because it keeps a third party out of the delivery path.
Expo is the right answer when your app is distributed with Expo's push
credentials - and it is the only mode where a push delivery can reach
`delivered`, because Expo is the only transport here that reports receipts. See
[Expo push setup](/docs/expo-setup) and
[Native / Mobile Testing](/docs/native-testing).

Both modes can be live at once on the server: a device row names its own
provider, so one backend serves an app that registers either.

> **Unregister before switching:** The two modes register different tokens. Switching without unregistering
> leaves the previous device row behind, and the user gets both.

## The feed [#the-feed]

```tsx
import { useNotificationFeed } from "@better-push/core/native";

function Inbox() {
  const feed = useNotificationFeed({ markReadOnPushClick: true });

  return (
    <FlatList
      data={feed.notifications}
      keyExtractor={(item) => item.id}
      refreshing={feed.isLoading}
      onRefresh={() => void feed.refresh()}
      onEndReached={() => void feed.loadMore()}
      renderItem={({ item }) => (
        <Pressable onPress={() => void feed.markRead(item.id)}>
          <Text>{item.title}</Text>
        </Pressable>
      )}
    />
  );
}
```

Identical return value to the web hook: `notifications`, `unreadCount`,
`isLoading`, `isLoadingMore`, `error`, `hasMore`, `loadMore`, `markRead`,
`markAllRead`, `refresh`.

`markReadOnPushClick` is opt-in, exactly as on the web - opening a push is not
universally "read". When it is on, the hook handles both tap paths: a tap while
the app is running, and the tap that cold-started it (which no listener can
catch, because the app did not exist yet).

Polling pauses while the app is backgrounded and refreshes on return, using
React Native's `AppState` in place of the browser's page visibility.

## Preferences [#preferences]

```tsx
import { usePreferences } from "@better-push/core/native";

function Settings() {
  const { preferences, setPreference, save, dirty, isSaving } = usePreferences();
  // ... render a Switch per channel, then a Save button
}
```

Same semantics as the web hook: edits are local, `save()` PUTs only the diff and
adopts the server's resolved view. See [Preferences](/docs/preferences).

## How it stays one client [#how-it-stays-one-client]

The web and native hooks are not two implementations. The hook bodies live in a
shared core and the platforms inject **two things**:

| Seam                | Web                                    | Native                    |
| ------------------- | -------------------------------------- | ------------------------- |
| `RequestAuthorizer` | `credentials: "include"`               | `Authorization: Bearer …` |
| `Lifecycle`         | `document.hidden` + `visibilitychange` | `AppState`                |

Pagination, cursors, polling and backoff, optimistic mark-read, and the
preference diff are written once. Anything genuinely platform-shaped - the
service worker on web, `expo-notifications` on native - stays in the binding
rather than widening the seams.

## Metro resolution [#metro-resolution]

The `./native` subpath declares a `react-native` export condition, so Metro
resolves the React Native build and never walks into server code. In a pnpm
monorepo, point Metro at the workspace root:

```js title="metro.config.js"
const path = require("node:path");
const { getDefaultConfig } = require("expo/metro-config");

const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "../..");
const config = getDefaultConfig(projectRoot);

config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, "node_modules"),
  path.resolve(workspaceRoot, "node_modules"),
];

module.exports = config;
```

Leave hierarchical lookup enabled: under pnpm a package's own dependencies live
beside it inside `.pnpm`, and only the walk up the tree finds them.

## A working app [#a-working-app]

`apps/native` in the repo is a full consumer: provider, registration, an inbox
screen, and a preferences screen, all from these hooks. Its README has the
credentials, the dev-client build, and the on-device walkthrough.


---

# Test Web Push on devices

> Install the PWA and verify web push on real Android and iOS phones.

Canonical documentation: /docs/testing-on-devices



Web push behaves differently across platforms, and the differences only show up
on real hardware. This page covers installing your app as a PWA and verifying
push on desktop, Android, and iOS.

## Why HTTPS and a real deploy [#why-https-and-a-real-deploy]

Service workers and push require a secure context. `localhost` is exempt, but a
phone pointed at your machine's LAN IP is **not** - so device testing means a
public HTTPS URL. Deploy first (the demo uses Railway), then open that URL on
the phone.

## Desktop (Chrome or Firefox) [#desktop-chrome-or-firefox]

1. Open your HTTPS URL.
2. Click your register button and accept the permission prompt.
3. Send a notification from the server and confirm it arrives **with the tab in
   the background or closed** (the browser must still be running).

## Android (Chrome) [#android-chrome]

Android Chrome supports push both in a normal tab and as an installed PWA. Test
the installed path:

1. Open the URL in Chrome.
2. Menu → **Add to Home Screen** to install.
3. Open the **installed** app, log in, and register.
4. Close the app, send from the server, and the notification appears in the
   system tray.

## iOS and iPadOS 16.4+ [#ios-and-ipados-164]

iOS supports web push **only for web apps added to the Home Screen** - not in a
regular Safari tab. In a plain tab, `window.PushManager` is undefined, so
better-push's `isSupported()` correctly returns `false` there.

Your UI must detect this and guide the user instead of showing a dead button:

```ts
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent);
const standalone =
  window.matchMedia("(display-mode: standalone)").matches ||
  (navigator as unknown as { standalone?: boolean }).standalone === true;

const iosNeedsInstall = isIos && !standalone;
```

When `iosNeedsInstall` is true, show "Share → Add to Home Screen, then open the
installed app" instead of the register button.

The flow:

1. Open the URL in Safari - verify the **install instructions** appear (not a
   broken button).
2. **Share → Add to Home Screen.**
3. Open the installed app, tap register (the permission prompt appears on the
   tap), and allow.
4. Close the app, send from the server, and the notification appears on the lock
   screen.

## The user-gesture rule [#the-user-gesture-rule]

Both platforms require the permission prompt to follow a user gesture. Never
prompt automatically on load - always trigger `register()` from a button tap.
better-push's React hook never prompts on its own for exactly this reason.

## PWA requirements checklist [#pwa-requirements-checklist]

To be installable, your app needs:

* A web app manifest with `name`, `short_name`, `start_url`,
  `display: "standalone"`, `theme_color`, `background_color`, and 192×192 and
  512×512 icons (include one `maskable` variant).
* iOS metadata: an `apple-touch-icon` (180×180) and `appleWebApp` settings.

The demo app under `apps/demos/next` implements all of this and is the reference.


---

# Token lifecycle

> Dead-token pruning, rotation by re-registration, and the opt-in staleDeviceAfter cutoff.

Canonical documentation: /docs/token-lifecycle



Push tokens die. Apps get uninstalled, subscriptions expire, browsers reset
their storage. Hand-rolled push code usually keeps sending to those tokens
forever, wasting quota and hiding real failures. better-push prunes them from
the same result mapping every provider already returns.

## Dead-token pruning [#dead-token-pruning]

Each provider returns a `tokenDead` flag per delivery. When it is `true`, the
send pipeline calls `disableDevice`: the device row gets `disabled_at` and a
`disabled_reason`, and it is excluded from every future fan-out. The delivery
row is recorded as `failed` with the normalized code, so the history stays
auditable.

Nothing is deleted, and nothing throws. One dead token in a batch never affects
the other devices in that send.

| Provider   | Signal                                        | Code            | Device disabled |
| ---------- | --------------------------------------------- | --------------- | --------------- |
| `web-push` | `404` from the push service                   | `invalid_token` | yes             |
| `web-push` | `410 Gone`                                    | `expired_token` | yes             |
| `fcm`      | `messaging/registration-token-not-registered` | `invalid_token` | yes             |
| `fcm`      | `messaging/invalid-argument`                  | `invalid_token` | yes             |
| `fcm`      | `messaging/invalid-registration-token`        | `invalid_token` | yes             |
| `apns`     | `410 Unregistered`                            | `expired_token` | yes             |
| `apns`     | `400 BadDeviceToken`                          | `invalid_token` | yes             |
| `expo`     | `DeviceNotRegistered`                         | `invalid_token` | yes             |

## The signal that arrives late [#the-signal-that-arrives-late]

Every row above is reported **in the send response** - except the last one.

Expo answers a send with a *ticket*, not an outcome, and reports
`DeviceNotRegistered` later in a **receipt** fetched by ticket id. So the
pipeline schedules a `receipt` job at `now + receiptDelay` (default `"15m"`)
and folds the answer onto the delivery row when it arrives.

This is the one place a **push** delivery reaches `delivered`:

| Receipt says          | Delivery row                         | Device                     |
| --------------------- | ------------------------------------ | -------------------------- |
| `ok`                  | `delivered`, with `delivered_at` set | untouched                  |
| `DeviceNotRegistered` | `failed`, error `invalid_token`      | disabled                   |
| nothing yet           | left at `sent`                       | untouched, the job retries |

`bp_delivery.delivered_at` therefore means: **confirmed received**. It is
written for `inApp` rows, where the row in your database *is* the delivery, and
for push rows a provider has confirmed by receipt. It stays null for a
`web-push`, `fcm` or `apns` send, because those transports do not report one -
`sent` is as far as their acknowledgement goes.

Receipts need a `queue`. Without one there is nothing to run fifteen minutes
later, so Expo tickets are final and a dead token is pruned on its *next* send
instead - one notification later than it could have been, and nothing else
changes. See [Expo push setup](/docs/expo-setup).

## Failures that must not kill a token [#failures-that-must-not-kill-a-token]

The other half of the job is **not** disabling a device when the fault is yours.
A credential or configuration error would otherwise wipe out every device in one
send, and the tokens were perfectly valid the whole time.

| Provider | Signal                                              | Code                | Device disabled |
| -------- | --------------------------------------------------- | ------------------- | --------------- |
| `apns`   | `403 InvalidProviderToken` / `ExpiredProviderToken` | `provider_error`    | no              |
| `apns`   | `400 DeviceTokenNotForTopic`                        | `provider_error`    | no              |
| `apns`   | `429 TooManyRequests`                               | `rate_limited`      | no              |
| `apns`   | `413 PayloadTooLarge`                               | `payload_too_large` | no              |
| `fcm`    | `app/invalid-credential`                            | `provider_error`    | no              |
| `fcm`    | `messaging/authentication-error`                    | `provider_error`    | no              |
| `fcm`    | `messaging/message-rate-exceeded`, `quota-exceeded` | `rate_limited`      | no              |
| `fcm`    | `messaging/payload-size-limit-exceeded`             | `payload_too_large` | no              |
| any      | connection failure                                  | `network_error`     | no              |

> **A run of provider_error means check your config:** `provider_error` on every delivery for one provider is a credential problem -
> a wrong p8 key, an expired service account, the wrong APNs gateway - not a
> device problem. Nothing was disabled, so fixing the config is enough; there is
> no re-registration to chase.

## Rotation is just re-registration [#rotation-is-just-re-registration]

Tokens rotate: FCM reissues a registration token, iOS hands out a new device
token after a restore. There is no server-side rotation logic to write, and no
endpoint to call.

The device row is unique on `(provider, token)`, and `POST {basePath}/devices`
upserts against that key:

* **Same token again** - the existing row is refreshed: `last_seen_at` moves
  forward, and `disabled_at` is cleared, so a device that was disabled comes
  back to life the moment it re-registers.
* **New token** - a new active row. The old row lingers until its next send
  returns dead, at which point it is disabled by the rule above and stops being
  targeted.

So a client that re-registers whenever its token changes - on FCM's token
refresh callback, or on every launch - is all that is required. Worst case, one
notification is sent to a stale token once, fails, and prunes it.

```ts title="on the client, whenever the token changes"
await fetch("/api/push/devices", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ platform: "web", provider: "fcm", token: nextToken }),
});
```

## `staleDeviceAfter` [#staledeviceafter]

Some tokens go quiet without ever producing a dead-token signal - a phone that
was wiped, a browser profile that no longer exists. `staleDeviceAfter` is an
opt-in age cutoff for those:

```ts title="src/push.ts"
export const push = betterPush({
  // ...database, providers, session
  // Skip devices not seen in 90 days.
  staleDeviceAfter: 90 * 24 * 60 * 60 * 1000,
});
```

It is a number of **milliseconds**, validated as a positive integer. When set,
`notify()` drops any active device whose `last_seen_at` is older than
`Date.now() - staleDeviceAfter` before building deliveries. Such a device:

* gets **no delivery row** - it was never targeted, so there is nothing to
  record.
* is **not disabled** - it stays active, and re-registering refreshes
  `last_seen_at` and brings it straight back into fan-out.

It is **off by default**: leaving it unset targets every active device, which is
the behavior every earlier phase had. Disabled devices are always excluded,
whether or not you set it.

> **Pick a cutoff longer than your quietest user's gap:** A cutoff shorter than the interval between a user's sessions silently stops
> their notifications. Ninety days is a reasonable floor; if your app notifies
> users who rarely open it, do not set this at all.

## Putting it together [#putting-it-together]

For a healthy deployment you need exactly two things:

1. Clients re-register their token when it changes (and, cheaply, on launch).
2. Providers are configured correctly, so `provider_error` stays rare.

Everything else - pruning, re-enabling, per-delivery history - is already
handled by the send pipeline. See [Providers](/docs/providers) for how a single
`notify()` reaches all of them.


---

# Web Push setup

> VAPID keys, the service worker, and the HTTPS requirement explained.

Canonical documentation: /docs/web-push-setup



Web push has three moving parts you have to get right: VAPID keys, a service
worker served from your origin, and HTTPS. This page covers each.

## Registration UI [#registration-ui]

The styled copy-in toggle exposes the browser states users need to understand.
It uses fixture state here, so it never opens a permission prompt or calls an API.

**push-toggle:** Interactive browser push toggle covering available, busy, subscribed, denied, unsupported, and error states.

## VAPID keys [#vapid-keys]

VAPID (Voluntary Application Server Identification, RFC 8292) is how a push
service knows your server is allowed to send to a subscription. You need one key
pair for your whole application.

```ts
import { generateVapidKeys } from "@better-push/core/providers/web-push";

const { publicKey, privateKey } = generateVapidKeys();
```

* The **private key** stays on your server and is passed to `webPush({ vapid })`.
* The **public key** is passed to `webPush({ vapid })` **and** handed to the
  browser client as `applicationServerKey`.
* The **subject** must be a `mailto:` or `https:` URL - a contact the push
  service can reach if there is a problem.

```ts
webPush({
  vapid: {
    subject: "mailto:you@example.com",
    publicKey: process.env.VAPID_PUBLIC_KEY!,
    privateKey: process.env.VAPID_PRIVATE_KEY!,
  },
  ttl: 86400, // optional, seconds a push service holds a message; default 1 day
});
```

Rotating VAPID keys invalidates every existing subscription, so treat them as
long-lived secrets.

## The service worker [#the-service-worker]

A push notification is delivered to a **service worker**, not to a page - that
is what lets a notification arrive when your site is not open. Service workers
cannot import npm modules without a build step, so better-push ships the worker
as a static file for you to copy to `public/sw.js`. The exact source is also
exported:

```ts
import { SERVICE_WORKER_SOURCE } from "@better-push/core/client/sw";
```

The worker handles two events:

* **`push`** - reads the JSON payload and calls `showNotification(title, { body, data })`.
* **`notificationclick`** - focuses an existing window if there is one, else
  opens `data.url` (or `/`).

The payload your worker receives is the JSON string
`{ notificationId, title, body, data }` produced by `push.notify()`.

## The HTTPS requirement [#the-https-requirement]

Service workers and the Push API require a **secure context**. The one exception
is `http://localhost`, which browsers treat as secure - so local development
needs no certificates.

Everywhere else, including a phone pointed at your machine's LAN IP, you need
real HTTPS. This is why testing on devices means deploying to an HTTPS URL. See
[Testing On Devices](/docs/testing-on-devices).

## How sending maps to the push protocol [#how-sending-maps-to-the-push-protocol]

When you call `push.notify()`, the web push provider:

1. Parses each device's stored subscription JSON.
2. Encrypts the payload per RFC 8291 and signs the request with your VAPID keys
   (via the mature `web-push` package - better-push does not reimplement the
   crypto).
3. Sends to each subscription's endpoint with bounded concurrency.
4. Maps the push service's response: `404`/`410` mean the subscription is gone,
   so the device is disabled; `413`, `429`, and other errors are recorded on the
   delivery row without disabling the device.


---

# Digests

> Collapse a burst of notifications into one, with a fixed window or a sliding debounce.

Canonical documentation: /docs/digests



Twenty people like a post. Without digests that is twenty `notify()` calls,
twenty notifications, twenty feed rows, and twenty pushes to every device the
author owns. This is the single most common reason a team stops using a
notification library and starts building one.

A digest turns those twenty calls into **one** notification. Declare it on the
type:

```ts title="src/push.ts"
postLiked: define<{ postId: string; postTitle: string; actor: string }>({
  title: (p) => `${p.actor} liked your post`,
  label: "Post likes",
  group: "Social",
  digest: { // [!code highlight]
    debounce: "30s", // [!code highlight]
    maxWait: "10m", // [!code highlight]
    key: (p) => p.postId, // [!code highlight]
    render: (items, { total }) => ({ // [!code highlight]
      title: // [!code highlight]
        total === 1 // [!code highlight]
          ? `${items[0]!.actor} liked your post` // [!code highlight]
          : `${total} people liked "${items[0]!.postTitle}"`, // [!code highlight]
      body: // [!code highlight]
        total > 1 // [!code highlight]
          ? `${items.map((i) => i.actor).slice(0, 3).join(", ")} and others` // [!code highlight]
          : undefined, // [!code highlight]
    }), // [!code highlight]
  }, // [!code highlight]
}),
```

Nothing about the call site changes. `notify("postLiked", { userId, payload })`
still returns, but it now returns `{ outcome: "digested" }` - the send was
collected rather than delivered.

## The two shapes [#the-two-shapes]

Exactly one per type, enforced at construction.

**A fixed window.** The first event fixes the end time and later events do not
move it.

```ts
digest: { window: "5m", render }
```

Use it when the cadence should be predictable: a five-minute window sends at
most one notification every five minutes, no matter how the events arrive.

**A sliding debounce.** Each event pushes the end out, capped by `maxWait` so a
chatty stream still flushes.

```ts
digest: { debounce: "30s", maxWait: "10m", render }
```

Use it when you want to send once the burst is over. Twenty likes arriving over
a minute produce one notification thirty seconds after the last one - not four
notifications on window boundaries. `maxWait` is measured from the first event
and is required: without it a stream of one event every twenty-nine seconds
would never flush at all.

## The grouping key [#the-grouping-key]

`key` splits a type's windows by subject, so you get one digest per post rather
than one per user:

```ts
digest: { debounce: "30s", maxWait: "10m", key: (p) => p.postId, render }
```

Without it there is a single window per `(user, type)` and likes on two
different posts merge into one notification. With it, they are two.

> **Keep the cardinality low:** A key that is unique per event - a timestamp, a comment id - produces a digest
> per event, which digests nothing and writes a window row for every send. The
> key should name the *subject* people are reacting to, not the reaction.

The key is available to `render` as `context.key`, which is usually what a
`data.url` should point at:

```ts
render: (items, { total, key }) => ({
  title: `${total} people liked your post`,
  data: { url: `/posts/${key}` },
}),
```

## `render` [#render]

`render(items, context)` composes the one notification the window becomes. It
runs at flush time, on the flushing process.

```ts
interface DigestRenderContext {
  /** Every item appended, including any past `maxItems`. */
  total: number;
  /** The grouping key, or `""`. */
  key: string;
  userId: string;
  type: string;
  openedAt: Date;
  flushedAt: Date;
}

interface DigestRendered {
  title: string;
  body?: string;
  data?: Record<string, unknown>;
  /** Overrides the definition's channels for this digest. */
  channels?: Channel[];
}
```

**A single-item window still renders.** There is no "skip the digest when only
one thing happened" mode, because it would mean two code paths and two possible
wordings for the same event. Handle `total === 1` yourself - it is one ternary,
and it lets you write "Sam liked your post" instead of "1 person liked your
post".

## `maxItems` and `total` [#maxitems-and-total]

`items` is capped, `total` is not. Past the cap (100 by default, or
`digest.maxItems` per type, or `digest: { maxItems }` instance-wide) items are
counted but not stored, so one window can never grow into an unbounded document.

```ts
render: (items, { total }) => ({
  // `total` is honest even when `items` stopped growing.
  title: `${total} people liked your post`,
  // `items` is the sample you can name.
  body: items.slice(0, 3).map((i) => i.actor).join(", "),
}),
```

## What flushes a window [#what-flushes-a-window]

The window row in **your** Postgres is the source of truth. The queue only
supplies an alarm clock, so a lost or duplicated alarm can neither lose nor
duplicate a digest.

| Fold                       | A window flushes                                                   |
| -------------------------- | ------------------------------------------------------------------ |
| Database only, cron-driven | at the next `push.flushDigests()` or `POST /_internal/run-pending` |
| `queue: dbQueue()`         | within one poll interval (\~1s) of its due time                    |
| `queue: bullmq()`          | at its due time, on a native delayed job                           |

With no queue at all, call it from cron:

```ts title="app/api/cron/route.ts"
const stats = await push.flushDigests();
// -> { flushed, rearmed, failed, remaining }
```

A running worker sweeps for overdue windows on its own every 15 seconds, so a
window whose alarm was lost - a flushed Redis, a `bp_job` row deleted by hand, a
crash between the append and the enqueue - is still delivered. That sweep is
skipped entirely when no type declares a digest.

## Preferences and the feed [#preferences-and-the-feed]

Preferences are resolved **at flush time**, by the same code an immediate send
uses. A user who turns push off while a window is open gets a `suppressed`
delivery row and no push, and their in-app feed entry still appears if in-app is
on.

The feed gets exactly one entry, and one realtime `created` signal - from the
feed's point of view a digest is one new notification, which is the whole point.

## Reading the result [#reading-the-result]

```ts
const result = await push.notify("postLiked", { userId, payload });
if (result.outcome === "digested") {
  result.windowId;      // the window this landed in
  result.windowEndsAt;  // when it is currently expected to flush
  result.itemCount;     // items in the window, including this one
}
```

`windowEndsAt` is a snapshot: on a debounce, the next event moves it.

## Concurrency [#concurrency]

An append is a single `INSERT ... ON CONFLICT DO UPDATE` against a partial
unique index. Twenty likes are twenty cheap upserts onto one row, and two app
instances appending at the same moment is a database-level guarantee rather than
application logic.

The uniqueness covers windows that are open **and** unclaimed, so an event
arriving while a window is being rendered opens a fresh window instead of being
swallowed by a digest whose items have already been read. A flush takes a lease;
a worker that dies mid-render releases the window instead of stranding its
items.

## When a digest cannot be rendered [#when-a-digest-cannot-be-rendered]

A `render` that throws releases the claim, records `last_error`, and retries
with backoff. After `digest.maxFlushAttempts` (default 3) the window is closed
with no notification, logged as an error, and reported through the
`digest.failed` event. A deterministically broken `render` gives up visibly
rather than being swept forever.

## What digests do not do [#what-digests-do-not-do]

* **No digest for the ad-hoc `notify({...})` form.** Digest config lives on a
  definition, and an ad-hoc send has no payload for `key` or `render` to work
  with. An ad-hoc call whose `type` happens to match a digested definition
  delivers immediately.
* **No cross-type digests.** One window is one type.
* **No per-channel digests.** A window renders one notification, delivered on
  the channels its definition declares (or the ones `render` returns).

## Options [#options]

```ts
betterPush({
  digest: {
    /** Default item cap for types that do not set one. Default 100. */
    maxItems: 100,
    /** Flush lease in ms. Default 60000. A slower render risks a double flush. */
    leaseMs: 60_000,
    /** Failed flushes before a window gives up. Default 3. */
    maxFlushAttempts: 3,
    /** Windows the worker sweep flushes per tick. Default 50. */
    sweepBatch: 50,
  },
  retention: {
    /** Flushed windows are history; nothing is deleted without this. */
    digestWindows: "30d",
  },
});
```

## Events [#events]

```ts
betterPush({
  onEvent: (event) => {
    if (event.type === "digest.flushed") {
      // { windowId, userId, notificationType, key, total, notificationId }
    }
    if (event.type === "digest.failed") {
      // { windowId, userId, notificationType, key, total, attempts, error }
    }
  },
});
```

## Digest interfaces [#digest-interfaces]

**DigestConfig type reference:** Generated from `../../packages/better-push/src/digest/config.ts`. See the canonical documentation page for the field table.

**DigestOptions type reference:** Generated from `../../packages/better-push/src/digest/config.ts`. See the canonical documentation page for the field table.


---

# In-app feed

> A queryable, paginated in-app notification feed with unread counts and read state.

Canonical documentation: /docs/in-app-feed



Every `push.notify(...)` writes a `bp_notification` row. The **in-app feed**
exposes eligible rows as a paginated list with unread counts and
read state, plus headless React components to render it.

A notification is **in the feed** for its user when it has an `inApp` delivery
with status `delivered`. Suppressed and push-only notifications are excluded (but
still recorded for audit). See [Preferences](/docs/preferences) for how a
notification becomes suppressed.

## Endpoints [#endpoints]

All routes are added to the existing catch-all router - no change to your
Next.js or TanStack Start handler is needed. They require a session and use the
same JSON error envelope as the other mounted endpoints.

| Method & path                               | Purpose                        |
| ------------------------------------------- | ------------------------------ |
| `GET {basePath}/notifications`              | Paginated feed + `unreadCount` |
| `GET {basePath}/notifications/unread-count` | `{ count }`                    |
| `POST {basePath}/notifications/:id/read`    | Mark one read (idempotent)     |
| `POST {basePath}/notifications/read-all`    | Mark all unread read           |

`GET /notifications` takes `limit` (default 20, max 50) and an opaque `cursor`
for keyset pagination (newest first). Its response:

```json
{
  "notifications": [
    { "id": "...", "type": "orderShipped", "title": "...", "body": "...",
      "data": {}, "readAt": null, "createdAt": "..." }
  ],
  "nextCursor": "opaque-or-null",
  "unreadCount": 3
}
```

`nextCursor` is `null` on the last page; an invalid cursor returns `400`.

## `useNotificationFeed` [#usenotificationfeed]

```tsx title="app/inbox.tsx"
"use client";

import { useNotificationFeed } from "@better-push/core/react";

export function Inbox() {
  const {
    notifications,
    unreadCount,
    isLoading,
    hasMore,
    loadMore,
    markRead,
    markAllRead,
  } = useNotificationFeed({ pageSize: 20, pollInterval: 30000 });

  if (isLoading) return <p>Loading…</p>;
  return (
    <div>
      <button onClick={() => markAllRead()}>Mark all read ({unreadCount})</button>
      <ul>
        {notifications.map((n) => (
          <li key={n.id} onClick={() => markRead(n.id)}>
            {n.title}
          </li>
        ))}
      </ul>
      {hasMore && <button onClick={() => loadMore()}>Load more</button>}
    </div>
  );
}
```

`markRead` and `markAllRead` update local state optimistically. The hook is
visibility-aware: polling pauses when the tab is hidden and refreshes on
re-show, backing off on repeated network errors.

## Clearing the badge when a push is tapped [#clearing-the-badge-when-a-push-is-tapped]

By default, tapping a push notification does not mark it read: the unread count
stays where it was until the user opens the item in the feed. Opt in with one
flag:

```tsx
const feed = useNotificationFeed({ markReadOnPushClick: true });
```

With it on, clicking a push notification marks that notification read wherever
the user is signed in - tapping on a phone clears the badge in the browser too.

It works because every provider now ships the `notificationId` alongside the
payload, and the service worker passes it to the page on click (it does not call
the API itself: a worker `fetch` would only carry cookie sessions, not bearer
tokens). If no tab is open, the worker appends `?bp_read=<id>` to the URL it
opens and the hook consumes it. The parameter is always stripped from the
address bar, whether or not the option is on.

> **A tap is not always a read:** Off by default on purpose. In a chat app, "read" usually means the thread was
> opened and seen, not that a banner was dismissed into the app. Turn it on only
> where opening the push really is the acknowledgement.

React Native gets the same option from the same hook:
`useNotificationFeed({ markReadOnPushClick: true })` in
[`@better-push/core/native`](/docs/react-native) wires both an `expo-notifications`
response listener and the tap that cold-started the app.

## Transport [#transport]

By default the hook does not simply poll. It opens the server's
[SSE stream](/docs/realtime-feed) at `GET {basePath}/events` and refetches the
moment something changes, keeping a slow safety poll behind it. Where streaming
does not work - a serverless host, a buffering proxy, React Native - it falls
back permanently to polling on its own, and says so:

```tsx
const feed = useNotificationFeed();
feed.transport; // "sse" while a stream is live, "polling" otherwise
```

| Option             | Default  | Meaning                                                    |
| ------------------ | -------- | ---------------------------------------------------------- |
| `pollInterval`     | `30000`  | Poll interval when no stream is live                       |
| `idlePollInterval` | `300000` | Safety poll interval while a stream is live                |
| `transport`        | `"auto"` | `"polling"` never opens a stream; `"sse"` never falls back |

Nothing about your component changes either way - the hook returns the same
shape it always did. With more than one app instance, cross-instance signals
need [`cache: redis(...)`](/docs/cache); see
[Realtime Feed](/docs/realtime-feed) for the whole picture.

## Headless components [#headless-components]

The styled copy-in inbox shows the feed states and interactions without making
requests from this page:

**notification-inbox:** Interactive styled notification inbox with populated, empty, loading, and error states.

`@better-push/core/react` ships **headless** components: minimal semantic markup, no
design system, fully styleable via `className` and render-prop props. They work
identically on Next.js and TanStack Start.

```tsx title="app/header.tsx"
"use client";

import { useState } from "react";
import {
  NotificationBell,
  NotificationInbox,
  useNotificationFeed,
} from "@better-push/core/react";

export function Header() {
  const feed = useNotificationFeed();
  const [open, setOpen] = useState(false);
  return (
    <div style={{ position: "relative" }}>
      <NotificationBell feed={feed} open={open} onOpenChange={setOpen} />
      {open && <NotificationInbox feed={feed} />}
    </div>
  );
}
```

* **`NotificationBell`** - an icon slot plus an unread badge. Props: `onClick`,
  `renderBadge`, `className`, `children` (custom icon), and an `open` /
  `onOpenChange` passthrough.
* **`NotificationInbox`** - the list: each item shows title, body, relative time,
  and an unread indicator; clicking marks it read and, if `data.url` is set,
  links there. Includes "mark all read" and a "load more" trigger. Props:
  `renderItem`, `emptyState`, `className`.

Passing a shared `feed` (as above) makes the bell and inbox use one hook so a
read in the inbox updates the badge immediately. Omit it and each component
manages its own feed. Every component renders with zero props too.

## Hook result [#hook-result]

**UseNotificationFeed type reference:** Generated from `../../packages/better-push/src/client/shared/use-feed-core.ts`. See the canonical documentation page for the field table.


---

# Preferences

> Per-type, per-channel notification preferences with deterministic resolution and suppression.

Canonical documentation: /docs/preferences



Preferences let users turn a notification type—or a single channel—off.
Subsequent `notify()` calls **suppress** disabled channels: the push is not
sent and/or the item does not enter the in-app feed, while an auditable
`suppressed` delivery row is still recorded.

Preferences are rows in **your** Postgres (`bp_preference`), keyed by
`(user_id, type, channel)`. Add the table to your schema:

```ts title="src/db/schema.ts"
export {
  bpDevice,
  bpNotification,
  bpDelivery,
  bpPreference,
} from "@better-push/core/adapters/drizzle/schema";
```

## Resolution rule [#resolution-rule]

For a `(userId, type, channel)`, `enabled` is decided by the **first** match in
this precedence order:

| # | Match                                                         |
| - | ------------------------------------------------------------- |
| 1 | stored row `(type, channel)` exact                            |
| 2 | stored row `(type, "*")`                                      |
| 3 | stored row `("*", channel)`                                   |
| 4 | stored row `("*", "*")`                                       |
| 5 | the definition's `defaultEnabled` (if the type is registered) |
| 6 | `true` (global default)                                       |

Resolution is per channel, computed from a single `listPreferences(userId)` read
plus in-memory precedence - never a query per channel. `"*"` is the wildcard for
"all types" or "all channels".

## Endpoints [#endpoints]

Added to the existing router; both require a session.

### `GET {basePath}/preferences` [#get-basepathpreferences]

Returns one entry per **registered** notification type, with resolved per-channel
state:

```json
{
  "preferences": [
    { "type": "orderShipped", "label": "Order shipped", "group": "Orders",
      "channels": { "push": true, "inApp": true } },
    { "type": "newComment", "label": "New comment", "group": "Social",
      "channels": { "push": false, "inApp": true } }
  ]
}
```

The channels listed per type are the definition's `channels`. If `notifications`
is empty, `preferences` is `[]`.

### `PUT {basePath}/preferences` [#put-basepathpreferences]

Bulk upsert on `(user_id, type, channel)`:

```json
{ "preferences": [ { "type": "newComment", "channel": "push", "enabled": false } ] }
```

Each `type` must be `"*"` or a registered type key; each `channel` must be
`"push"`, `"inApp"`, or `"*"`. An unknown type or channel returns `400`. The
response is the same shape as `GET /preferences` (the post-update resolved view),
so the client can re-render without a second fetch.

## `usePreferences` [#usepreferences]

```tsx title="app/settings/notifications.tsx"
"use client";

import { usePreferences } from "@better-push/core/react";

export function Settings() {
  const { preferences, dirty, isSaving, setPreference, save } = usePreferences();
  return (
    <div>
      {preferences.map((entry) =>
        Object.entries(entry.channels).map(([channel, on]) => (
          <label key={entry.type + channel}>
            <input
              type="checkbox"
              checked={on}
              onChange={(e) => setPreference(entry.type, channel as "push" | "inApp", e.target.checked)}
            />
            {entry.label} · {channel}
          </label>
        )),
      )}
      <button disabled={!dirty || isSaving} onClick={() => save()}>
        {isSaving ? "Saving…" : "Save"}
      </button>
    </div>
  );
}
```

`setPreference` edits local state (and sets `dirty`); `save` PUTs only the changed
entries and adopts the server's resolved view from the response. `reset` discards
edits.

## `NotificationPreferences` component [#notificationpreferences-component]

This styled copy-in version is interactive and uses fixture state, so changing a
switch here does not save anything to a server:

**notification-preferences:** Interactive grouped notification preferences with in-app and push switches.

The headless component renders `usePreferences` as grouped rows of per-channel
toggles with a Save button:

```tsx
"use client";
import { NotificationPreferences } from "@better-push/core/react";

export default function Page() {
  return <NotificationPreferences />;
}
```

Props: `renderRow` and `className` for styling, or pass a shared `preferences`
hook result.

In React Native the same `usePreferences()` hook is available from
[`@better-push/core/native`](/docs/react-native), with the same return value; you
render the toggles with RN primitives.

## Suppression [#suppression]

When you call `notify()`, better-push resolves each requested channel:

* **inApp enabled** → an `inApp` `delivered` delivery (the item enters the feed).
* **inApp disabled** → an `inApp` `suppressed` delivery (excluded from the feed).
* **push enabled** → active devices are loaded and sent through their provider.
* **push disabled** → one `push` `suppressed` marker, and no device sends.

The `bp_notification` row is always written (it is the event record).
`NotifyResult.deliveries` includes the suppressed entries so callers can see what
happened.

> **Preference scope:** Preferences are per-type, per-channel on/off switches. Quiet-hour windows and
> time-based suppression are not supported.

## Hook result [#hook-result]

**UsePreferences type reference:** Generated from `../../packages/better-push/src/client/shared/use-preferences-core.ts`. See the canonical documentation page for the field table.


---

# Scheduled sends

> Send this tomorrow at 9am - with an id you can cancel.

Canonical documentation: /docs/scheduled-sends



Pass `at` and the send happens later:

```ts
const result = await push.notify("orderShipped", {
  userId,
  payload: { orderId, eta },
  at: new Date("2026-08-01T09:00:00Z"), // [!code highlight]
});
// -> { outcome: "scheduled", scheduledId: "sched_…", at: Date }
```

`at` works on both `notify()` forms, typed and ad-hoc.

## Nothing is written until it fires [#nothing-is-written-until-it-fires]

The job carries the **original call**, not a half-written notification:

```ts
type SendJobPayload =
  | { form: "typed"; type: string; args: Record<string, unknown> }
  | { form: "adhoc"; input: Record<string, unknown> };
```

At the due time a worker replays it through the ordinary `notify()` pipeline.
Three things follow, and they are the reason it is stored this way:

* **Preferences resolve at fire time.** A user who opts out between scheduling
  and firing correctly receives nothing.
* **The device list resolves at fire time.** A phone registered yesterday gets
  the send; one deleted yesterday does not.
* **The definition's current functions run.** If you change a `title` template
  and redeploy, tomorrow's send uses the new one.

There is no schema change and no partially-written notification to reconcile,
because until it fires there is nothing but a job.

## Cancelling [#cancelling]

```ts
const { scheduledId } = result;
await push.cancelScheduled(scheduledId); // -> boolean
```

False means the id is unknown or the send is already being processed. A
cancellation that silently did nothing would be worse than none, so this reports
honestly rather than optimistically.

## A queue is required [#a-queue-is-required]

This is the one capability that genuinely cannot degrade: without a queue there
is nothing to hold the send, so `at` throws at the call site with a message
naming the fix.

```ts
// BetterPushError: notify({ at }) needs a queue to hold the send until it is
// due: add `queue: dbQueue()` or `queue: bullmq({ connection })` to betterPush()
```

Both backends do it; only precision changes.

|                    | Fires                                                   |
| ------------------ | ------------------------------------------------------- |
| `queue: dbQueue()` | within one poll interval (\~1s default) of its due time |
| `queue: bullmq()`  | at its due time, on a native delayed job                |

## Validation [#validation]

All at the call site, so failures are synchronous and loud:

* **An `at` in the past** (or within a second of now) **delivers immediately**
  and returns `{ outcome: "delivered" }`. A scheduler that quietly drops "send
  this at a time that just passed" is a bug generator.
* **No queue configured** throws `INVALID_CONFIG`, naming the fix.
* **More than a year out** is rejected. BullMQ holds delayed jobs in a
  memory-backed sorted set and `dbQueue` holds a row every claim scans past; a
  five-year timer is always a mistake, and finding out now beats finding out in
  2031\.
* **`at` that is not a `Date`** throws `INVALID_INPUT`.

## The result union [#the-result-union]

`NotifyResult` is discriminated on `outcome`, so TypeScript forces you to handle
the mode you configured - and `result.notificationId` cannot be read on a send
that has not happened yet.

```ts
const result = await push.notify("orderShipped", { userId, payload, at });

switch (result.outcome) {
  case "delivered":
    result.notificationId;
    result.deliveries;
    break;
  case "scheduled":
    result.scheduledId;
    result.at;
    break;
  case "digested":
    result.windowId;
    result.windowEndsAt;
    result.itemCount;
    break;
}
```

Where a caller only wants "did it go out now", it checks
`result.outcome === "delivered"`.

## When the definition changes before it fires [#when-the-definition-changes-before-it-fires]

The replay looks the type up **at fire time**:

* A payload the definition no longer accepts, or a type no longer registered at
  all, is not retryable. The job is dead-lettered immediately and shows up on
  the studio's queue screen, rather than retrying five times on its way to the
  same place.
* A database failure **is** retryable, and backs off normally.

## Scheduling a digested type [#scheduling-a-digested-type]

A scheduled send of a type that declares a [digest](/docs/digests) **appends to
a window at fire time**. That is the correct composition of the two features
rather than a special case: the replay runs the ordinary pipeline, and the
ordinary pipeline routes a digested type into its window.

## Events [#events]

```ts
betterPush({
  onEvent: (event) => {
    if (event.type === "notification.scheduled") {
      // { scheduledId, userId, notificationType, at }
    }
    if (event.type === "notification.canceled") {
      // { scheduledId }
    }
  },
});
```

## Not in scope [#not-in-scope]

* **No quiet hours or timezone-aware scheduling.** `at` is an absolute instant.
  Per-user quiet hours need a user timezone better-push does not store.
* **No recurring or cron-style schedules.** One-shot `at` only. BullMQ has
  repeatable jobs and `dbQueue` does not, so shipping them would break the
  "everything works on every backend" promise on day one. Compute the next
  instant yourself and schedule it when the previous one fires.


---

# Typed notifications

> Declare notification types once with define() and get type-checked notify() calls.

Canonical documentation: /docs/typed-notifications



Typed notification definitions let you declare each notification type once.
`notify()` then derives the payload type, title, body, and the
preferences UI from that single declaration. The lightweight ad-hoc form stays
available for one-off sends.

## `define` and the `notifications` config key [#define-and-the-notifications-config-key]

Wrap each type with `define<Payload>` and pass a `notifications` map to
`betterPush`:

```ts title="src/push.ts"
import { betterPush, define } from "@better-push/core";
import { drizzleAdapter } from "@better-push/core/adapters/drizzle";
import { webPush } from "@better-push/core/providers/web-push";
import { db } from "@/db";

export const push = betterPush({
  database: drizzleAdapter(db),
  providers: [webPush({ vapid: { /* ... */ } })],
  notifications: {
    orderShipped: define<{ orderId: string; eta: string }>({
      title: (p) => `Order ${p.orderId} shipped`,
      body: (p) => `Arriving ${p.eta}`,
      data: (p) => ({ url: `/orders/${p.orderId}` }),
      label: "Order shipped",
      group: "Orders",
    }),
    newComment: define<{ postId: string; author: string }>({
      title: (p) => `New comment from ${p.author}`,
      label: "New comment",
      group: "Social",
    }),
  },
  session: async (request) => {
    /* ... */
  },
});
```

`betterPush` captures the `notifications` map in its type, so the returned
`push.notify` is checked against your definitions.

### Definition fields [#definition-fields]

| Field            | Meaning                                                                |
| ---------------- | ---------------------------------------------------------------------- |
| `title`          | Static string, or `(payload) => string`. &#x2A;*Required.**            |
| `body`           | Static string, or `(payload) => string \| undefined`. Optional.        |
| `channels`       | Channels this type targets. Default `["push", "inApp"]`.               |
| `data`           | `(payload) => Record<string, unknown>`, deep-merged under `args.data`. |
| `label`          | Human label for the preferences UI. Defaults to the type key.          |
| `group`          | Optional grouping bucket in the preferences UI.                        |
| `defaultEnabled` | Fallback when no preference row exists. Default `true`.                |

Invalid definitions throw a `BetterPushError` at construction naming the type:
every definition needs a `title`; `channels`, if present, is a non-empty subset
of `["push", "inApp"]`; and a type key may not be `"*"` (reserved for wildcard
preferences).

## The two `notify` forms [#the-two-notify-forms]

### Typed (primary) [#typed-primary]

```ts
await push.notify("orderShipped", {
  userId: user.id,
  payload: { orderId: "1024", eta: "tomorrow" },
  // channels?: override the definition's channels
  // data?: merged over definition.data(payload)
});
```

The `payload` is inferred from `define<Payload>`. A wrong payload shape is a
compile error; an unknown type key is a compile error too. When a definition's
`Payload` is `void`, `payload` is optional.

### Ad-hoc (one-off sends) [#ad-hoc-one-off-sends]

```ts
await push.notify({
  userId: user.id,
  title: "Something happened",
  body: "No declared type needed.",
  data: { url: "/" },
  // type?: defaults to "default"; channels?: defaults to both
});
```

An app that never declares a type can use only this form - `notifications` is
optional. If an ad-hoc `type` happens to match a registered definition, that
definition's `defaultEnabled` and label still apply for preference resolution.

## Resolution [#resolution]

For the typed form, better-push computes:

* `channels = args.channels ?? definition.channels ?? ["push", "inApp"]`
* `title = typeof def.title === "function" ? def.title(payload) : def.title`
* `body` likewise
* `data = { ...def.data?.(payload), ...args.data }`

The result flows into the normal send pipeline and is gated by
[preferences](/docs/preferences).

> **Root exports:** `define`, `NotificationDefinition`, and `NotificationDefinitions` are exported
> from the package root (`@better-push/core`).


---

# UI components

> Styled components you copy into your project and own.

Canonical documentation: /docs/ui-components



`@better-push/core/react` ships **headless** components: semantic markup, no design
system, style them however you like. That is the right library default and the
wrong first impression.

So there is a second set: four styled components you copy into your project, in
the shadcn model. They are built on the same hooks, they land as files in your
repo, and the moment they are there they are yours to restyle, reorder, or
delete half of.

```bash
npx @better-push/cli add --list
npx @better-push/cli add notification-bell
```

## What ships [#what-ships]

| Component                  | What it is                                                                       |
| -------------------------- | -------------------------------------------------------------------------------- |
| `notification-inbox`       | The feed list: read state, infinite cursor, empty and error states, live updates |
| `notification-bell`        | An unread badge over the inbox, in a popover                                     |
| `notification-preferences` | Grouped per-type, per-channel switches, saved as one batch                       |
| `push-toggle`              | Enable browser push, with the `denied` and `unsupported` states handled          |

## Component gallery [#component-gallery]

These previews render the same source files that the CLI and shadcn registry
copy into your project. Use the controls to inspect the important states.

### Notification inbox [#notification-inbox]

**notification-inbox:** Interactive notification inbox preview with populated, empty, loading, and error states.

### Notification bell [#notification-bell]

Click the bell to open its inbox. Change the unread count with the control.

**notification-bell:** Interactive notification bell with an unread badge and inbox popover.

### Notification preferences [#notification-preferences]

The switches edit local state. Save and Discard become available after a change.

**notification-preferences:** Interactive grouped notification preferences with in-app and push switches.

### Push toggle [#push-toggle]

The status control includes idle, registering, subscribed, denied, unsupported,
and error states. Preview actions never request browser permission.

**push-toggle:** Interactive browser push toggle covering available, busy, subscribed, denied, unsupported, and error states.

Each is Tailwind plus shadcn primitives, imports only from `@better-push/core/react`,
and is covered by tests that render it against a mocked transport.

## Two install paths [#two-install-paths]

### The CLI [#the-cli]

```bash
npx @better-push/cli add notification-bell
```

Works offline, without shadcn, and without a docs domain being live. It finds
your components directory from `components.json` when shadcn is set up and falls
back to `{src}/components/better-push/` otherwise, rewrites the import paths for
your project's alias, refuses to overwrite without `--force`, and prints the
shadcn primitives you still need.

Dependencies come with it: asking for the bell writes the inbox and the shared
format helper too, because the bell imports them.

### shadcn [#shadcn]

```bash
export BETTER_PUSH_DOCS_ORIGIN="https://docs.example.com"
npx shadcn@latest add "$BETTER_PUSH_DOCS_ORIGIN/r/notification-inbox.json"
```

Same components, same source. If you are already in that workflow, use it.

> **One source, two builds:** Both come from `packages/ui-registry`. A CI test asserts the docs-site registry
> and the CLI's embedded copy are byte-for-byte identical, because two consumers
> of one source is exactly the shape that drifts silently.

## What you still need [#what-you-still-need]

The components use these shadcn primitives:

```bash
npx shadcn@latest add badge button popover scroll-area skeleton switch
```

`add` prints the ones your chosen components need, so you do not have to read
this list.

## Using them [#using-them]

```tsx title="app/layout.tsx"
import { NotificationBell } from "@/components/better-push/notification-bell";

<header>
  <NotificationBell />
</header>
```

```tsx title="app/settings/notifications/page.tsx"
import { NotificationPreferences } from "@/components/better-push/notification-preferences";
import { PushToggle } from "@/components/better-push/push-toggle";

export default function Page() {
  return (
    <>
      <PushToggle applicationServerKey={process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!} />
      <NotificationPreferences />
    </>
  );
}
```

The bell owns one feed hook and passes it to the inbox, so opening the popover
does not start a second poll or a second SSE stream for the same user. If you
render both separately, share the hook yourself:

```tsx
const feed = useNotificationFeed();

<NotificationBell />
<NotificationInbox feed={feed} />
```

## When to stay headless [#when-to-stay-headless]

If you already have a design system, **the hooks are the API** and these files
are an example. `useNotificationFeed`, `usePreferences` and `usePushRegistration`
are the whole surface; everything in these components is markup over them.

Copy one, read how it wires the hook, then write your own. That is a better
outcome than fighting a `className` prop into matching your buttons.


---

# Database adapters

> One Postgres SQL core, three drivers - pg, Drizzle, and Prisma - and who owns the schema.

Canonical documentation: /docs/database-adapters



better-push needs Postgres. It does not need a particular way of talking to it:
pick `pg`, Drizzle, or Prisma, and every capability works identically because
all three run the same SQL.

```ts title="src/push.ts"
import { postgresAdapter } from "@better-push/core/adapters/postgres";
// or
import { drizzleAdapter } from "@better-push/core/adapters/drizzle";
// or
import { prismaAdapter } from "@better-push/core/adapters/prisma";
```

## Which one [#which-one]

| You already use | Pick                    | Why                                                                                                |
| --------------- | ----------------------- | -------------------------------------------------------------------------------------------------- |
| Drizzle         | `drizzleAdapter(db)`    | Your `drizzle-kit` generates the migration; better-push ships the table declarations.              |
| Prisma          | `prismaAdapter(prisma)` | One client, one connection pool. Add the model fragment if you also want to query `bp_*` yourself. |
| Neither         | `postgresAdapter(url)`  | No ORM in the path. This is also the smallest dependency footprint.                                |

There is no wrong answer here in the way there usually is: the driver decides
how a statement is *sent*, not what it does.

## The three drivers [#the-three-drivers]

### `pg` [#pg]

```ts
import { postgresAdapter } from "@better-push/core/adapters/postgres";

export const adapter = postgresAdapter(process.env.DATABASE_URL!);
```

Pass a connection string and better-push builds a `Pool`; pass a `Pool` you
already have and it is **borrowed and never ended** - whoever opened a
connection closes it. `pg` is an optional peer dependency, imported lazily, so
an app on Drizzle or Prisma never installs it.

`schema` sets the `search_path` for a pool better-push creates, for a database
that keeps the `bp_*` tables outside `public`:

```ts
postgresAdapter(process.env.DATABASE_URL!, { schema: "notifications" });
```

### Drizzle [#drizzle]

```ts
import { drizzle } from "drizzle-orm/node-postgres";
import { drizzleAdapter } from "@better-push/core/adapters/drizzle";

export const adapter = drizzleAdapter(drizzle(process.env.DATABASE_URL!));
```

Any Drizzle Postgres client works - `node-postgres`, `postgres.js`, Neon,
whichever - because the driver normalizes the two result shapes those return.
Include the table declarations in your own schema so `drizzle-kit` generates
the migration:

```ts title="src/db/schema.ts"
export {
  bpDevice,
  bpNotification,
  bpDelivery,
  bpPreference,
  bpJob,
  bpDigestWindow,
  bpMetricRollup,
  bpRollupState,
  bpWorker,
  bpAudit,
  bpMigration,
} from "@better-push/core/adapters/drizzle/schema";
```

### Prisma [#prisma]

```ts
import { PrismaClient } from "@prisma/client";
import { prismaAdapter } from "@better-push/core/adapters/prisma";

export const adapter = prismaAdapter(new PrismaClient());
```

better-push takes no Prisma dependency: `prismaAdapter` accepts anything with
`$queryRawUnsafe` and `$transaction`, so a generated client of any shape works.

`transactionTimeoutMs` defaults to **15 seconds** rather than Prisma's own 5.
better-push opens exactly two interactive transactions - writing a notification
with its deliveries, and replacing a range of rollup buckets - and the second
one is a real amount of work on a busy hour.

```ts
prismaAdapter(prisma, { transactionTimeoutMs: 30_000 });
```

## Who owns the schema [#who-owns-the-schema]

`BP_SCHEMA_SQL` is the source of truth. Everything else is a way of expressing
it, and a test in this repository builds each one for real and diffs the
catalog, so they cannot drift.

```ts
import { applySchema, BP_SCHEMA_SQL, BP_TABLES } from "@better-push/core/adapters/sql";
```

| Path                                        | What it is                                                                                  |
| ------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `BP_SCHEMA_SQL`                             | The complete schema as idempotent DDL. Every object is `IF NOT EXISTS`.                     |
| `applySchema(executor)`                     | Runs it and records the version in `bp_migration`. Safe to run repeatedly and concurrently. |
| `@better-push/core/adapters/drizzle/schema` | The same schema as Drizzle tables, for `drizzle-kit`.                                       |
| `@better-push/core/adapters/prisma/schema`  | The same schema as Prisma models, plus the SQL Prisma cannot express.                       |

### Prisma cannot express a partial index [#prisma-cannot-express-a-partial-index]

better-push has three, and one of them is load-bearing.

The first-class path is the CLI, which writes both halves for you:

```bash
npx @better-push/cli generate --orm prisma
```

```
created  prisma/better-push.prisma
created  prisma/better-push-indexes.sql

  1. Paste better-push.prisma into your schema (or keep it as a
     multi-file schema member).
  2. npx prisma migrate dev
  3. psql "$DATABASE_URL" -f prisma/better-push-indexes.sql
     (or: npx @better-push/cli migrate, which creates everything
     including these three indexes)
```

`npx @better-push/cli migrate` skips all of it and creates the complete schema in one
step, and `npx @better-push/cli doctor` reports the missing index as an **error** if
you take the Prisma path and forget the SQL.

The same two artifacts are exported for a script that would rather build them
itself:

```ts
import {
  PRISMA_SCHEMA_FRAGMENT,
  PRISMA_UNSUPPORTED_SQL,
} from "@better-push/core/adapters/prisma/schema";
```

Paste `PRISMA_SCHEMA_FRAGMENT` into your `schema.prisma`, run
`prisma migrate dev`, then **run `PRISMA_UNSUPPORTED_SQL` once**:

* `bp_digest_window_open_unique` is what makes a digest append a single
  statement. It covers only windows that are open *and* unclaimed, so an event
  arriving while a window is being rendered opens a fresh window instead of
  being swallowed by a digest whose items have already been read. Without it,
  every append opens a new window and one digest becomes twenty.
* `bp_digest_window_due_idx` and `bp_job_due_idx` are the sweep's and the
  claim's exact predicates. Without them both still work, and scan instead.

Skipping the first is not a slow digest. It is a duplicated one.

> **A Prisma-created database is not finished:** `prisma migrate` builds every table, column, constraint and ordinary index
> correctly. It silently omits all three partial indexes, because the schema
> language has no way to say them - and it will do so again on every reset.`npx @better-push/cli doctor` is the second line of defence: it reports
> `bp_digest_window_open_unique` as an error, and the other two as warnings.

## Writing a fourth driver [#writing-a-fourth-driver]

All 51 `DatabaseAdapter` methods are implemented **once**, as Postgres SQL over
a two-method executor. A driver for a client better-push does not ship is
therefore about 120 lines:

```ts
import { sqlAdapter, type SqlExecutor } from "@better-push/core/adapters/sql";

function executorFor(client: MyClient): SqlExecutor {
  return {
    query: ({ text, params }) => client.query(text, params),
    transaction: (fn) => client.begin((tx) => fn(executorFor(tx))),
  };
}

export const adapter = sqlAdapter(executorFor(client));
```

Two methods, and that is deliberate: every method added to this seam has to be
implemented by every driver, including ones nobody here maintains.

There is **no affected-row count**, because Prisma's raw API does not expose
one. Every statement that needs a count returns `RETURNING id` and counts the
rows. A client that cannot answer with rows cannot back better-push.

`transaction` must run `fn` on a single connection. On a pool that means
checking a client out - a `BEGIN` on one connection and a `COMMIT` on another
is not a transaction, it is two statements that happen to look like one.

## Not supported [#not-supported]

* **MySQL and SQLite.** The SQL is deliberately Postgres dialect:
  `FOR UPDATE SKIP LOCKED`, `ON CONFLICT` against a partial index, row-value
  keyset pagination, `percentile_cont`. A second dialect would mean branches in
  51 methods.
* **Edge runtimes.** `pg` needs Node.

## Adapter interfaces [#adapter-interfaces]

**DatabaseAdapter type reference:** Generated from `../../packages/better-push/src/adapters/types.ts`. See the canonical documentation page for the field table.

**SqlExecutor type reference:** Generated from `../../packages/better-push/src/adapters/sql/executor.ts`. See the canonical documentation page for the field table.


---

# Express

> Mount better-push on Express, and the three things that decide whether it works.

Canonical documentation: /docs/express



`push.handler` is a `(Request) => Promise<Response>` function. Express speaks
`IncomingMessage`/`ServerResponse`, so the integration is one bridge - the same
one `@better-push/core/node`, NestJS and the CLI studio all use.

```ts title="src/server.ts"
import express from "express";
import { toExpressHandler } from "@better-push/core/express";
import { push } from "./push";

const app = express();
app.use(express.json());
app.all("/api/push/*splat", toExpressHandler(push));

app.listen(3000);
```

That is the file this project's conformance suite boots and asserts against, so
the example above is code that CI runs rather than code someone wrote once.

> **Express 4:** Express 4 spells the wildcard `"/api/push/*"`. Express 5 moved to
> path-to-regexp v8, where a bare `*` is invalid and a named splat
> (`"*splat"`) is required.

## Three things that decide whether it works [#three-things-that-decide-whether-it-works]

### Mount it before `compression()` [#mount-it-before-compression]

```ts
app.all("/api/push/*splat", toExpressHandler(push));
app.use(compression());   // after, always
```

Compression buffers, and `GET /api/push/events` is a Server-Sent Events stream
that never ends on its own. Compressed, it produces no output at all until the
stream closes - which is to say, the in-app feed silently stops updating and
nothing anywhere reports an error.

### `basePath` must match the mount path [#basepath-must-match-the-mount-path]

```ts
export const push = betterPush({ basePath: "/api/push", /* ... */ });
app.all("/api/push/*splat", toExpressHandler(push));
```

The router matches on the **full pathname**, not on what Express stripped off
before calling the handler. Mounting at `/push` while `basePath` is `/api/push`
answers 404 for every endpoint.

### A body parser is tolerated, not required [#a-body-parser-is-tolerated-not-required]

`express.json()` consumes the request stream. The bridge notices - `req.body`
is present - and re-serializes it, so a globally mounted parser is fine.

The one difference: `express.json()` rejects malformed JSON itself, with its own
400, before better-push sees the request. Mount the push routes before the
parser if you want better-push's error envelope for that case.

## Streaming and disconnects [#streaming-and-disconnects]

The bridge streams the response body straight to the socket - it never buffers
through `arrayBuffer()` - and wires an `AbortController` to the client
disconnecting. Both exist for the SSE route: without the first the feed hangs,
and without the second every disconnected client leaks a stream registration
until its lifetime cap.

It also sets `x-accel-buffering: no` on event streams, which is what stops nginx
from re-introducing the buffering Express was told not to do.

## Behind a proxy [#behind-a-proxy]

The bridge builds the request URL from `x-forwarded-proto` and `Host`. Override
it when your proxy does not set those:

```ts
toExpressHandler(push, { origin: "https://app.example.com" });
```

## Plain `node:http` [#plain-nodehttp]

Skip Express entirely if you have nothing else to mount:

```ts
import { createServer } from "node:http";
import { toNodeHandler } from "@better-push/core/node";

createServer(toNodeHandler(push)).listen(3000);
```


---

# Hono

> Mount better-push on Hono - four lines, and nothing is adapted.

Canonical documentation: /docs/hono



Hono speaks web standards, and so does better-push. `c.req.raw` is already a
`Request` and `push.handler` already returns a `Response`, so this integration
unwraps a context and stops.

```ts title="src/server.ts"
import { Hono } from "hono";
import { toHonoHandler } from "@better-push/core/hono";
import { push } from "./push";

const app = new Hono();
app.all("/api/push/*", toHonoHandler(push));

export default app;
```

That is the file this project's conformance suite boots and asserts against -
including the Server-Sent Events case, which streams here because the runtime
under Hono already streams a `Response` body.

`basePath` in the better-push config must match the mount path: the router
matches on the full pathname, not on what Hono stripped.

No `hono` dependency is added to your app by better-push. The context is typed
structurally as `{ req: { raw: Request } }`.

## Any runtime Hono runs on [#any-runtime-hono-runs-on]

Because nothing is adapted, better-push runs wherever Hono does - as long as the
rest of your configuration does too:

| Runtime                         | Works | Caveat                                                                     |
| ------------------------------- | ----- | -------------------------------------------------------------------------- |
| Node (`@hono/node-server`)      | Yes   | The reference setup.                                                       |
| Bun, Deno                       | Yes   | `pg` and `firebase-admin` need Node APIs; check your driver and providers. |
| Cloudflare Workers, Vercel Edge | No    | `postgresAdapter` needs `pg`, and `apns()` needs `node:http2`.             |

Edge is a deliberate non-goal rather than an oversight: better-push talks to
Postgres and to APNs over HTTP/2, and both want Node.

## Mounting under a base path [#mounting-under-a-base-path]

```ts
const api = new Hono();
api.all("/push/*", toHonoHandler(push));

const app = new Hono();
app.route("/api", api);
```

`basePath` is still `/api/push` here - the path the client requests - because
that is what the router sees.


---

# NestJS

> Mount better-push as a Nest module, with forRoot and forRootAsync.

Canonical documentation: /docs/nestjs



```ts title="src/app.module.ts"
import { Module } from "@nestjs/common";
import { BetterPushModule } from "@better-push/core/nestjs";
import { push } from "./push";

@Module({
  imports: [BetterPushModule.forRoot({ push, basePath: "/api/push" })],
})
export class AppModule {}
```

```ts title="src/main.ts"
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";

const app = await NestFactory.create(AppModule);
await app.listen(3000);
```

## How it mounts [#how-it-mounts]

The module routes through `NestModule.configure()` **middleware**, not a
controller. A controller would need a decorated class with a route per endpoint,
which would drag `experimentalDecorators` and `emitDecoratorMetadata` into
better-push's own tsconfig - changing how the entire package compiles, for every
app that never touches Nest.

The middleware is applied to every route and decides for itself whether a path
belongs to better-push, forwarding everything else with `next()`. Nest's
wildcard route syntax changed between major versions (path-to-regexp v6 to v8);
a prefix check works on both, and is the same check the router would make.

`@nestjs/common` and `@nestjs/core` are **optional peer dependencies** and the
entry imports neither. Everything Nest hands it is typed structurally.

## `forRootAsync` [#forrootasync]

When the instance depends on something Nest owns - a config service, a database
connection - build it in a factory:

```ts title="src/app.module.ts"
@Module({
  imports: [
    ConfigModule,
    BetterPushModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      basePath: "/api/push",
      useFactory: (config: ConfigService) =>
        betterPush({
          database: postgresAdapter(config.getOrThrow("DATABASE_URL")),
          providers: [webPush({ vapid: config.getOrThrow("VAPID") })],
          session: betterAuthSession(auth),
          basePath: "/api/push",
        }),
    }),
  ],
})
export class AppModule {}
```

`basePath` is **required** here and must match the one in the config: the
instance the factory returns does not exist when routing is set up, so its own
`basePath` cannot be read. The module says so rather than guessing.

The resolved instance is also provided under the `BETTER_PUSH` token, so the
rest of your app can inject it:

```ts
import { BETTER_PUSH } from "@better-push/core/nestjs";

constructor(@Inject(BETTER_PUSH) private readonly push: BetterPush) {}
```

## Caveats [#caveats]

**Global prefix.** `app.setGlobalPrefix("api")` applies to controllers, and this
is middleware. Include the prefix in `basePath` yourself:

```ts
app.setGlobalPrefix("api");
BetterPushModule.forRoot({ push, basePath: "/api/push" });
```

**Body parser.** `NestFactory.create(AppModule, { bodyParser: false })` is
recommended but not required - the bridge re-serializes a body a parser has
already consumed. With the parser on, malformed JSON is rejected by Nest before
better-push sees it, so that one case answers with Nest's error rather than
better-push's.

**Fastify is untested.** The middleware path is exercised on the Express
platform. `FastifyRequest.raw`/`FastifyReply.raw` are the objects it would need,
and nothing here has been run against them - so it is documented as untested
rather than claimed as supported.

**Two instances in one process** are fine: each registration gets its own module
class closing over its own mount, so a second `forRoot` cannot redirect the
first.


---

# Sessions

> Wire better-push to your auth - better-auth, NextAuth, Clerk, or a custom JWT.

Canonical documentation: /docs/sessions



better-push never authenticates anyone. It asks your app one question per
request - &#x2A;who is this?* - through the `session` function:

```ts
session: (request: Request) => { userId: string } | null
```

Return `null` and the router answers 401. That is the whole contract, and it is
why better-push works with any auth library: the `userId` you return is what
lands in `bp_device.user_id` and what `notify()` targets.

> **It must be cheap:** `session` runs on every request to a mounted endpoint, including each feed
> poll. Read a cookie or verify a token; do not go to the database twice.

## better-auth [#better-auth]

```ts title="src/push.ts"
import { betterPush } from "@better-push/core";
import { betterAuthSession } from "@better-push/core/auth/better-auth";
import { auth } from "@/auth";

export const push = betterPush({
  session: betterAuthSession(auth),
  // ...
});
```

That is the whole integration. It reads the request headers, so it covers both
transports better-push clients use: the browser SDK sends a cookie and
`@better-push/core/native` sends a bearer token.

A `getSession` that **throws** is logged and treated as unauthenticated. A
session backend having a bad minute should answer 401 on the push endpoints,
not turn every one of them into a 500.

`mapUserId` derives the id better-push stores, and rejects the session when it
returns `null`:

```ts
betterAuthSession(auth, {
  mapUserId: (session) => `${session.user.id}`,
  onError: (error) => logger.warn({ error }, "push session lookup failed"),
});
```

better-auth is **not** a dependency of better-push. `betterAuthSession` needs
`auth.api.getSession({ headers })`, which is an interface, not an import.

## NextAuth [#nextauth]

```ts title="src/push.ts"
import { auth } from "@/auth";

export const push = betterPush({
  session: async () => {
    const session = await auth();
    return session?.user?.id ? { userId: session.user.id } : null;
  },
  // ...
});
```

NextAuth v5's `auth()` reads the request from Next's async context, so the
`request` argument goes unused. On v4 use `getServerSession(authOptions)` the
same way.

## Clerk [#clerk]

```ts title="src/push.ts"
import { getAuth } from "@clerk/nextjs/server";

export const push = betterPush({
  session: (request) => {
    const { userId } = getAuth(request as never);
    return userId ? { userId } : null;
  },
  // ...
});
```

`getAuth` expects Next's `NextRequest`, which is a `Request` with extra
properties Clerk does not read here.

## A custom JWT [#a-custom-jwt]

```ts title="src/push.ts"
import { jwtVerify } from "jose";

const secret = new TextEncoder().encode(process.env.JWT_SECRET!);

export const push = betterPush({
  session: async (request) => {
    const header = request.headers.get("authorization") ?? "";
    if (!header.startsWith("Bearer ")) return null;
    try {
      const { payload } = await jwtVerify(header.slice(7), secret);
      return typeof payload.sub === "string" ? { userId: payload.sub } : null;
    } catch {
      // An expired or forged token is not an error, it is anonymous.
      return null;
    }
  },
  // ...
});
```

The `catch` matters: a verification failure must answer 401, not 500. Every
recipe on this page follows the same rule.

## Multi-tenancy [#multi-tenancy]

`userId` is an opaque string. Prefix it to scope devices and feeds per tenant:

```ts
session: async (request) => {
  const session = await auth.api.getSession({ headers: request.headers });
  if (!session) return null;
  return { userId: `${session.session.activeOrganizationId}:${session.user.id}` };
},
```

Then `notify()` targets the same composite id. There is no separate tenant
column, and no query that can forget to filter by one.

## The studio is separate [#the-studio-is-separate]

`session` gates the **user-facing** endpoints. The studio has its own
`permissions` callback with its own actor and its own audit trail - see
[Studio](/docs/studio). Do not reuse one for the other: a user session says who
someone is, and a studio permission says what an operator may look at.


---

# TanStack Start

> Mount better-push in a TanStack Start app with a server route.

Canonical documentation: /docs/tanstack



better-push works with [TanStack Start](https://tanstack.com/start) exactly as
it does with Next.js: the core is a framework-agnostic
`(request: Request) => Promise<Response>` handler, and the integration is a thin
map onto TanStack Start's server routes. Everything else in the docs - the
schema, `push.notify()`, VAPID setup, the client SDK, the React hook - is
identical.

> **Fastest path:** `npx @better-push/cli init` detects TanStack Start and writes the route below
> for you. See the [CLI](/docs/cli) page.

## Mount the endpoints [#mount-the-endpoints]

TanStack Start server routes are web-standard: each method handler receives a
`{ request }` and returns a `Response`. `toTanStackStartHandler` returns the
`GET`/`POST`/`PUT`/`PATCH`/`DELETE` map to drop into a splat route. Every method
the router serves is exported, so the router itself decides what is allowed on a
path and new endpoints never require touching this file.

```ts title="src/routes/api/push/$.ts"
import { createFileRoute } from "@tanstack/react-router";
import { push } from "@/push";
import { toTanStackStartHandler } from "@better-push/core/tanstack";

export const Route = createFileRoute("/api/push/$")({
  server: {
    handlers: toTanStackStartHandler(push),
  },
});
```

The splat route `/api/push/$` catches every better-push endpoint (`/devices`,
`/devices/:id`, `/notifications`, `/notifications/unread-count`,
`/notifications/:id/read`, `/notifications/read-all`, and `/preferences`). No
`@tanstack/*` types leak into the library - the integration is dependency-free
and typed structurally.

## Everything else is the same [#everything-else-is-the-same]

The `push.ts` config, the `bp_*` Drizzle schema, `push.notify(...)`, the
`public/sw.js` service worker, and the browser client are framework-agnostic.
Follow the [Quickstart](/docs/quickstart) for those steps; only the route file
above differs from the Next.js setup.

```ts
// Configure once (identical to the Next.js setup)
import { betterPush } from "@better-push/core";
import { drizzleAdapter } from "@better-push/core/adapters/drizzle";
import { webPush } from "@better-push/core/providers/web-push";
import { db } from "@/db";

export const push = betterPush({
  database: drizzleAdapter(db),
  providers: [webPush({ vapid: { /* ... */ } })],
  session: async (request) => {
    // Plug in TanStack Start's auth / session here.
    return null;
  },
});
```

## Client [#client]

The browser SDK and the `usePushRegistration` React hook from
`@better-push/core/react` are the same in a TanStack Start app - they only depend on
browser APIs and your endpoints, not on the server framework. Pass your
`VAPID_PUBLIC_KEY` as `applicationServerKey` and serve `public/sw.js` from your
origin.


---

# AI and agent access

> Read the better-push documentation as clean Markdown without parsing the website.

Canonical documentation: /docs/agent-access



The documentation site publishes Markdown generated from the same MDX files as
the human-readable pages. Use these endpoints when an AI assistant, coding agent,
or local indexing tool needs current better-push instructions.

| Need                           | Endpoint                              |
| ------------------------------ | ------------------------------------- |
| Discover available pages       | [`/llms.txt`](/llms.txt)              |
| Read all documentation at once | [`/llms-full.txt`](/llms-full.txt)    |
| Read one page                  | Append `.md` to its documentation URL |

For example, the Markdown version of the quickstart is
[`/docs/quickstart.md`](/docs/quickstart.md).

```bash
export BETTER_PUSH_DOCS_ORIGIN="https://docs.example.com"
curl "$BETTER_PUSH_DOCS_ORIGIN/llms.txt"
curl "$BETTER_PUSH_DOCS_ORIGIN/docs/quickstart.md"
```

Clients that support HTTP content negotiation can request Markdown from the
normal page URL:

```bash
curl -H "Accept: text/markdown" \
  "$BETTER_PUSH_DOCS_ORIGIN/docs/quickstart"
```

Set `BETTER_PUSH_DOCS_ORIGIN` to the current public documentation deployment.
The endpoints are served by that deployment itself, so moving from a beta URL
to a production domain does not require changing route code.

Use the page-specific endpoint for implementation work because it keeps the
context focused. Use `llms-full.txt` only when the task genuinely spans the full
library. Mermaid diagrams remain fenced Mermaid source in Markdown so agents can
understand the flow without extracting an SVG. Interactive component examples
are represented by their surrounding explanatory content.

These outputs are generated during the documentation build. Do not copy or sync
them into a separate knowledge base unless your agent platform requires its own
index.


---

# CLI reference

> Scaffold, migrate, diagnose, emulate, and inspect a better-push installation.

Canonical documentation: /docs/cli



Run the CLI without installing it globally:

```bash
npx @better-push/cli <command>
```

To use the shorter local binary form, install `@better-push/cli` as a development
dependency and run `pnpm exec better-push <command>`.

## Commands [#commands]

| Command    | Purpose                                                |
| ---------- | ------------------------------------------------------ |
| `init`     | Detect the stack and scaffold the integration          |
| `migrate`  | Apply the canonical tables and indexes                 |
| `generate` | Write schema files for your migration tool             |
| `doctor`   | Diagnose code, environment, schema, and runtime wiring |
| `dev`      | Run the local emulator inbox                           |
| `add`      | Copy owned UI components into the application          |
| `studio`   | Run the operator Studio against a database             |

The CLI sends no telemetry and performs no version check.

## Scaffold with `init` [#scaffold-with-init]

```bash
npx @better-push/cli init
```

`init` detects the framework, database client, package manager, source layout,
and import aliases. It previews file and environment changes, requests
confirmation, and skips existing files unless `--force` is set. It never
changes the database.

| Option                                                | Effect                                        |
| ----------------------------------------------------- | --------------------------------------------- |
| `--framework <next\|tanstack\|express\|hono\|nestjs>` | Override framework detection                  |
| `--orm <drizzle\|prisma\|postgres>`                   | Override database-client detection            |
| `--cwd <dir>`                                         | Target another project directory              |
| `--dry-run`                                           | Preview without writing                       |
| `--yes`, `-y`                                         | Skip confirmation                             |
| `--force`                                             | Allow existing generated files to be replaced |

The generated mount depends on the framework; the schema artifact depends on
the database client.

| Framework      | Generated mount                            |
| -------------- | ------------------------------------------ |
| Next.js        | App Router catch-all route                 |
| TanStack Start | Splat server route                         |
| Express        | `Router` mounted with `app.use()`          |
| Hono           | Sub-application mounted with `app.route()` |
| NestJS         | Module imported by the application         |

## Manage the schema [#manage-the-schema]

Choose one schema owner. Both approaches produce the same `bp_*` tables and
indexes.

### Apply the canonical schema [#apply-the-canonical-schema]

```bash
npx @better-push/cli migrate
```

The command resolves the connection string from `--database-url`,
`DATABASE_URL`, `.env.local`, or `.env`. It uses idempotent DDL and asks for
confirmation before connecting to a non-local database.

| Option                 | Effect                                     |
| ---------------------- | ------------------------------------------ |
| `--database-url <url>` | Override the resolved connection string    |
| `--dry-run`            | Print SQL without connecting               |
| `--yes`                | Skip confirmation for a non-local database |
| `--cwd <dir>`          | Change where environment files are read    |

### Use the application's migration tool [#use-the-applications-migration-tool]

```bash
npx @better-push/cli generate
```

| Client  | Generated artifact                                  |
| ------- | --------------------------------------------------- |
| Drizzle | TypeScript schema for `drizzle-kit`                 |
| Prisma  | Prisma models and a required partial-index SQL file |
| `pg`    | Canonical SQL                                       |

Options: `--orm` overrides detection, `--out` changes the single-file output,
`--stdout` prints instead of writing, and `--force` replaces existing output.
Prisma cannot express the required partial indexes in its schema language, so
apply the generated SQL after the Prisma migration.

## Diagnose with `doctor` [#diagnose-with-doctor]

```bash
npx @better-push/cli doctor
```

Static checks inspect the project layout, route mount, service worker, and
environment. When the configuration can be loaded, live checks inspect the
schema, providers, session resolver, queue, cache, digests, and retention.

`doctor` exits with status 1 if it finds an error. Use `--json` for structured
output, `--config <path>` to select the module exporting the better-push
instance, and `--cwd <dir>` to inspect another project.

The same read-only diagnostics are available in application code:

```ts
const result = await push.diagnose();
if (result.summary.error > 0) {
  console.error(result.findings);
}
```

## Run development tools [#run-development-tools]

### Emulator inbox [#emulator-inbox]

```bash
npx @better-push/cli dev
npx @better-push/cli dev --port 4984
```

The inbox binds to `127.0.0.1` and has no authentication. Add the
development-only `emulator()` provider and follow the [emulator guide](/docs/emulator)
to register a virtual device.

### Copy UI components [#copy-ui-components]

```bash
npx @better-push/cli add --list
npx @better-push/cli add notification-bell
```

Use `--dir <path>` to choose the component directory and `--force` to replace
an existing component. The copied source becomes part of your application; see
[UI components](/docs/ui-components).

### Local Studio [#local-studio]

```bash
npx @better-push/cli studio
```

Studio binds to `127.0.0.1:4983` and is read-only by default. `--allow-writes`
enables operator actions, `--port` changes the port, and `--database-url`
overrides database discovery. See [Run Studio locally](/docs/studio-local).

## Diagnostic result [#diagnostic-result]

**Diagnostics type reference:** Generated from `../../packages/better-push/src/diagnose.ts`. See the canonical documentation page for the field table.

**Finding type reference:** Generated from `../../packages/better-push/src/diagnose.ts`. See the canonical documentation page for the field table.


---

# Configuration

> Server options, defaults, and the public package entry points.

Canonical documentation: /docs/configuration



Create one `betterPush()` instance on the server, export it for application code,
and mount its handler in your framework. The database, providers, and session
resolver are required; queues, caches, notification definitions, maintenance,
and operational controls are optional.

```ts title="push.ts"
import { betterPush } from "@better-push/core";

export const push = betterPush({
  database,
  providers,
  session,
});
```

## Server options [#server-options]

**BetterPushOptions type reference:** Generated from `../../packages/better-push/src/index.ts`. See the canonical documentation page for the field table.

## Package entry points [#package-entry-points]

Import integrations from their subpaths so your application loads only the code
and optional peer dependencies it uses.

| Purpose    | Entry points                                                                     |
| ---------- | -------------------------------------------------------------------------------- |
| Database   | `@better-push/core/adapters/postgres`, `/drizzle`, `/prisma`                     |
| Providers  | `@better-push/core/providers/web-push`, `/fcm`, `/apns`, `/expo`, `/emulator`    |
| Frameworks | `@better-push/core/nextjs`, `/tanstack`, `/express`, `/hono`, `/nestjs`, `/node` |
| Delivery   | `@better-push/core/queue/db-queue`, `/bullmq`                                    |
| Cache      | `@better-push/core/cache/memory`, `/redis`                                       |
| Clients    | `@better-push/core/client`, `/react`, `/native`                                  |
| Tools      | `@better-push/core/studio`, `/emulator`, `/stats`                                |

The relevant guide documents each subpath's setup and options. See
[database adapters](/docs/database-adapters), [providers](/docs/providers),
[async delivery](/docs/async-delivery), [cache](/docs/cache), and the
[framework walkthroughs](/docs/walkthroughs).


---

# Async delivery

> Move sending off the request path with dbQueue() or bullmq() - the Postgres you already have, or the Redis you already have.

Canonical documentation: /docs/async-delivery



By default `notify()` sends inline: it writes its rows, then talks to every
push service before the call returns. That is the right default - it works on
serverless, needs no extra process, and is the reason better-push has no
mandatory infrastructure. But it puts a network round trip per provider on your
request, and one slow push service slows down whatever called you.

Adding a queue moves the sending into a worker. `notify()` writes its rows,
enqueues one job per provider, and returns in milliseconds.

## Turning it on [#turning-it-on]

```ts title="src/push.ts"
import { betterPush } from "@better-push/core";
import { drizzleAdapter } from "@better-push/core/adapters/drizzle";
import { webPush } from "@better-push/core/providers/web-push";
import { dbQueue } from "@better-push/core/queue/db-queue"; // [!code highlight]

export const push = betterPush({
  database: drizzleAdapter(db),
  providers: [webPush({ vapid })],
  session: getSession,
  queue: dbQueue(), // [!code highlight]
});
```

That is the whole configuration change. `dbQueue()` uses the Postgres you
already gave the adapter - no Redis, no broker, no second datastore. Work lives
in a `bp_job` table that ships with the schema whether or not you configure a
queue, so turning it on later is a config line, not a migration.

Then run a worker in its own process - see
[Running a Worker](/docs/running-a-worker):

```ts title="worker.ts"
import { push } from "./src/push";

const stop = push.startWorker();
process.on("SIGTERM", () => void stop().then(() => process.exit(0)));
```

> **Nothing sends without a worker:** With a queue configured and no worker running, jobs accumulate in `bp_job` and
> no push is delivered. That is not a failure mode to be surprised by - it is
> the point, and it is recoverable: start a worker and the backlog drains. If
> you cannot run a long-lived process, use
> [`runPending()` or the HTTP entrypoint](/docs/running-a-worker#cron-and-serverless).

## What changes in `NotifyResult` [#what-changes-in-notifyresult]

Push deliveries come back `queued` instead of `sent` or `failed`. Nothing else
moves: in-app deliveries are a row in your database, so they are already done by
the time `notify()` returns, and a preference-suppressed channel never had
anything to send.

```ts
const result = await push.notify({ userId, title: "Your order shipped" });
if (result.outcome !== "delivered") return; // scheduled, or collected into a digest

result.deliveries;
// [
//   { id: "...", channel: "inApp", status: "delivered" },
//   { id: "...", channel: "push", deviceId: "...", status: "queued" },
// ]
```

The real outcome lands on `bp_delivery` when the worker runs. If your UI reports
"sent", read the delivery row rather than the `notify()` result.

## Inline versus queued [#inline-versus-queued]

|                      | Inline (default)             | `queue: dbQueue()`                    |
| -------------------- | ---------------------------- | ------------------------------------- |
| `notify()` returns   | after every provider replies | after the rows are written            |
| Push delivery status | `sent` / `failed`            | `queued`, then `sent` / `failed`      |
| Retries              | none - one attempt           | exponential backoff, then dead-letter |
| Needs a process      | no                           | yes, or a cron caller                 |
| Runs on serverless   | yes                          | yes, via `runPending()`               |
| A slow push service  | slows the request            | slows the worker                      |
| Extra infrastructure | none                         | none - the same Postgres              |

## What is still failed immediately [#what-is-still-failed-immediately]

A device whose `provider` key is not in your `providers` array is failed at
`notify()` time with `provider_not_configured`, queue or no queue. Enqueueing
work that is guaranteed to dead-letter would only delay an answer you already
have.

## The job [#the-job]

One job per provider batch, not one per device. A user with three browsers and
an iPhone produces two jobs: one `web-push` batch of three, one `apns` batch of
one. The job carries ids only:

```json
{ "notificationId": "...", "provider": "web-push", "deliveryIds": ["...", "..."] }
```

The worker reloads the notification and the delivery rows when it runs, so a job
can never be stale against the database it is about to act on. It also means a
retry naturally narrows itself: only deliveries still at `queued` are reloaded,
so a device that already succeeded is never sent to twice. See
[Retries and Failures](/docs/retries-and-failures).

## Inspecting the queue [#inspecting-the-queue]

```sql
-- work in flight, with when it is next due
SELECT id, attempts, run_at, last_error FROM bp_job WHERE status = 'pending';

-- jobs that gave up
SELECT id, attempts, last_error FROM bp_job WHERE status = 'dead';
```

A successful job leaves **no** row: it is deleted, not marked done.
`bp_delivery` already records what happened to every delivery - status,
attempts, error, `sent_at` - so keeping completed jobs would be a second audit
trail that grows without bound.

## Options [#options]

```ts
dbQueue({
  maxAttempts: 5,             // claims before a job dead-letters
  visibilityTimeoutMs: 300_000, // when another worker may steal a stuck claim
  batchSize: 10,              // jobs claimed per poll
  pollIntervalMs: 1_000,      // sleep between polls that found nothing
  backoff: (attempt) => 30_000 * 4 ** (attempt - 1), // ms until the next try
});
```

Every field has a working default; the defaults above are the actual ones.
`backoff` defaults to 30s, 2m, 8m, 32m, capped at an hour, each with ±20%
jitter.

## The BullMQ backend [#the-bullmq-backend]

`bullmq()` is the second backend. It uses the Redis you may already have for the
[cache](/docs/cache), and swaps a poll loop for BullMQ's own worker and native
delayed jobs.

```bash
npm install bullmq ioredis
```

```ts title="src/push.ts"
import { bullmq } from "@better-push/core/queue/bullmq"; // [!code highlight]

export const push = betterPush({
  database: drizzleAdapter(db),
  providers: [webPush({ vapid })],
  session: getSession,
  queue: bullmq({ connection: process.env.REDIS_URL! }), // [!code highlight]
});
```

`bullmq` and `ioredis` are **optional peer dependencies**, imported lazily on
first use, so an app on `dbQueue` never needs them installed.

### Options [#options-1]

```ts
bullmq({
  connection: process.env.REDIS_URL!, // or { client }
  queueName: "better-push",   // two apps sharing a Redis need different names
  prefix: "bp",               // BullMQ key prefix
  concurrency: 10,            // jobs processed at once per worker
  maxAttempts: 5,             // the same default as dbQueue
  completedTtlSeconds: 3_600, // how long a completed job id stays reserved
  backoff: (attempt) => 30_000 * 4 ** (attempt - 1),
});
```

`backoff` defaults to the same 30s / 2m / 8m / 32m schedule `dbQueue` uses, from
the same function - the retry behaviour is one definition, not two that drift.

`completedTtlSeconds` matters more than it looks. Maintenance work schedules
itself with a deterministic id (`rollup-2026-07-30T15`) and relies on the queue
rejecting a duplicate. A completed id is only unique while BullMQ still
remembers it, so freeing it inside its own period would let the next worker tick
re-add it. An hour is the shortest period any maintenance job uses.

### It never shares the cache's connection [#it-never-shares-the-caches-connection]

> **Do not hand it `redis()`'s client:** BullMQ requires `maxRetriesPerRequest: null` and a dedicated blocking socket
> per worker. The cache's client is deliberately the opposite - it is tuned to
> fail fast on a request path so an unreachable Redis falls back to Postgres
> instead of hanging. Pointing both at the same **server** is fine and expected;
> handing them the same **client** is not.

Pass a URL and this backend dials its own connections and closes them in
`push.close()`. Pass `{ client }` and it borrows yours for the producer side and
duplicates it per worker - whoever created a connection is responsible for
ending it.

### Which one to use [#which-one-to-use]

|                           | `dbQueue()`                                | `bullmq()`                                                         |
| ------------------------- | ------------------------------------------ | ------------------------------------------------------------------ |
| Infrastructure            | the Postgres you already have              | a Redis                                                            |
| Delayed jobs              | a `run_at` column, found by polling        | native, fired at the instant                                       |
| Scheduled sends           | within a poll interval (\~1s)              | at the due time                                                    |
| Digest flushes            | within a poll interval                     | at the due time                                                    |
| Throughput                | fine to a few hundred jobs/second          | higher, and cheaper per job                                        |
| Serverless `runPending()` | a single claim statement - the right shape | works, but a blocking worker in a function that must return is not |
| Studio queue screen       | reads `bp_job`                             | reads Redis through the same inspector                             |
| Operational surface       | one datastore                              | two                                                                |

Neither is a downgrade. `dbQueue` is the right answer for most apps and the only
one that adds no infrastructure; `bullmq` is the right answer when you already
run Redis, want exact timing, or are pushing enough volume that a Postgres table
is the wrong shape for a queue.

## Swapping the backend [#swapping-the-backend]

`queue` takes any `QueueAdapter` factory, and the worker loop belongs to the
backend rather than to core - `dbQueue` polls and claims rows, while `bullmq`
brings its own `Worker` class and native delayed jobs. Nothing above the
`queue:` line changes when the backend does, including the studio: the queue
screen reads through an optional `QueueInspector` that both backends implement,
so switching backends never costs observability.

## The worker and the cache [#the-worker-and-the-cache]

If you configure a [cache](/docs/cache), give the **worker process** the same
`cache:` line the web process has. Two things depend on it:

* The worker is the process that disables a dead push token. With a shared
  cache that invalidation reaches the web instances at once; with `memory()` -
  or with no cache in the worker - their cached device lists keep a dead token
  for up to the 300 second TTL and keep targeting it.
* The worker reads preferences and device lists on every send, which is exactly
  what the cache is there to accelerate.

The worker publishes **no** realtime signals, and that is deliberate: the feed
row is written by `notify()` in both inline and queued mode, so the signal is
published there. Push send outcomes do not change the feed, so there is nothing
for the worker to announce - which is why an instant in-app feed works
identically with and without a queue.

Give the worker a clean shutdown too:

```ts title="worker.ts"
const stop = push.startWorker();
process.on("SIGTERM", () => {
  void stop()
    .then(() => push.close())
    .then(() => process.exit(0));
});
```

## Queue interfaces [#queue-interfaces]

**QueueAdapter type reference:** Generated from `../../packages/better-push/src/queue/types.ts`. See the canonical documentation page for the field table.

**WorkerOptions type reference:** Generated from `../../packages/better-push/src/queue/types.ts`. See the canonical documentation page for the field table.

**RunPendingOptions type reference:** Generated from `../../packages/better-push/src/queue/types.ts`. See the canonical documentation page for the field table.


---

# Cache

> An optional cache that accelerates hot reads, shares rate limits, and carries realtime signals between instances.

Canonical documentation: /docs/cache



better-push works with no cache at all, and that stays true. Adding one is a
single config line that makes three things better at once:

* **Hot reads stop hitting Postgres.** Every `notify()` reads the user's
  preferences and device list; every feed poll reads their unread count. Those
  change rarely and are read constantly.
* **Rate limits become accurate.** Counters live in the cache, so a budget of
  120 requests a minute means 120 across your whole deployment rather than 120
  per replica.
* **Realtime crosses instances.** The cache's pub/sub is the bus that lets the
  instance holding a user's [SSE stream](/docs/realtime-feed) hear about a send
  that landed on a different instance - or in your worker process.

The cache is **not** a source of truth. Postgres stays authoritative, every read
falls back to it, and a cache failure degrades performance rather than
correctness.

## Turning it on [#turning-it-on]

```ts title="src/push.ts"
import { betterPush } from "@better-push/core";
import { drizzleAdapter } from "@better-push/core/adapters/drizzle";
import { webPush } from "@better-push/core/providers/web-push";
import { redis } from "@better-push/core/cache/redis"; // [!code highlight]

export const push = betterPush({
  database: drizzleAdapter(db),
  providers: [webPush({ vapid })],
  session: getSession,
  cache: redis(process.env.REDIS_URL!), // [!code highlight]
});
```

`ioredis` is an **optional peer dependency**, imported lazily on the first
command:

```bash
npm install ioredis
```

Apps that configure no cache never need it installed, and if you forget it the
error names the install command.

### Bring your own client [#bring-your-own-client]

Pass a client instead of a URL when your app already owns a tuned, clustered, or
TLS-configured connection:

```ts
import Redis from "ioredis";

const client = new Redis(process.env.REDIS_URL!, { tls: {} });

export const push = betterPush({
  // ...
  cache: redis({ client }),
});
```

Whoever created a connection is responsible for ending it: `push.close()` ends
connections better-push made and leaves yours open. Pub/sub always gets its own
connection - a subscribed Redis connection refuses ordinary commands - created
with `client.duplicate()` on the first subscribe unless you supply one:

```ts
cache: redis({ client, subscriber: client.duplicate() }),
```

Keys and channels are namespaced with `keyPrefix`, `"bp:"` by default, so a
shared Redis instance stays legible:

```ts
cache: redis(process.env.REDIS_URL!, { keyPrefix: "myapp:push:" }),
```

## `memory()` [#memory]

```ts
import { memory } from "@better-push/core/cache/memory";

cache: memory(),
```

An in-process `Map` with no dependencies. It is a **development, single-process,
and test** tool: everything it holds is visible to exactly one process, so rate
limits are per instance, realtime reaches only tabs attached to that instance,
and - the one that bites - a separate worker process cannot invalidate the web
process's cached device list.

## The degradation matrix [#the-degradation-matrix]

What a cache changes:

|                         | no cache                 | `memory()`                                                                  | `redis()`             |
| ----------------------- | ------------------------ | --------------------------------------------------------------------------- | --------------------- |
| Hot reads               | every read hits Postgres | cached per instance                                                         | cached, shared        |
| Invalidation            | n/a                      | correct in-process only                                                     | correct everywhere    |
| Rate limits             | per instance             | per instance                                                                | shared and accurate   |
| Realtime                | in-process only          | in-process only                                                             | across every instance |
| Separate worker process | fine                     | **device cache goes stale up to the TTL after the worker disables a token** | fine                  |
| Verdict                 | any deployment           | one process only                                                            | any deployment        |

And what a [queue](/docs/async-delivery) changes. Everything works at every
fold; only precision does:

| Capability                               | DB only                                                    | + `dbQueue`                              | + `bullmq`                              |
| ---------------------------------------- | ---------------------------------------------------------- | ---------------------------------------- | --------------------------------------- |
| Async delivery with retries              | inline, no retries                                         | yes                                      | yes, higher throughput                  |
| [Scheduled sends](/docs/scheduled-sends) | **not available** - `at` throws                            | yes, \~poll-interval precision           | yes, exact                              |
| [Digests](/docs/digests)                 | yes, flushed by cron via `run-pending` or `flushDigests()` | yes, \~poll-interval precision           | yes, exact                              |
| Studio queue screen                      | shows `bp_job` (usually empty)                             | full                                     | full, read through the BullMQ inspector |
| Cancellation of a scheduled send         | n/a                                                        | yes, unless already claimed              | yes, unless already active              |
| [Push receipts](/docs/expo-setup) (Expo) | tickets are final; dead tokens pruned on the next send     | fetched on the next poll after the delay | fetched at the delay, exactly           |
| `delivered_at` on push rows              | never set                                                  | set from receipts                        | set from receipts                       |

Scheduled sends are the one row that genuinely cannot degrade: without a queue
there is nothing to hold the send, so `at` throws at the call site rather than
delivering early. Digests are the opposite - their source of truth is a row in
your Postgres, so cron alone is enough.

That worker row is the honest one. The worker is the process that disables a
dead push token; with `memory()`, that invalidation never reaches the web
instances, so sends keep targeting a dead token for up to 300 seconds. It fails
softly - the provider rejects it again and the token is disabled again - but it
is the reason `memory()` is not a production answer once you have two processes.

## What is cached [#what-is-cached]

| Region      | Serves                                    | TTL  | Dropped by                                |
| ----------- | ----------------------------------------- | ---- | ----------------------------------------- |
| devices     | the active device list used by `notify()` | 300s | device register, delete, disable          |
| preferences | a user's stored preference rows           | 300s | preference writes                         |
| unread      | the unread count on the feed endpoint     | 60s  | notification created, mark-read, read-all |

The TTLs are constants, not configuration. They are a safety net rather than the
correctness mechanism: with a shared cache an invalidation is visible to every
instance immediately, and the TTL only bounds a window this design does not
otherwise cover. A knob here would be a knob on how wrong your data may be, and
nobody can answer that usefully at config time.

The HTTP device list (`GET {basePath}/devices`) is deliberately **not** cached:
it is a rare, user-initiated read on a settings screen, and a second region over
the same table would be a second thing to get wrong.

## Why invalidation is not your problem [#why-invalidation-is-not-your-problem]

Caching is a decorator over the `DatabaseAdapter`, not cache calls sprinkled
through the runtime. Nothing can mutate your database except through that
object, so invalidation is structural rather than a discipline someone can
forget:

* `notify()`, the router, the dispatcher, the worker and the studio contain no
  cache code at all;
* a delivery failure that disables a dead token drops that user's device list,
  even though the dispatcher has never heard of the cache;
* a write inside a transaction drops its keys **once, after the commit** - and a
  rollback drops nothing, because nothing happened;
* reads inside a transaction bypass the cache entirely, since a transaction's
  snapshot is not a fact that should outlive it.

Two places are knowingly inexact, both healed by the TTL and both harmless:
retention pruning cannot know which users' unread counts it changed, and a
device that changes owner leaves the previous owner's cached list stale.

## Failure behaviour [#failure-behaviour]

Nothing on the send path may be slowed or broken by the cache:

* a read that cannot reach the cache falls back to the database and logs;
* a write that cannot invalidate still succeeds, and the TTL heals it;
* a rate-limit counter that cannot be reached **allows** the request (a limiter
  that fails closed turns a Redis blip into an outage);
* a realtime publish that fails is logged and never awaited on the send path.

## Shutting down [#shutting-down]

```ts
process.on("SIGTERM", () => void push.close());
```

`push.close()` ends open SSE streams cleanly (so clients reconnect elsewhere
rather than hang), releases the bus, stops the limiter's sweep timer, and closes
cache connections better-push created. It is idempotent, and safe to call when
no cache is configured.

## Cache interfaces [#cache-interfaces]

**CacheAdapter type reference:** Generated from `../../packages/better-push/src/cache/types.ts`. See the canonical documentation page for the field table.

**CacheFactory type reference:** Generated from `../../packages/better-push/src/cache/types.ts`. See the canonical documentation page for the field table.


---

# Deployment

> Which pieces to run where, per platform.

Canonical documentation: /docs/deployment



better-push works with nothing but a database and a request. Everything else -
the queue, the worker, the cache - is a fold you open when your deployment can
support it.

The question that decides the shape is simple: &#x2A;*can this platform run a process
that outlives a request?**

## The two shapes [#the-two-shapes]

- **Serverless**: Vercel, Netlify, Lambda, Cloudflare Workers. No long-running process, so no worker.

- **Long-running**: Railway, Fly, Docker, Kubernetes, a bare Node server. A worker is one more process.

`better-push doctor` detects which you are on from `vercel.json`, `fly.toml`,
`railway.json`, a `Dockerfile`, or the platform's own build environment - and
tells you the same thing this page does, in your terminal.

***

## Serverless [#serverless]

**Vercel, Netlify, AWS Lambda, and anything else that only runs during a request.**

### Sends [#sends]

Two options, and inline is a real one:

```ts
// 1. Inline. notify() sends before it returns.
export const push = betterPush({ /* no queue */ });
```

The request that called `notify()` waits for the push service. For a handful of
devices that is tens of milliseconds; for a fan-out to hundreds it is not. There
is nothing to run and nothing to operate.

```ts
// 2. Queued, drained by cron.
import { dbQueue } from "@better-push/core/queue/db-queue";

export const push = betterPush({
  queue: dbQueue(),
  runPendingSecret: process.env.BETTER_PUSH_RUN_PENDING_SECRET,
});
```

`notify()` writes its rows, enqueues one job per provider, and returns in
milliseconds. Then a cron job drains the queue:

```
POST https://your-app.com/api/push/_internal/run-pending
Authorization: Bearer $BETTER_PUSH_RUN_PENDING_SECRET
```

```json title="vercel.json"
{ "crons": [{ "path": "/api/cron/push", "schedule": "* * * * *" }] }
```

Your cron route calls `push.runPending()` or POSTs to the internal endpoint.
Either way, delivery latency becomes your cron interval.

> **`bullmq` needs a worker:** BullMQ's delayed jobs are driven by a running worker process. On serverless,
> use `dbQueue()`: `runPending()` reads `bp_job` directly and needs nothing
> long-lived.

### Scheduled sends and digests [#scheduled-sends-and-digests]

Both need something to fire at a due time. `runPending()` flushes overdue digest
windows *before* it drains jobs, so one cron entry covers both - at cron
resolution rather than to the second.

### Cache and realtime [#cache-and-realtime]

A serverless deployment is many short-lived instances by definition, so the
in-process signal bus reaches nothing. Configure `cache: redis(...)` and the SSE
feed works across them; leave it out and the client polls, which still works.

Rate limits are per-instance without a shared cache, which on serverless means
effectively unlimited. If rate limiting matters to you, Redis is not optional.

### Retention [#retention]

A prune pass is worker work. Without one, set `retention` and call
`push.runPending()` from a daily cron - maintenance jobs are scheduled through
the same queue.

***

## Long-running [#long-running]

**Railway, Fly, Docker, Kubernetes, a bare Node server.**

### The worker [#the-worker]

One extra process, sharing the config:

```ts title="worker.ts"
import { push } from "./push";

const stop = push.startWorker();
process.on("SIGTERM", () => {
  // Stops claiming, waits for in-flight jobs, then releases connections.
  void stop().then(() => push.close());
});
```

It claims jobs with `FOR UPDATE SKIP LOCKED`, so any number of workers is safe.
It also maintains rollups, prunes to your retention windows, sweeps overdue
digest windows, and heartbeats into `bp_worker` - which is what the studio's
"is anything running?" answer reads.

Give it the database and provider credentials. It needs no `PORT`, no session
secret, and no HTTP surface at all.

### Which queue [#which-queue]

|                 | `dbQueue()`                      | `bullmq()`                    |
| --------------- | -------------------------------- | ----------------------------- |
| Needs           | The Postgres you already have    | Redis                         |
| Delayed jobs    | Polled, at the worker's interval | Native, fire at their instant |
| Scheduled sends | Within one poll                  | To the second                 |
| Digest flushes  | Within one poll                  | To the second                 |

Start with `dbQueue()`. Move to `bullmq` when you already have Redis, or when
poll-interval latency on a scheduled send stops being acceptable.

### Cache and realtime [#cache-and-realtime-1]

More than one instance means `cache: redis(...)`, or SSE signals and rate-limit
counters stop crossing processes. `memory()` is honest about this - it reports
itself as single-instance, and `doctor` repeats that back to you.

***

## Per platform [#per-platform]

| Target           | Components                                   | Worker                                        | Cron                                 |
| ---------------- | -------------------------------------------- | --------------------------------------------- | ------------------------------------ |
| **Vercel**       | `dbQueue()` optional, `redis()` for realtime | Not possible                                  | `vercel.json` crons -> `run-pending` |
| **Netlify**      | Same                                         | Not possible                                  | Scheduled functions -> `run-pending` |
| **Railway**      | `dbQueue()` or `bullmq()`, `redis()`         | A second service, same image, `startWorker()` | Not needed                           |
| **Fly**          | Same                                         | A second process in `fly.toml`                | Not needed                           |
| **Docker / K8s** | Same                                         | A second container or deployment              | Not needed                           |
| **Bare Node**    | Same                                         | A second process                              | Not needed                           |

## What `doctor` will say [#what-doctor-will-say]

```bash
npx @better-push/cli doctor
```

On a healthy serverless deployment: `queue.none` or `queue.configured`,
`cache.none` if you have not added Redis, `realtime.not-shared`, and
`retention.unset`. **All warnings, not errors** - they name a consequence, not a
fault.

The errors are the things that are actually broken: a missing table, a schema
version mismatch, a malformed credential, or the missing partial index a
`prisma migrate` database always has.

Run it in CI. It exits 1 on any error.


---

# Metrics, rollups, and retention

> Hourly rollups that keep charts cheap, opt-in pruning, worker heartbeats, and the onEvent hook.

Canonical documentation: /docs/metrics-and-retention



Notification tables grow forever, and the obvious dashboard query - "deliveries
in the last 24h grouped by status" - is a full scan of the largest table. This
page is about the machinery that stops both being true.

All of it rides on the [queue](/docs/async-delivery): a worker schedules and
runs it, with no cron and no scheduler.

## Rollups [#rollups]

`bp_metric_rollup` holds hourly counts keyed by
`(bucket, type, channel, provider, status)`. Studio charts read it instead of
scanning `bp_delivery`.

They are **on by default** whenever a queue is configured, because they are
additive, cheap, and the only thing keeping charts affordable:

```ts
betterPush({
  // …
  queue: dbQueue(),
  metrics: { rollups: true, rollupLookbackHours: 6 },
});
```

`rollupLookbackHours` is the interesting one. A delivery's **status keeps
changing after the hour it was created in** - queued becomes sent, a retry
becomes failed - so recent buckets have to be revisited rather than written
once. Each run recomputes the last N hours and replaces them, which is also what
makes a re-run idempotent rather than doubling counts.

The current, incomplete hour is never rolled up: it would be wrong the moment it
was written. The stats layer reads raw rows for that live edge and stitches the
two together.

> **No worker means no rollups, and that is fine:** Without a queue there is no worker, so the watermark never advances. The stats
> layer notices and falls back to aggregating raw rows, which is affordable at
> the volumes inline delivery implies. The studio says so with a banner rather
> than silently showing an empty chart.

## Retention [#retention]

**Nothing is ever deleted unless you configure it.** That is the only safe
default for someone else's notification history.

```ts
betterPush({
  // …
  retention: {
    deliveries: "30d",
    notifications: "90d",
    deadJobs: "14d",
    rollups: "13mo",
    audit: "1y",
    digestWindows: "30d",
  },
});
```

`digestWindows` prunes flushed [digest](/docs/digests) windows only. An open
window is live state, not history: it holds items a user is still owed, so it is
never deleted no matter how old it is.

Durations are `<number><unit>` with `ms`, `s`, `m`, `h`, `d`, `w`, `mo`, or `y`.
A typo is rejected at construction with the valid units listed - the one place a
permissive parser would be genuinely dangerous.

### notifications must outlast deliveries [#notifications-must-outlast-deliveries]

Deleting a notification cascades to its deliveries, so a shorter notification
window would silently win and your configured delivery retention would be a lie.
That configuration is rejected:

```
invalid betterPush() configuration (at retention.notifications): "7d" is shorter
than retention.deliveries "30d", but deleting a notification also deletes its
deliveries - so the delivery window could never be honoured.
```

### How pruning runs [#how-pruning-runs]

A prune job is scheduled once a day, and only when `retention` is set. It
deletes in **bounded batches** (5,000 rows per statement) inside a time budget,
so it never takes a long lock on a live table. Whatever is left is picked up by
the next day's run.

## Scheduling without a scheduler [#scheduling-without-a-scheduler]

Maintenance needs no cron, no leader election, and no new machinery. Every
worker simply tries to enqueue jobs whose ids come from the clock -
`rollup-2026-07-29T15`, `prune-2026-07-30` - and the primary key does the rest:
inserting an id that already exists is a no-op, so ten workers ticking at once
produce exactly one row, and `SKIP LOCKED` means exactly one of them runs it.

Each tick schedules the **next** period, due at that boundary. A completed job's
row is deleted, so scheduling the current period would re-enqueue it the moment
it finished.

## Worker heartbeats [#worker-heartbeats]

`bp_job.locked_by` only names workers currently holding a claim, so an idle
worker - the normal state of a healthy queue - would be invisible. Every worker
upserts its own `bp_worker` row every \~15 seconds with its hostname, uptime, and
how many jobs it has processed.

The studio shows a worker as live while its heartbeat is under 60 seconds old,
and the same data drives the health endpoint's "jobs are pending but no worker
has heartbeated recently".

Set `BETTER_PUSH_WORKER_VERSION` in your deployment to see a half-finished
rollout at a glance.

## The `onEvent` hook [#the-onevent-hook]

better-push deliberately has **no HTTP request log table**. A feed polling every
30 seconds would write more rows than the notification data itself, and anyone
who wants that retention already has a system built for it.

Instead, the lifecycle events it already produces are available as a callback:

```ts
betterPush({
  // …
  onEvent: (event) => {
    switch (event.type) {
      case "notification.created":
      case "delivery.attempted":
      case "job.dead_lettered":
        telemetry.record(event);
    }
  },
});
```

It is fire-and-forget: never awaited on the send path, and errors - thrown or
rejected - are logged rather than propagated. A telemetry sink must never be
able to slow a notification down or fail one.

## The indexes [#the-indexes]

The canonical schema includes the time-range indexes these queries need:

```sql
CREATE INDEX bp_delivery_created_idx ON bp_delivery (created_at DESC);
CREATE INDEX bp_delivery_status_created_idx ON bp_delivery (status, created_at DESC);
CREATE INDEX bp_delivery_device_idx ON bp_delivery (device_id);
CREATE INDEX bp_notification_created_idx ON bp_notification (created_at DESC);
```

`drizzle-kit generate` includes them with the better-push tables.

## Maintenance options [#maintenance-options]

**MetricsOptions type reference:** Generated from `../../packages/better-push/src/maintenance/config.ts`. See the canonical documentation page for the field table.

**RetentionOptions type reference:** Generated from `../../packages/better-push/src/maintenance/config.ts`. See the canonical documentation page for the field table.


---

# Rate limiting

> Per-user limits on the mounted router - on by default, shared through the cache, and failing open.

Canonical documentation: /docs/rate-limiting



The router is rate limited **by default**. A feed that polls, an inbox that
paginates, and a preferences screen that saves are all endpoints an
authenticated client can call in a loop, and each one costs a query on your
primary database.

```ts title="src/push.ts"
export const push = betterPush({
  database: drizzleAdapter(db),
  providers: [webPush({ vapid })],
  session: getSession,

  // Defaults shown. `rateLimit: false` disables it entirely.
  rateLimit: {
    max: 120,              // reads per user per window
    window: "1m",
    writes: { max: 30 },   // tighter, independent budget for mutations
    maxStreams: 5,         // concurrent SSE streams per user per instance
  },
});
```

## How it counts [#how-it-counts]

One fixed window per user per bucket. `GET`/`HEAD` requests count against the
read budget; everything that changes state - registering or deleting a device,
mark-read, read-all, preference writes - counts against the `writes` budget. The
two are **independent**, so a burst of writes cannot exhaust a user's ability to
read their feed.

`writes` inherits `window` unless it names its own.

## Where the counters live [#where-the-counters-live]

With a [cache](/docs/cache) configured, counters are one `incr` in the cache, so
the budget is shared across every instance: 120 a minute means 120 a minute for
your deployment.

Without one, the same fixed window lives in the process. The budget is then **per
instance**, so N replicas allow N times the limit. That is a documented
degradation rather than a silent difference - if the number matters to you,
configure a cache.

There is deliberately **no `bp_rate_limit` table**. A database write per
30-second feed poll per user is more write traffic than the notification data
itself, which is the same reason better-push has no request log.

## The 429 [#the-429]

```http
HTTP/1.1 429 Too Many Requests
retry-after: 42
x-ratelimit-limit: 120
x-ratelimit-remaining: 0
x-ratelimit-reset: 42

{"error":{"code":"RATE_LIMITED","message":"too many requests; retry in 42s"}}
```

The body uses the same envelope as every other router error. The client hooks
treat it as a failed request and back off, so an over-eager tab recovers on its
own.

## It fails open [#it-fails-open]

If the cache cannot serve a counter, the request is **allowed** and a warning is
logged. A rate limiter that fails closed turns a brief Redis outage into a total
one, which is a worse failure than the one it prevents.

## What it does not do [#what-it-does-not-do]

**Anonymous traffic is not rate limited.** Limiting by IP means trusting
`X-Forwarded-For`, and trusting that header correctly needs a proxy-trust
configuration - which hop to believe, which networks are yours - that better-push
is not in a position to get right on your behalf. Getting it wrong is worse than
not doing it: one forged header and an attacker either evades the limit or locks
out a shared NAT.

Unauthenticated requests are already answered `401` by the session gate, so they
cost one session lookup. If you need protection below that line, your proxy, CDN
or WAF is where it belongs, and it is where you can express it properly.

Two routes are outside the limiter by design:

* **`POST {basePath}/_internal/run-pending`** is matched before the session
  resolves and has no user to be keyed on. Its shared secret is its gate.
* **The studio** is an admin surface behind your own admin auth, and a limiter
  there would mostly rate-limit an operator during an incident.

## Streams [#streams]

`maxStreams` caps how many concurrent [SSE streams](/docs/realtime-feed) one user
may hold **on one instance**; past that the route answers 429. It is a resource
bound rather than an abuse control - each stream is an open connection and a bus
subscription - and five is enough for any realistic number of tabs.

`rateLimit: false` removes this cap along with everything else.

## Rate-limit options [#rate-limit-options]

**RateLimitOptions type reference:** Generated from `../../packages/better-push/src/rate-limit.ts`. See the canonical documentation page for the field table.


---

# Realtime feed

> Server-sent events for the in-app feed - instant updates, with polling as the honest fallback.

Canonical documentation: /docs/realtime-feed



The [in-app feed](/docs/in-app-feed) polls every 30 seconds by default. That is
cheap to run and works everywhere, but it means an in-app notification can be up
to 30 seconds late, and ten thousand open tabs is a steady 333 queries a second
that almost always return what the last one returned.

The realtime transport fixes both. The browser holds one long-lived stream at
`GET {basePath}/events`; when something changes the user's feed, the server
writes one event down it and the hook refetches.

## Turning it on [#turning-it-on]

There is nothing to turn on in the client - `useNotificationFeed` selects the
transport itself. On the server, the route exists by default:

```ts title="src/push.ts"
export const push = betterPush({
  database: drizzleAdapter(db),
  providers: [webPush({ vapid })],
  session: getSession,

  // Defaults shown. `realtime: false` removes GET {basePath}/events entirely.
  realtime: { maxDuration: "15m", pingInterval: "25s" },
});
```

> **One instance needs nothing; several need a cache:** With a single app process, realtime works with no extra infrastructure. The
> moment you run **two** replicas, or move sending into a worker process, you
> need [`cache: redis(...)`](/docs/cache) - see below for why.

## Why several instances need a bus [#why-several-instances-need-a-bus]

The hard part is not SSE. It is that **the process running `notify()` is usually
not the process holding the stream**. With two replicas the send lands on
instance A while the tab is attached to instance B. With
[`queue: dbQueue()`](/docs/async-delivery) the sending happens in the worker
process entirely.

So instances need a way to tell each other "user U has something new". That is
the cache's pub/sub, one channel per user. The browser never touches Redis.

```mermaid
sequenceDiagram
  participant S as Server code on instance one
  participant D as Postgres
  participant R as Redis pub sub
  participant B as Instance two
  participant U as User tab on instance two

  S->>D: insert notification and deliveries in one transaction
  S->>R: publish signal for the user
  Note over S,R: publish after the transaction commits
  R->>B: deliver signal on the channel for user U
  B->>U: write one event to the open stream
  U->>B: refetch page one of the feed
  B-->>U: notifications and unread count
```

## The event carries no content [#the-event-carries-no-content]

An SSE frame - and the message on the bus behind it - is a **signal, never
content**:

```
event: signal
data: {"reason":"created","notificationId":"018f...","}
```

That is all of it. No title, no body, no data, ever. The client refetches page 1
through the ordinary feed endpoint and reuses the merge logic it already has.

Two things follow, and both are the point: nothing readable transits your Redis,
and the HTTP endpoint stays the single definition of feed-item shape - there is
no second serialization to keep in sync.

`reason` is `"created"` when a notification was written to the feed, and
`"read"` when a mark-read or read-all changed it. A send whose `inApp` channel
was suppressed publishes nothing, because nothing entered the feed.

## What the client does [#what-the-client-does]

With `transport: "auto"` (the default) and a platform that has `EventSource`:

1. Fetch the first page on mount, as before.
2. Open the stream. Until the server's `ready` frame arrives, polling continues
   at the normal interval - an open socket is not proof of a working stream,
   since a buffering proxy accepts one happily and delivers nothing.
3. On `ready`, drop polling to `idlePollInterval` (default 5 minutes). Polling
   is slowed, never stopped: a stream that silently stops delivering must not
   strand the UI forever.
4. On a signal, wait \~250ms and refetch page 1. A burst of twenty notifications
   produces one fetch, not twenty.
5. On error, `EventSource` reconnects by itself. After **two** consecutive opens
   that never reach `ready`, give up permanently, close the stream, and go back
   to normal polling.
6. A hidden tab keeps its stream (it is nearly free, and gives an instant update
   on return); polling stays paused while hidden, exactly as before.

```tsx
const feed = useNotificationFeed();

// "sse" only while a stream is genuinely live; "polling" otherwise.
<span>{feed.transport === "sse" ? "live" : "polling"}</span>;
```

### Options [#options]

| Option             | Default  | Meaning                                                                                |
| ------------------ | -------- | -------------------------------------------------------------------------------------- |
| `transport`        | `"auto"` | `"auto"` streams if it can, `"sse"` never falls back, `"polling"` never opens a stream |
| `idlePollInterval` | `300000` | Safety poll interval while the stream is live                                          |
| `pollInterval`     | `30000`  | Poll interval when there is no live stream                                             |

## Serverless, and other places this will not work [#serverless-and-other-places-this-will-not-work]

A platform that cannot hold an open response for minutes cannot serve SSE. On
Vercel functions, Cloudflare Workers with short limits, or behind a proxy that
buffers responses, the stream either never delivers or is cut immediately.

That is handled rather than papered over: the client gives up after two failed
opens and reports `transport: "polling"`, which is true. Everything keeps
working at the polling interval. If you know streaming is not available, set
`realtime: false` on the server and `transport: "polling"` on the client, and
skip the two wasted connection attempts.

## React Native polls [#react-native-polls]

`@better-push/core/native` does not open streams, and `feed.transport` always reads
`"polling"` there. React Native has no `EventSource`, and the polyfills that
exist cannot attach an `Authorization` header - which is exactly how native
authenticates. Rather than ship a transport that cannot carry native auth, the
realtime seam is simply absent on that platform and the shared core polls,
unchanged.

## Operational details [#operational-details]

* **Bounded lifetime.** A stream closes cleanly after `maxDuration` (default 15
  minutes) and the browser reconnects. Bounded connections make rolling
  deploys, proxy timeouts and platform limits ordinary events instead of
  incidents.
* **Keepalives.** A `: ping` comment every `pingInterval` (default 25s) stops
  intermediaries from reaping an idle connection.
* **Proxy buffering.** The response sets `X-Accel-Buffering: no` and
  `Cache-Control: no-cache, no-transform`. If you terminate with nginx or Caddy,
  make sure your config does not buffer or compress this route.
* **Per-user cap.** One user may hold `rateLimit.maxStreams` concurrent streams
  on one instance (default 5); beyond that the route answers 429. See
  [Rate limiting](/docs/rate-limiting).
* **Clean shutdown.** `push.close()` ends every open stream so clients reconnect
  elsewhere rather than hanging on a dying instance.

```ts
process.on("SIGTERM", () => void push.close());
```

## The studio keeps polling [#the-studio-keeps-polling]

[The studio](/docs/studio) is not on this transport. It is an operator tool
where a 10-second refresh is not a product problem, and giving it streams would
add moving parts to the surface you look at when things are already going wrong.

## Realtime interfaces [#realtime-interfaces]

**RealtimeOptions type reference:** Generated from `../../packages/better-push/src/realtime/types.ts`. See the canonical documentation page for the field table.

**RealtimeSignal type reference:** Generated from `../../packages/better-push/src/realtime/signal.ts`. See the canonical documentation page for the field table.


---

# Retries and failures

> Which provider errors are retried, how backoff works, and what dead-lettering leaves behind.

Canonical documentation: /docs/retries-and-failures



A queue is only worth having if it retries the right things. better-push decides
that from the normalized `ProviderResultCode` every provider already returns -
there is no new provider surface and nothing to configure per transport.

This page assumes you have [async delivery](/docs/async-delivery) configured.
Without a queue there is exactly one attempt, so every failure below is final.

## What is retried [#what-is-retried]

| Code                      | Retried | Why                                                |
| ------------------------- | ------- | -------------------------------------------------- |
| `rate_limited`            | yes     | the service asked you to slow down, not to stop    |
| `network_error`           | yes     | a connection fault says nothing about the token    |
| `provider_error`          | yes     | a 5xx or a credential fault you can fix and re-run |
| `invalid_token`           | **no**  | the token is dead; the device is disabled          |
| `expired_token`           | **no**  | same, and it will not come back                    |
| `payload_too_large`       | **no**  | the payload stays too large on the next try        |
| `provider_not_configured` | **no**  | never enqueued in the first place                  |

A job is retried when **any** delivery in it failed with a retryable code.

> **A run of provider_error means check your config:** `provider_error` on every delivery for one provider is usually a credential
> problem - a wrong p8 key, an expired service account, the wrong APNs gateway.
> Retries will keep failing until you fix it, then the next attempt succeeds.
> Nothing was disabled in the meantime; see
> [Token Lifecycle](/docs/token-lifecycle).

## Retries never re-send [#retries-never-re-send]

The retry job carries only the deliveries that still need sending. That is not
bookkeeping the queue does - it falls out of the delivery status:

* a delivery that succeeded is `sent`,
* one that failed permanently is `failed`,
* one waiting for a retry stays &#x2A;*`queued`**, with `error` holding the last code
  and `attempts` counting the tries.

When the retry runs, it reloads only the `queued` rows. So a batch of three where
one sent, one hit a dead token, and one was rate-limited retries exactly one
delivery - and the device that already got the notification never gets it twice.

## Delivery statuses [#delivery-statuses]

| Status       | Meaning                                                        |
| ------------ | -------------------------------------------------------------- |
| `queued`     | no successful attempt yet, **including between retries**       |
| `sent`       | handed to the push service                                     |
| `failed`     | permanently failed: a non-retryable code, or retries exhausted |
| `suppressed` | a preference switched the channel off; nothing was sent        |
| `delivered`  | in-app rows (the row in your database is the delivery)         |

## Backoff [#backoff]

The default schedule is **30s, 2m, 8m, 32m**, capped at an hour, each with ±20%
jitter. The jitter matters: without it, every worker that failed during the same
provider outage would retry at the same instant and cause a second one.

Override it if your traffic wants something else:

```ts
dbQueue({
  maxAttempts: 8,
  backoff: (attempt) => Math.min(5_000 * 2 ** attempt, 600_000),
});
```

`attempt` is 1 on the first failure. The returned value is milliseconds until
the job is due again.

## Dead-lettering [#dead-lettering]

A job dies when it hits a non-retryable failure, or when it has been claimed
`maxAttempts` times (5 by default). It is marked `status = 'dead'`, its
`last_error` is kept, and it is never claimed again. Its deliveries are written
as `failed` with the last provider code.

```sql
SELECT id, kind, attempts, last_error, updated_at
FROM bp_job
WHERE status = 'dead'
ORDER BY updated_at DESC;
```

Dead rows are kept for inspection - they are the only place a permanently failed
job's history lives, since successful jobs are deleted. Clear them out when you
have read them:

These queries are the `dbQueue` view. On [`bullmq()`](/docs/async-delivery#the-bullmq-backend)
the same state lives in Redis - a dead-lettered job is a BullMQ `failed` job -
and the studio's queue screen shows either one identically, because it reads
through the backend's own inspector rather than querying `bp_job` directly.

```sql
DELETE FROM bp_job WHERE status = 'dead' AND updated_at < now() - interval '30 days';
```

To retry one by hand after fixing the underlying problem, put it back:

```sql
UPDATE bp_job
SET status = 'pending', attempts = 0, run_at = now(), locked_at = NULL
WHERE id = '...';
```

Its deliveries are `failed` by then, so also set the ones you want re-sent back
to `queued` - the job only reloads rows in that state.

## Attempts are counted at claim time [#attempts-are-counted-at-claim-time]

`attempts` increments when a worker claims a job, not when it reports a failure.
That is deliberate: a job that crashes its worker every time - a poison job -
would otherwise never burn an attempt, be reclaimed after every visibility
timeout, and loop forever. Counting at claim guarantees it eventually
dead-letters and shows up in the query above.

The same counter is what recovers a crashed worker. A claim that is never settled
ages out after `visibilityTimeoutMs` (5 minutes by default) and another worker
picks the job up.

## Failures that are not the provider's fault [#failures-that-are-not-the-providers-fault]

| Situation                                  | What happens                                    |
| ------------------------------------------ | ----------------------------------------------- |
| the notification was deleted after enqueue | the job is retired, not retried                 |
| a device row was deleted after enqueue     | that delivery is `failed` with `device_deleted` |
| the job payload is unreadable              | dead-lettered immediately - no retry can fix it |
| the handler throws                         | treated as retryable; the worker loop survives  |
| the worker is killed mid-job               | reclaimed after the visibility timeout          |


---

# Run a worker

> startWorker in a container, graceful shutdown, multiple workers, and the cron/serverless path.

Canonical documentation: /docs/running-a-worker



A queue needs something to drain it. better-push gives you two shapes: a
long-lived worker, and a bounded drain you can call from a cron job or a
serverless function. Both run the same code and both are safe to run at once.

This page assumes you have [async delivery](/docs/async-delivery) configured.

## The long-lived worker [#the-long-lived-worker]

```ts title="worker.ts"
import { push } from "./src/push";

const stop = push.startWorker();
console.log("worker started");

for (const signal of ["SIGTERM", "SIGINT"] as const) {
  process.on(signal, () => {
    // Awaiting stop() is the point: it stops claiming and resolves once the
    // jobs already in flight have settled.
    void stop().then(() => process.exit(0));
  });
}
```

`startWorker()` returns synchronously and loops in the background: claim due
jobs, send them, settle them, repeat. When a claim comes back full it goes
straight round again; when it comes back short it sleeps `pollIntervalMs`.

It never dies from a bad job. A handler that throws is treated as a retryable
failure, and a claim that fails because the database blipped is logged and
retried on the next tick.

### Tuning one worker [#tuning-one-worker]

```ts
push.startWorker({
  batchSize: 25,      // jobs claimed per poll
  pollIntervalMs: 500, // sleep between polls that found nothing
});
```

Both default to the values you gave the backend, which default to 10 and 1000ms
on `dbQueue`. On `bullmq` there is no poll loop, so `pollIntervalMs` has nothing
to do and `batchSize` sets the worker's concurrency instead.

The worker itself is backend-independent: it generates the worker id, writes the
`bp_worker` heartbeat the studio reads, sweeps overdue
[digest](/docs/digests) windows, and schedules rollups and pruning on a 15
second tick - all of that lives above the queue adapter on purpose, so a BullMQ
deployment gets studio worker rows for free.

## Running it as a container [#running-it-as-a-container]

The worker is a plain Node process with no HTTP port, so it wants **no** health
check and **no** domain. In a Dockerfile that already builds your app, add a
second entrypoint and override the start command for the worker service:

```dockerfile
# one image, two roles
CMD ["node", "server.js"]              # web service
# worker service start command: node worker.js
```

Give the worker the same `DATABASE_URL` and the same provider credentials as the
web service - it is the process that actually talks to APNs, FCM, and the browser
push services.

> **Only one service should migrate:** If your web container runs migrations on boot, the worker must not. Two
> services racing the migrator is a real way to break a deploy.

## Graceful shutdown [#graceful-shutdown]

Platforms send `SIGTERM` before replacing a container. Without a handler, the
process dies mid-send: the push may or may not have reached the service, and the
delivery row is left at `queued`.

`stop()` fixes both halves. It stops claiming immediately and resolves only once
the in-flight batch has settled, so the delivery rows are written before the
process exits. A job that was claimed but never settled - because the container
was killed outright - is not lost either: after `visibilityTimeoutMs` another
worker reclaims it.

## Multiple workers [#multiple-workers]

Run as many as you like. Claims use `FOR UPDATE SKIP LOCKED`, so concurrent
workers never block each other and never receive the same row. Scaling the
worker service to three replicas needs no configuration and no coordination.

The same guarantee is what makes a crashed worker recoverable rather than
fatal: its claim ages out and someone else takes the job.

## Cron and serverless [#cron-and-serverless]

If you cannot run a process that never returns, drain on a schedule instead.
`runPending()` does bounded work and resolves with what it did:

```ts
const stats = await push.runPending({ maxJobs: 100, maxMs: 20_000 });
// { claimed: 12, completed: 11, retried: 1, deadLettered: 0, budgetExhausted: false }
```

Both budgets are optional; either one stopping the drain sets
`budgetExhausted` when work is still due, which is your signal to invoke again
immediately rather than waiting for the next tick.

> **Pick a budget under your timeout:** `maxMs` is checked between jobs, not during one, so set it comfortably below
> your function's timeout - a job that starts just under the deadline still runs
> to completion.

### The HTTP entrypoint [#the-http-entrypoint]

For schedulers that can only make an HTTP request, mount the built-in route by
configuring a secret:

```ts
export const push = betterPush({
  // ...
  queue: dbQueue(),
  runPendingSecret: process.env.BETTER_PUSH_RUN_PENDING_SECRET,
});
```

```bash
curl -X POST https://your.app/api/push/_internal/run-pending \
  -H "authorization: Bearer $BETTER_PUSH_RUN_PENDING_SECRET" \
  -H "content-type: application/json" \
  -d '{"maxJobs": 100, "maxMs": 20000}'
```

* Without `runPendingSecret` the route answers **404**: it does not exist unless
  you enable it.
* It is the only route outside the session gate - a scheduler has no user to be -
  and authenticates with the shared secret alone, compared in constant time.
* A wrong or missing secret is **401**; anything but `POST` is **405**.
* The JSON body is optional and validated; `{ maxJobs?, maxMs? }` only.
* Success is **200** with the same stats object `runPending()` returns,
  including a `digests` block:

```json
{
  "claimed": 12, "completed": 12, "retried": 0, "deadLettered": 0,
  "budgetExhausted": false,
  "digests": { "flushed": 2, "rearmed": 0, "failed": 0, "remaining": 0 }
}
```

Digest windows are flushed **before** jobs are drained, so the notifications
they produce are delivered by the same call rather than waiting for the next
tick. That also means the route is useful with **no queue at all**: configure
`runPendingSecret` alongside a type that declares a `digest` and cron becomes
the only trigger a window needs.

Use a long random value, keep it out of your client bundle, and rotate it like
any other credential.

## Mixing the two [#mixing-the-two]

A long-lived worker and a cron drain can run against the same queue at the same
time - they claim through the same statement. A common shape is one worker for
normal throughput plus an hourly `runPending()` as a safety net, so a queue never
sits still because a worker died quietly.

## Worker options [#worker-options]

**WorkerOptions type reference:** Generated from `../../packages/better-push/src/queue/types.ts`. See the canonical documentation page for the field table.

**RunPendingOptions type reference:** Generated from `../../packages/better-push/src/queue/types.ts`. See the canonical documentation page for the field table.


---

# Security

> The boundaries better-push enforces, and the ones your application owns.

Canonical documentation: /docs/security



better-push runs inside your application, against your database, using your
session. It defends the surfaces it owns—its endpoints, queries, stored
notification records, and provider hand-off—but it cannot replace your
authentication, database security, proxy policy, or credential management.

## What better-push does [#what-better-push-does]

Every public router endpoint resolves your `session` and scopes database reads
and writes to its `userId`. The only exception is
`POST {basePath}/_internal/run-pending`: a scheduler has no user session, so the
route exists only when `runPendingSecret` is configured and compares its bearer
token in constant time. Per-user rate limits protect reads, writes, and open SSE
streams without trusting proxy-provided IP headers.

State-changing requests (`POST`, `PUT`, `PATCH`, and `DELETE`) must be
same-origin. Requests with no `Origin` remain available to native clients,
cron, and server-to-server callers. `Sec-Fetch-Site: cross-site` is refused,
even if an allowlist entry would otherwise match. Add exact origins when a
browser is served elsewhere:

```ts
const push = betterPush({
  // ...
  trustedOrigins: ["https://app.example.com"],
});
```

There are no wildcard origins. Use the function form for a dynamic tenant
allowlist. Behind a proxy, the default same-origin comparison uses the origin
of `request.url`; pin the proxy's Host header or configure the list explicitly.
`trustedOrigins: false` disables this defense and is appropriate only when an
upstream layer enforces an equivalent policy.

Requests are bounded before JSON parsing. Defaults are 64 KiB per body, 4 KiB
per device token, 200 preferences per update, and 8 KiB for serialized
notification content. Configure larger values through `limits` when the
application genuinely needs them.

Feed cursors are opaque rather than signed; safety comes from every cursor
query still filtering by `user_id`. SQL is composed from static chunks that
cannot contain `$`, keeping parameterization structural. Private JSON responses
are `private, no-store` and vary on cookies and authorization. Device tokens
never appear in client JSON or studio queries.

The studio omits title, body, and data unless your permission callback grants
`content`. A reveal is a separate, audited action. Realtime carries only a
change signal; logs, metrics, diagnostics, errors, and audit metadata do not
carry notification content.

## What better-push does not do [#what-better-push-does-not-do]

better-push does not authenticate a user; your `session` callback does. It does
not provide CORS, because its browser endpoints are designed to be mounted in
the app they serve. It does not perform IP rate limiting because that requires
an explicit proxy-trust policy. Put unauthenticated edge limits on your proxy or
CDN. It does not encrypt notification payloads at rest; use your database and
storage controls. It cannot protect a provider after its credentials are
compromised.

## What your application must do [#what-your-application-must-do]

* Keep VAPID private keys, APNs p8 keys, Firebase service accounts, Expo access
  tokens, database credentials, and `runPendingSecret` in a secret manager.
* Use a random `runPendingSecret` of at least 32 characters, rotate it like an
  API credential, and also limit the endpoint at the platform edge.
* Rotate provider credentials in the provider console, deploy the replacement,
  verify delivery, and revoke the old credential.
* Choose a cookie `SameSite` policy intentionally. If cross-site cookies are
  necessary, keep the origin gate enabled and enumerate the calling apps.
* Treat the studio permission callback as the admin authorization boundary.
  `allowWrites` enables actions; it does not decide who is an administrator.
* Do not put secrets in notification `data`. It is stored in your database and
  delivered to a device in clear application data.

## Reporting [#reporting]

Report vulnerabilities privately using the repository's
[`SECURITY.md`](https://github.com/hesennivas/better-push/blob/main/SECURITY.md).
Do not open a public issue containing exploit details or real user data.

## Configuration reference [#configuration-reference]

**BetterPushOptions type reference:** Generated from `../../packages/better-push/src/index.ts`. See the canonical documentation page for the field table.


---

# Run Studio locally

> npx @better-push/cli studio - the studio against your own database, on localhost.

Canonical documentation: /docs/studio-local



The fastest way to look at your notification data is the CLI studio. It needs no
code change and no mounted route: point it at a database and it serves the same
SPA the [mounted studio](/docs/studio-production) does.

```bash
npx @better-push/cli studio
```

```
  better-push studio

  http://127.0.0.1:4983
  bound to localhost only - there is no authentication here
  read-only - pass --allow-writes to enable operator actions
```

## Options [#options]

| Flag                   | Default           | Meaning                        |
| ---------------------- | ----------------- | ------------------------------ |
| `--database-url <url>` | -                 | Postgres connection string     |
| `--cwd <dir>`          | current directory | where to look for `.env` files |
| `--port <port>`        | `4983`            | port to listen on              |
| `--allow-writes`       | off               | enable the operator actions    |

## Where the connection comes from [#where-the-connection-comes-from]

Resolved in this order, so the common case needs no flag at all:

1. `--database-url`
2. `DATABASE_URL` in the environment
3. `DATABASE_URL` in `.env.local`, then `.env`, in `--cwd`

If none of those has one, it says so and names all three rather than failing on
a connection attempt.

> **It binds to localhost, and that is not configurable:** There is **no authentication** in the CLI studio - it is a developer tool
> against a database you already have full access to. That is exactly why it
> binds to `127.0.0.1` only and why the bind address cannot be changed. Do not
> put it behind a tunnel or a reverse proxy; if you want a studio other people
> can reach, [mount it in your app](/docs/studio-production) where your own auth
> protects it.

## Read-only by default [#read-only-by-default]

Without `--allow-writes` the operator actions are refused by the **server**, not
just hidden in the UI - retry, purge, disable, delete, reset preferences, and
send test all answer 403.

With `--allow-writes` they are enabled, and the audit trail records the actor as
`cli:<your-os-username>` so a local action is still attributable:

```bash
npx @better-push/cli studio --allow-writes
```

Content is always visible in the CLI studio - masking it would be theatre when
the same person can open `psql` - but **reveals are still audited**, so the
trail is complete wherever it was read from.

## Pointing it at production [#pointing-it-at-production]

You can, with the usual care: it is a read-only console by default, and every
query is time-bounded and capped. Prefer a read replica if you have one, and do
not pass `--allow-writes` unless you mean it.

```bash
DATABASE_URL="postgres://…" npx @better-push/cli studio
```

For a studio your team can reach without handing out database credentials,
[mount it in the app](/docs/studio-production) instead.


---

# Mount Studio in an application

> studioHandler, the permission model, server-side redaction, and the audit trail.

Canonical documentation: /docs/studio-production



The mounted studio is a second handler you put wherever your admin surface
lives. It is **not** a method on the core instance: an admin console should not
exist unless someone deliberately mounted one, and keeping it in its own subpath
keeps its code and assets out of your app's main bundle.

```ts title="app/admin/push/[...all]/route.ts"
import { studioHandler } from "@better-push/core/studio";
import { toNextJsHandler } from "@better-push/core/nextjs";
import { push } from "@/push";

const studio = studioHandler(push, {
  permissions: async (request) => {
    const session = await auth(request);
    if (!session?.user.isAdmin) return null;   // -> 401
    return {
      actor: session.user.id,
      view: true,
      content: session.user.role === "support",
      write: session.user.role === "ops",
    };
  },
  allowWrites: process.env.NODE_ENV !== "production",
});

export const { GET, POST, PUT, PATCH, DELETE } = toNextJsHandler(studio);
```

The handler serves the JSON API under `/api/*` relative to its mount, and the
SPA for every other path, with an index fallback so deep links work.

> **Link to a screen, not the bare mount:** Next route handlers do not match the parent segment of their own catch-all, so
> `/admin/push` alone answers 404 while `/admin/push/overview` works. Point your
> admin nav at a screen path. The optional catch-all (`[[...all]]`) is still the
> right shape - it is what lets every other client-side route resolve.

## The permission model [#the-permission-model]

A permission **set**, not a boolean, because the three questions are genuinely
different:

`view` grants metrics, counts, statuses, error codes, timings, and identifiers.
`content` additionally grants notification title, body, and data payloads.
`write` grants operator actions when `allowWrites` is also enabled. The full
permission result is generated from its TypeScript interface below.

`actor` identifies the admin in the audit trail and is required. Returning
`null` means "not an admin" and answers 401 on every route.

Seeing *that* a send failed is not the same as reading *what it said*, and
neither implies the right to delete someone's device. A support engineer usually
wants `view` + `content`; an on-call engineer usually wants `view` + `write`.

## Redaction is server-side [#redaction-is-server-side]

When `content` is false the API does **not** include `title`, `body`, or `data`
at all - not empty strings, not masked strings, **absent**. Rows carry
`redacted: true` so the UI can render the affordance.

```json
{
  "id": "8f2a…",
  "type": "orderShipped",
  "createdAt": "2026-07-29T10:00:00.000Z",
  "redacted": true,
  "deliveries": [ … ]
}
```

The redaction happens in the query layer, not the transport and not the UI: a
row that was never authorised should never have had its content loaded into a
response object in the first place. There is nothing for a client bug or a
serializer change to leak.

## Reveal is a separate, audited call [#reveal-is-a-separate-audited-call]

An admin with `content` still asks explicitly, per notification:

```
POST /api/reveal   { "notificationIds": ["8f2a…"] }
```

Each id writes its own `bp_audit` row. That is the point of splitting reveal
from list: the record says exactly what someone looked at, rather than "loaded
a page". Without `content` it is a 403 and nothing is written.

## Writes need both switches [#writes-need-both-switches]

Every write endpoint requires `allowWrites === true` on the handler **and**
`write === true` from your callback. Either one missing is a 403 - including the
case where your callback grants `write` but the handler was constructed
read-only.

`allowWrites` defaults to **off**, and turning it on logs a loud warning at
construction, so an accidental production enable is visible in the first boot's
logs rather than discovered after someone deletes a device.

| Action            | Endpoint                                    |
| ----------------- | ------------------------------------------- |
| Retry a dead job  | `POST /api/jobs/:id/retry`                  |
| Purge dead jobs   | `POST /api/jobs/purge`                      |
| Disable a device  | `POST /api/devices/:id/disable`             |
| Delete a device   | `DELETE /api/devices/:id`                   |
| Reset preferences | `POST /api/users/:userId/preferences/reset` |
| Send a test       | `POST /api/send-test`                       |

## The audit trail [#the-audit-trail]

Every privileged action writes a `bp_audit` row: actor, action, target, and
metadata. Audited actions are `reveal`, `retry_job`, `purge_jobs`,
`disable_device`, `delete_device`, `reset_preferences`, and `send_test`.

```sql
SELECT created_at, actor, action, target_type, target_id
FROM bp_audit
ORDER BY created_at DESC
LIMIT 50;
```

The studio shows the same list on its Audit screen. Give `bp_audit` a
[retention window](/docs/metrics-and-retention) if you do not want it kept
forever.

## Health for uptime monitors [#health-for-uptime-monitors]

`GET /api/health` returns machine-readable health. Configure a `healthSecret`
and a monitor can poll it with a bearer token instead of an admin session:

```ts
studioHandler(push, {
  permissions: resolveAdmin,
  healthSecret: process.env.BETTER_PUSH_HEALTH_SECRET,
});
```

```bash
curl -H "authorization: Bearer $BETTER_PUSH_HEALTH_SECRET" \
  https://your.app/admin/push/api/health
# {"status":"degraded","issues":["3 job(s) dead-lettered"], …}
```

The comparison is timing-safe. Without the secret configured, health needs
`view` like everything else.

## Read-only tiers are the normal case [#read-only-tiers-are-the-normal-case]

A studio with `allowWrites: false` is a complete monitoring and forensics
console - every screen works, nothing can be changed. Enabling writes is a
separate decision from enabling the studio, and it is worth keeping it that way.

## Studio interfaces [#studio-interfaces]

**StudioOptions type reference:** Generated from `../../packages/better-push/src/studio/handler.ts`. See the canonical documentation page for the field table.

**StudioPermissions type reference:** Generated from `../../packages/better-push/src/studio/permissions.ts`. See the canonical documentation page for the field table.


---

# Studio

> Monitoring, forensics, and operator actions over the rows better-push already writes.

Canonical documentation: /docs/studio



Every question an operator asks is **already a row in your database**.
`bp_notification` records what was created, `bp_delivery` records every attempt
with its status, error code, and timings, `bp_device` records the fleet and why
tokens died, `bp_preference` records opt-outs, and `bp_job` records queue depth,
retries, and dead letters.

The studio is the console over those rows. It is not an instrumentation
problem - it is a query problem, which is why better-push can answer it without
a metrics service, an agent, or a copy of your data anywhere else.

## What it is for [#what-it-is-for]

The differentiated value is **forensics** and **queue health** - the two things
an external APM cannot do, because it does not have these rows:

* *"User 8f2a says the order-shipped push never arrived."* Search the user, see
  their devices, see that one was disabled three days ago with `expired_token`,
  see the delivery row that says so.
* *"What is stuck, and why?"* Queue depth, the age of the oldest pending job,
  dead-lettered jobs with their last error, and which worker holds which claim.

It is deliberately **not** a rebuild of Grafana. There are enough charts for
context and no more; aggregate dashboards over long time ranges are what APM
tools already do well.

## Two ways to run it [#two-ways-to-run-it]

|         | [CLI studio](/docs/studio-local) | [Mounted studio](/docs/studio-production)    |
| ------- | -------------------------------- | -------------------------------------------- |
| Command | `npx @better-push/cli studio`    | `studioHandler(push, { … })`                 |
| Runs on | your machine, localhost only     | your app, wherever you mount it              |
| Auth    | none, by design                  | your own admin session                       |
| Content | always visible                   | requires the `content` permission            |
| Writes  | `--allow-writes`                 | `allowWrites` **and** the `write` permission |

Both serve the **same** prebuilt SPA and the same JSON API, so what you learn in
one applies to the other.

## Screens [#screens]

* **Overview** - volume, success rate, failures by error code, delivery lag,
  queue depth, live workers, device fleet, latency percentiles, read rate.
  Selecting an error code opens it in the deliveries explorer.
* **Deliveries** - every attempt, with a detail pane beside the list showing the
  notification and its sibling deliveries.
* **Queue** - pending and dead jobs with retry and purge, and each job's payload
  and last error. It reads through the configured backend's own inspector, so it
  is identical on [`dbQueue()` and `bullmq()`](/docs/async-delivery) and still
  works with no queue configured at all, where it shows the `bp_job` table.
* **Users** - devices (including disabled ones), stored preference overrides, and
  recent notifications. This is the screen that answers a support ticket.
* **Send test** - go through the real `notify()` path.
* **Audit** - every privileged action, including each content reveal.

Three things apply across all of them:

* **A global time window** in the header (6 hours to 90 days) that scopes the
  overview and the deliveries explorer.
* **A command palette** on <kbd>⌘K</kbd>. Paste a notification, delivery, device
  or user id and it resolves to whatever it is; it also jumps between screens,
  as does <kbd>g</kbd> followed by a letter.
* **Addressable views.** The screen, the window, and every list filter live in
  the URL, so `…/deliveries?error=BadDeviceToken&range=7d` is a link you can hand
  to whoever asked.

## What it is built on [#what-it-is-built-on]

The UI talks to a `StatsSource` interface and nothing else:

```ts
import { databaseStats } from "@better-push/core/stats";

const stats = databaseStats(drizzleAdapter(db));
await stats.overview({ from: "2026-07-01T00:00:00Z" });
```

Every method is **time-bounded and paginated** with a hard server-side cap, so
no query the studio can be talked into issuing scans more of your history than
the cap allows. Charts read [rollups](/docs/metrics-and-retention); forensic
lists read raw rows through indexes added for exactly that purpose.

The UI reads only through this bounded interface; it does not query tables
directly.

## What it does not do [#what-it-does-not-do]

* **No HTTP request logging.** A feed polling every 30 seconds would write more
  rows than the notification data itself. Use the
  [`onEvent` hook](/docs/metrics-and-retention#the-onevent-hook) to forward
  lifecycle events to OpenTelemetry or your APM instead.
* **No realtime.** Screens poll and show "updated Ns ago".
* **No alerting**, and no cross-project views.


---

# Comparison

> How a library inside your stack differs from a hosted notification service.

Canonical documentation: /docs/comparison



Novu, Knock, and OneSignal are notification services. better-push is a library
inside your application. The important choice is not a checklist of push APIs;
it is where subscriber identity, preferences, content, endpoints, and
operations live.

|                                 | better-push                | Novu / Knock / OneSignal    | Build it yourself              |
| ------------------------------- | -------------------------- | --------------------------- | ------------------------------ |
| User and device source of truth | your Postgres              | synced to a vendor          | whatever you design            |
| Endpoint and session            | mounted in your app        | vendor API and SDK          | yours                          |
| Notification content            | your database and provider | vendor service and provider | yours                          |
| Push and in-app feed            | included                   | included                    | build both                     |
| Email, SMS, Slack               | no                         | commonly included           | build or integrate             |
| Campaign and template UI        | no                         | hosted product              | build it                       |
| Non-engineer dashboard          | no hosted dashboard        | yes                         | build and operate it           |
| Databases                       | Postgres only              | vendor-managed              | your choice                    |
| Operations and on-call          | you                        | shared with vendor          | you                            |
| Hosting fee                     | infrastructure you run     | usage or contract pricing   | infrastructure and engineering |

## The subscriber-sync problem [#the-subscriber-sync-problem]

A hosted service needs a second representation of your users. Your application
must create subscribers, update profile fields, rotate device tokens, reconcile
preferences, and delete data in both systems. That can be a good trade when a
marketing or support team needs a hosted workflow product.

better-push removes that category of integration. `session()` returns the user
already authenticated by your app, `bp_device.user_id` uses the same id, and a
send queries that user's devices and preferences in the same database. There is
no subscriber API to keep synchronized because there is no second subscriber
store.

## When a hosted service is the better fit [#when-a-hosted-service-is-the-better-fit]

Choose a service when non-engineers need campaigns, visual workflows,
templating, analytics, or channels such as email and SMS; when vendor support
and an external team on call are worth the data boundary; or when Postgres and
Node do not fit the application.

## When better-push is the better fit [#when-better-push-is-the-better-fit]

Choose better-push when push and an in-app feed belong inside a TypeScript app,
subscriber data should not be synchronized elsewhere, notification history
needs to join directly to application data, and your team is prepared to run
the database, worker, provider credentials, and operational response.

## When to build it yourself [#when-to-build-it-yourself]

Build from scratch when the provider or workflow model is fundamentally
different, another database is mandatory, or owning every protocol detail is a
product requirement. The cost includes token lifecycle, retries, idempotency,
preferences, cross-user query safety, provider receipts, observability, admin
authorization, and years of edge cases—not only the first successful push.


---

# Demo matrix

> Five running frameworks, three interchangeable Postgres drivers, one session and one queue.

Canonical documentation: /docs/demos



The [live demo](https://demo.better-push.com) puts every supported host on one origin. Sign in through any stack and the same better-auth cookie authenticates all five.

| App                | Path                                                  | Deployed driver |
| ------------------ | ----------------------------------------------------- | --------------- |
| Next.js App Router | [`/next`](https://demo.better-push.com/next/)         | Drizzle         |
| TanStack Start     | [`/tanstack`](https://demo.better-push.com/tanstack/) | Prisma          |
| Express 5          | [`/express`](https://demo.better-push.com/express/)   | raw `pg`        |
| Hono               | [`/hono`](https://demo.better-push.com/hono/)         | Drizzle         |
| NestJS             | [`/nestjs`](https://demo.better-push.com/nestjs/)     | Prisma          |

Every app exposes device registration, send, in-app feed, preferences, Studio, browser diagnostics, and the emulator. The Next app remains the depth demo for digests, scheduled sends, queue inspection, FCM, and two-instance realtime.

## Cross-stack walkthroughs [#cross-stack-walkthroughs]

### Sign in anywhere [#sign-in-anywhere]

Sign in as Alice on Express, then open NestJS. No second sign-in is needed: every app has its own better-auth handler, but they share the secret, tables, cookie name, origin, and cookie path.

### Register here, send there [#register-here-send-there]

Register an emulator device on Hono. Open Next or NestJS and send. The device and delivery rows are shared database records, so the notification appears in the [shared inbox](https://demo.better-push.com/dev/inbox/).

### One worker, five producers [#one-worker-five-producers]

Every app writes the same `bp_job` payload. One worker imports the shared notification definitions and drains jobs regardless of which framework enqueued them.

### Kill a token everywhere [#kill-a-token-everywhere]

Disconnect a virtual device in the inbox, then send from another stack. The emulator returns `invalid_token`, the shared `bp_device` row is disabled, and every stack stops targeting it.

The [matrix page](https://demo.better-push.com/matrix) calls each app's own `push.diagnose()` endpoint. The [parity page](https://demo.better-push.com/parity) runs a send/feed/preference/read sequence through all three adapters against the live database.


---

# Emulator

> See a notification without a device, a certificate, or a permission prompt.

Canonical documentation: /docs/emulator



To see a single push notification normally, you need VAPID keys, a service
worker, HTTPS or localhost, a browser permission prompt, and - for anything
mobile - a real device with real credentials. Every one of those is a place to
stop.

The emulator removes all of them from the first hour. Run one command, register
a virtual device, and `notify()` shows up in a local inbox.

```bash
npx @better-push/cli dev
```

```
  better-push dev

  inbox      http://127.0.0.1:4984
  provider   add emulator() to your providers, or set
             BETTER_PUSH_EMULATOR_URL=http://127.0.0.1:4984 if you moved this inbox

  bound to localhost only - there is no authentication here
```

## It is a real provider, not a fake [#it-is-a-real-provider-not-a-fake]

`emulator()` is a `PushProvider` like `webPush()` or `apns()`. A notification
sent to it goes through preferences, digests, delivery rows, the queue, retries
and the token lifecycle exactly as one sent to Apple does.

That is the point. An interception layer would show you what you *meant*; this
shows you what the pipeline actually produced - including a delivery that
preferences suppressed, or a burst that a digest collapsed into one.

```mermaid
sequenceDiagram
  participant B as Browser page
  participant A as App server
  participant E as Emulator provider
  participant I as Emulator inbox
  B->>A: Register a virtual device
  A->>A: Store the emulator device
  Note over A: Your app creates a notification
  A->>E: dispatch batch
  E->>I: Send rendered payloads
  I-->>E: Return delivery results
  E-->>A: Return device results
  A->>A: Update delivery and device
  I->>I: Show the notification
```

## Wiring it up [#wiring-it-up]

### 1. Add the provider [#1-add-the-provider]

```ts title="push.ts"
import { emulator } from "@better-push/core/providers/emulator";

export const push = betterPush({
  providers: [
    webPush({ vapid }),
    // Development only. `doctor` reports it as an error in production.
    ...(process.env.NODE_ENV === "production" ? [] : [emulator()]),
  ],
  // ...
});
```

It defaults to `BETTER_PUSH_EMULATOR_URL`, then `http://127.0.0.1:4984`.

### 2. Register a virtual device [#2-register-a-virtual-device]

```tsx
"use client";
import { useEmulatorDevice } from "@better-push/core/react";

export function DevTools() {
  const emulator = useEmulatorDevice();
  if (process.env.NODE_ENV === "production") return null;

  return (
    <button onClick={() => void emulator.register()}>
      {emulator.status === "registered"
        ? "Virtual device registered"
        : "Register a virtual device"}
    </button>
  );
}
```

The hook POSTs to **your own** `/devices` endpoint with a random token kept in
`localStorage`. So your own session applies, the row it creates is an ordinary
`bp_device` row, and there is no cross-origin request anywhere - the inbox never
talks to the browser, only to your server.

| Option       | Default       | What                                                    |
| ------------ | ------------- | ------------------------------------------------------- |
| `basePath`   | `"/api/push"` | Must match your server's                                |
| `baseURL`    | same origin   | For a separate API host                                 |
| `platform`   | `"web"`       | What this virtual device stands for; shown in the inbox |
| `deviceName` | `"Emulator"`  | Shown in the device list and the studio                 |

### 3. Send something [#3-send-something]

```ts
await push.notify({ userId: "alice", title: "Hello from the emulator" });
```

It appears in the inbox within a second, over SSE, with the rendered title and
body, the `data`, the type, the target device and platform, and the delivery id.

## The disconnect switch [#the-disconnect-switch]

Every device in the inbox has a **Disconnect** button. The next send to it comes
back `invalid_token` with `tokenDead`, and better-push disables the device -
exactly as it would for a real `410 Gone` from a push service.

That makes the [token lifecycle](/docs/token-lifecycle) something you can watch
happen in ten seconds rather than something you read about and hope you got
right. **Reconnect** puts it back.

## When the inbox is not running [#when-the-inbox-is-not-running]

Every delivery fails with `network_error` and a detail saying to run
`better-push dev`. Nothing throws: a developer who forgot the command sees failed
deliveries and a clear message, not a 500 that takes their request down.

`network_error` is retryable, so with a [queue](/docs/async-delivery) configured
the notification simply arrives when the inbox comes back.

Pass `emulator({ tolerant: false })` if you would rather it threw.

## Mounting the inbox yourself [#mounting-the-inbox-yourself]

`dev` is `createServer(toNodeHandler(emulatorInbox().handler))` plus a banner.
The inbox is a library surface, so an app that wants it inside its own dev server
can mount it:

```ts
import { emulatorInbox } from "@better-push/core/emulator";

const inbox = emulatorInbox({ basePath: "/__inbox" });
// inbox.handler is (Request) => Promise<Response>
```

| Option     | Default | What                                       |
| ---------- | ------- | ------------------------------------------ |
| `capacity` | `500`   | Notifications kept; the oldest are dropped |
| `basePath` | `"/"`   | Where the inbox is mounted                 |

State is **in-memory and per process**. It is a dev tool: there is nothing to
persist, and nothing to secure beyond binding to loopback.

## This is not a push service [#this-is-not-a-push-service]

> **Development only:** The emulator delivers to a local inbox. It reaches no device, no browser, and
> no phone. `push.diagnose()` reports it as an **error** when
> `NODE_ENV === "production"`, and the provider logs a warning at startup -
> because a production deployment that reached this code has a configuration
> mistake that would otherwise look like a working system delivering nothing.

## Native [#native]

There is no `useEmulatorDevice` for React Native yet. The inbox receives whatever
the `emulator` provider is sent, so a native app can register a virtual device by
hand:

```ts
await fetch(`${API}/api/push/devices`, {
  method: "POST",
  headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
  body: JSON.stringify({
    platform: "ios",
    provider: "emulator",
    token: "emulator-my-simulator",
  }),
});
```


---

# Quickstart

> Add better-push to an existing Node and Postgres application and verify the first delivery.

Canonical documentation: /docs/quickstart



This guide uses the CLI to add better-push to an existing application. It keeps
provider setup out of the first loop by using the local emulator.

## Prerequisites [#prerequisites]

* Node.js 22 or newer
* PostgreSQL and a `DATABASE_URL`
* A supported framework and database client

The CLI supports Next.js, TanStack Start, Express, Hono, and NestJS with `pg`,
Drizzle, or Prisma. Use a [framework walkthrough](/docs/walkthroughs) if you
prefer to wire the files manually.

## 1. Scaffold the integration [#1-scaffold-the-integration]

Run this from the application root:

```bash
npx @better-push/cli init
```

The command detects your stack, previews its changes, and asks before writing.
Use `--dry-run` to inspect the output without changing files.

It creates or proposes these integration points:

| File                      | Purpose                                                             |
| ------------------------- | ------------------------------------------------------------------- |
| `push.ts`                 | Database, providers, session resolver, and notification definitions |
| Framework route or module | Mounts the better-push HTTP handler at `/api/push`                  |
| Schema artifact           | Defines the `bp_*` tables for `pg`, Drizzle, or Prisma              |
| `public/sw.js`            | Receives Web Push in browser applications                           |
| Environment entries       | Development VAPID values and required placeholders                  |

Existing files are skipped unless you explicitly pass `--force`.

## 2. Connect your application [#2-connect-your-application]

Open the generated `push.ts` and complete every `TODO`:

1. Point the adapter at your database client if the CLI could not infer its
   import.
2. Replace the session placeholder with your authentication lookup. Return
   `{ userId }` for an authenticated request and `null` otherwise.
3. Confirm that `basePath` matches the route mount.

```ts
session: async (request) => {
  const user = await getUserFromRequest(request);
  return user ? { userId: user.id } : null;
},
```

The session resolver protects device, feed, and preference endpoints. Calls to
`push.notify()` are server-side operations; your application must authorize
them before choosing a target user.

## 3. Create the database tables [#3-create-the-database-tables]

For a quick local setup, apply the canonical schema directly:

```bash
npx @better-push/cli migrate
```

To keep schema changes in your existing migration history, run
`npx @better-push/cli generate` and use your normal migration tool instead.
Prisma users must also apply the generated partial-index SQL. See
[Database adapters](/docs/database-adapters) for each path.

## 4. Validate the integration [#4-validate-the-integration]

```bash
npx @better-push/cli doctor
```

`doctor` checks the project layout, environment, schema, providers, route
configuration, queue, and cache. It exits with status 1 when it finds an error,
so the same command can run in CI.

## 5. Verify a delivery locally [#5-verify-a-delivery-locally]

Uncomment the generated development-only `emulator()` provider in `push.ts`,
then start the inbox:

```bash
npx @better-push/cli dev
```

Add a development-only registration control to an authenticated page:

```tsx
"use client";

import { useEmulatorDevice } from "@better-push/core/react";

export function RegisterTestDevice() {
  const device = useEmulatorDevice();

  return (
    <button onClick={() => void device.register()}>
      {device.status === "registered" ? "Test device registered" : "Register test device"}
    </button>
  );
}
```

Register the device, then send from trusted server code using the same user ID:

```ts
await push.notify({
  userId: user.id,
  title: "Hello from better-push",
  body: "The delivery pipeline is working.",
  data: { url: "/notifications" },
});
```

The notification appears in the inbox printed by `better-push dev`. The send
also creates notification and delivery records in Postgres, so this verifies
the normal persistence, routing, and result handling—not a separate mock path.

## Continue the implementation [#continue-the-implementation]

* Configure [Web Push](/docs/web-push-setup), [FCM](/docs/fcm-setup),
  [APNs](/docs/apns-setup), or [Expo](/docs/expo-setup).
* Declare reusable [typed notifications](/docs/typed-notifications).
* Add the [in-app feed](/docs/in-app-feed) and [preferences](/docs/preferences).
* Read [Deployment](/docs/deployment) before choosing inline or
  [queued delivery](/docs/async-delivery).
* Review the [security boundary](/docs/security) before exposing the mounted
  routes.


---

# Express walkthrough

> See the Express 5 plus raw pg demo running.

Canonical documentation: /docs/walkthroughs/express



[Open the Express demo](https://demo.better-push.com/express/) or inspect [`apps/demos/express`](https://github.com/hesennivas/better-push/tree/main/apps/demos/express).

The app mounts the Express router under `/express/api/push`, protects Studio with better-auth, serves the shared React SPA, and uses the raw Postgres adapter in production.


---

# Hono walkthrough

> better-push on a Hono API, with the emulator standing in for a client.

Canonical documentation: /docs/walkthroughs/hono



[See it running](https://demo.better-push.com/hono/) and inspect [`apps/demos/hono`](https://github.com/hesennivas/better-push/tree/main/apps/demos/hono).

A Hono API server, from nothing to a notification you can see. Hono serves an
API rather than a browser, so there is no service worker and no permission
prompt - the devices are whatever clients you serve, and the emulator stands in
for one while you build.

You need a Hono project on Node with a `DATABASE_URL`.

## 1. Scaffold [#1-scaffold]

```bash
npx @better-push/cli init --framework hono
```

```
Detected Hono · node-postgres (pg) · package manager pnpm · source root src

Planned changes
create src/push.ts
create src/push-route.ts
create better-push.sql
env    .env.local (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT, …)
note   No service worker was written: this framework serves an API, not a
       browser. Register devices by POSTing to /api/push/devices from whatever
       client you serve.
```

## 2. Install and mount [#2-install-and-mount]

```bash
pnpm add @better-push/core pg
```

```ts title="src/index.ts"
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { pushRoutes } from "./push-route";

const app = new Hono();
app.route("/", pushRoutes);

serve({ fetch: app.fetch, port: 3000 });
```

> **Do not compress the event stream:** `GET /api/push/events` is SSE. A compression middleware buffers it, turning a
> live feed into one long silence followed by everything at once. Exclude that
> path, or mount compression after `pushRoutes`.

## 3. Create the tables [#3-create-the-tables]

```bash
npx @better-push/cli migrate
```

## 4. Wire your session [#4-wire-your-session]

Hono has no session convention, so this is yours to write. The resolver gets the
raw `Request`:

```ts title="src/push.ts"
session: async (request) => {
  const token = request.headers.get("authorization")?.replace("Bearer ", "");
  const user = token ? await verify(token) : null;
  return user ? { userId: user.id } : null;
},
```

## 5. Check it [#5-check-it]

```bash
npx @better-push/cli doctor
```

It knows this is Hono, finds `src/push-route.ts`, and does **not** complain
about a missing service worker - an API server has no page to register one from.

## 6. See a notification [#6-see-a-notification]

```ts title="src/push.ts"
import { emulator } from "@better-push/core/providers/emulator";

providers: [
  webPush({ vapid: { /* ... */ } }),
  ...(process.env.NODE_ENV === "production" ? [] : [emulator()]),
],
```

```bash
npx @better-push/cli dev
```

There is no browser here, so register the virtual device with a request - which
is exactly what your real clients will do:

```bash
curl -X POST http://localhost:3000/api/push/devices \
  -H "authorization: Bearer $YOUR_TOKEN" \
  -H "content-type: application/json" \
  -d '{"platform":"ios","provider":"emulator","token":"emulator-dev-1"}'
```

Then send:

```ts
await push.notify({ userId: "alice", title: "Hello", body: "It works." });
```

It appears at `http://127.0.0.1:4984`, tagged `ios` because that is what the
device registered as.

## 7. The real clients [#7-the-real-clients]

Hono usually fronts a mobile app or a separate frontend. Both register the same
way:

* **React Native**: [`@better-push/core/native`](/docs/react-native) handles the
  permission flow, the token, and re-registration on rotation.
* **A separate web frontend**: `usePushRegistration` from `@better-push/core/react`,
  with `baseURL` pointing at this API. Serve `sw.js` from the *frontend's*
  origin, not this one - a service worker's scope is its own origin.

## Where to go next [#where-to-go-next]

- [Hono integration](/docs/hono): The mount in detail, including the compression trap.

- [React Native](/docs/react-native): The native client, for the app this API probably serves.

- [Deployment](/docs/deployment): A Hono server can run a worker; here is how.


---

# Framework walkthroughs

> Choose a server framework and build a working better-push integration.

Canonical documentation: /docs/walkthroughs



Each walkthrough starts with a new application, mounts the better-push handler,
connects Postgres and a session resolver, applies the schema, and verifies a
delivery with the emulator.

| Framework          | Integration shape       | Walkthrough                                   |
| ------------------ | ----------------------- | --------------------------------------------- |
| Next.js App Router | Catch-all route handler | [Next.js](/docs/walkthroughs/nextjs)          |
| TanStack Start     | Splat server route      | [TanStack Start](/docs/walkthroughs/tanstack) |
| Express            | Mounted `Router`        | [Express](/docs/walkthroughs/express)         |
| Hono               | Mounted sub-application | [Hono](/docs/walkthroughs/hono)               |
| NestJS             | Imported module         | [NestJS](/docs/walkthroughs/nestjs)           |

The server configuration is otherwise framework-independent. After completing
a walkthrough, use [Sessions](/docs/sessions) to connect your authentication
system and [Providers](/docs/providers) to choose production delivery channels.


---

# NestJS walkthrough

> better-push on NestJS, including the basePath trap.

Canonical documentation: /docs/walkthroughs/nestjs



[See it running](https://demo.better-push.com/nestjs/) and inspect [`apps/demos/nestjs`](https://github.com/hesennivas/better-push/tree/main/apps/demos/nestjs).

A NestJS API, from nothing to a notification you can see. Nest is the stack with
one trap worth naming up front, so it gets said twice.

You need a Nest project with a `DATABASE_URL`.

## 1. Scaffold [#1-scaffold]

```bash
npx @better-push/cli init --framework nestjs
```

```
Detected NestJS · Prisma · package manager pnpm · source root src

Planned changes
create src/push.ts
create src/push.module.ts
create prisma/better-push.prisma
create prisma/better-push-indexes.sql
create prisma.config.ts
env    .env.local (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT, …)
```

`init` detects Nest even though `@nestjs/platform-express` depends on Express -
the more specific answer is the right one.

## 2. Install and import [#2-install-and-import]

```bash
pnpm add @better-push/core @prisma/client @prisma/adapter-pg pg
```

```ts title="src/app.module.ts"
import { Module } from "@nestjs/common";
import { PushModule } from "./push.module";

@Module({ imports: [PushModule] })
export class AppModule {}
```

That is the whole mount. The module routes through `configure()` middleware, so
there is no controller to write and no route decorator to keep in sync.

## 3. The basePath trap [#3-the-basepath-trap]

> **Two places must agree, and both fail silently:** **`forRootAsync` requires an explicit `basePath`.** The instance the factory
> returns does not exist when Nest configures routing, so its own `basePath`
> cannot be read. `init` writes `"/api/push"` in both files.**The module is middleware, not a controller**, so `app.setGlobalPrefix()` is
> **not** applied to it. If you set a global prefix, include it in `basePath`
> here *and* in `push.ts`.Neither mistake fails to compile. Both 404 every push endpoint at runtime,
> which is a confusing afternoon.

```ts title="src/push.module.ts"
@Module({
  imports: [
    BetterPushModule.forRootAsync({
      basePath: "/api/push",   // must match push.ts, prefix included
      useFactory: () => push,
    }),
  ],
})
export class PushModule {}
```

With `app.setGlobalPrefix("v1")`, both become `"/v1/api/push"`.

## 4. Create the tables [#4-create-the-tables]

```bash
npx @better-push/cli migrate
```

> **Prisma users: run the index SQL, or run migrate:** `prisma migrate` builds a database missing all three of better-push's partial
> indexes, because Prisma cannot express them and silently ignores what it
> cannot express. One of them,
> `bp_digest_window_open_unique`, carries behaviour: without it a burst of
> twenty events flushes as twenty notifications instead of one digest.`better-push migrate` creates everything. If you would rather Prisma owned the
> tables, run `prisma migrate dev` and then
> `psql "$DATABASE_URL" -f prisma/better-push-indexes.sql` - every time you reset
> the database.

## 5. Wire your session [#5-wire-your-session]

```ts title="src/push.ts"
session: async (request) => {
  const token = request.headers.get("authorization")?.replace("Bearer ", "");
  const user = token ? await verifyJwt(token) : null;
  return user ? { userId: user.sub } : null;
},
```

The resolver takes a web-standard `Request`, not Nest's `req` - the bridge
converts one to the other, so nothing here depends on Nest's platform.

> **Info:** Need Nest DI inside the resolver? `forRootAsync` takes `imports` and `inject`
> like any Nest async provider, so the factory can build the instance from
> injected services.

## 6. Check it [#6-check-it]

```bash
npx @better-push/cli doctor
```

`doctor` finds `src/push.module.ts`, confirms the schema, and - crucially for a
Prisma project - reports the three partial indexes as missing if you took the
`prisma migrate` path and skipped the SQL.

## 7. See a notification [#7-see-a-notification]

```ts title="src/push.ts"
import { emulator } from "@better-push/core/providers/emulator";

providers: [
  webPush({ vapid: { /* ... */ } }),
  ...(process.env.NODE_ENV === "production" ? [] : [emulator()]),
],
```

```bash
npx @better-push/cli dev
```

```bash
curl -X POST http://localhost:3000/api/push/devices \
  -H "authorization: Bearer $YOUR_TOKEN" \
  -H "content-type: application/json" \
  -d '{"platform":"android","provider":"emulator","token":"emulator-dev-1"}'
```

```ts
await push.notify({ userId: "alice", title: "Hello", body: "It works." });
```

It appears at `http://127.0.0.1:4984`. If nothing arrives, the `basePath` is the
first thing to check - see step 3.

## More than one instance [#more-than-one-instance]

Each `forRoot`/`forRootAsync` registration owns its own module class and mount,
so registering two instances at two paths works and neither overwrites the
other's routing.

## Where to go next [#where-to-go-next]

- [NestJS integration](/docs/nestjs): forRoot, forRootAsync, and the middleware model in detail.

- [Database adapters](/docs/database-adapters): Prisma's three unexpressible indexes, and what to do about them.

- [Deployment](/docs/deployment): A Nest server can run a worker; here is how.


---

# Next.js walkthrough

> From nothing to a notification you can see, in five commands.

Canonical documentation: /docs/walkthroughs/nextjs



[See it running](https://demo.better-push.com/next/) and inspect [`apps/demos/next`](https://github.com/hesennivas/better-push/tree/main/apps/demos/next).

An existing Next.js app on Postgres, from nothing to a notification you can see.
No VAPID keys to mint by hand, no service worker to write, no device to own, and
no certificate to configure.

You need a Next.js project with a `DATABASE_URL` pointing at a Postgres you can
write to. Everything else is below.

## 1. Scaffold [#1-scaffold]

```bash
npx @better-push/cli init
```

It detects Next.js, your database client, your package manager, whether you use
`src/`, and whether `@/*` maps to it - then shows the plan and waits.

```
Detected Next.js (App Router) · Drizzle ORM · package manager pnpm · source root src

Planned changes
create src/push.ts
create src/app/api/push/[...all]/route.ts
create src/db/better-push-schema.ts
create public/sw.js
env    .env.local (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT, SESSION_SECRET, …)
```

Say yes.

## 2. Install [#2-install]

```bash
pnpm add @better-push/core pg
```

## 3. Create the tables [#3-create-the-tables]

```bash
npx @better-push/cli migrate
```

```
  database  localhost:5432/app

  + bp_audit          + bp_delivery       + bp_device         + bp_digest_window
  + bp_job            + bp_metric_rollup  + bp_migration      + bp_notification
  + bp_preference     + bp_rollup_state   + bp_worker

  11 table(s) created, 24 index(es) created.
```

> **Info:** Want `drizzle-kit` to own these instead? `init` already wrote
> `src/db/better-push-schema.ts`. Re-export it from your schema and run
> `drizzle-kit generate && drizzle-kit migrate` - skip this step entirely.

## 4. Wire your session [#4-wire-your-session]

This is the one thing no tool can do for you. Open `src/push.ts` and replace the
TODO:

```ts title="src/push.ts"
session: async (request) => {
  const session = await auth(request);   // your auth, whatever it is
  return session ? { userId: session.user.id } : null;
},
```

Return `{ userId }` for an authenticated request, `null` otherwise. The router
answers 401 for `null`, and every endpoint is scoped to that user.

Using better-auth? One line:

```ts
import { betterAuthSession } from "@better-push/core/auth/better-auth";

session: betterAuthSession(auth),
```

## 5. Check it [#5-check-it]

```bash
npx @better-push/cli doctor
```

```
  ✓  Framework: Next.js (App Router)
  ✓  The mount file exists
  ✓  public/sw.js exists
  ✓  All 11 better-push tables exist
  ✓  VAPID keys are well-formed
  !  No queue is configured
  !  Nothing is ever deleted

  9 ok  3 warning(s)  0 errors
```

The warnings are folds you have not opened yet, not faults. Zero errors means it
is wired up.

## 6. See a notification [#6-see-a-notification]

Add the emulator provider - two lines, already commented in the file `init`
wrote:

```ts title="src/push.ts"
import { emulator } from "@better-push/core/providers/emulator";

providers: [
  webPush({ vapid: { /* ... */ } }),
  ...(process.env.NODE_ENV === "production" ? [] : [emulator()]),
],
```

Start the inbox in a second terminal:

```bash
npx @better-push/cli dev
```

Register a virtual device from any client component:

```tsx title="src/app/dev-tools.tsx"
"use client";
import { useEmulatorDevice } from "@better-push/core/react";

export function DevTools() {
  const emulator = useEmulatorDevice();
  if (process.env.NODE_ENV === "production") return null;
  return <button onClick={() => void emulator.register()}>Register device</button>;
}
```

Click it, then send from a server action or a route:

```ts
await push.notify({ userId: "your-user-id", title: "Hello", body: "It works." });
```

It appears at `http://127.0.0.1:4984` immediately. &#x2A;*No certificates, no
permission prompt, no device.**

## 7. Real push, and real UI [#7-real-push-and-real-ui]

Now that the pipeline works, turn on the parts that need the browser.

```bash
npx @better-push/cli add notification-bell notification-preferences push-toggle
npx shadcn@latest add badge button popover scroll-area skeleton switch
```

```tsx title="src/app/layout.tsx"
import { NotificationBell } from "@/components/better-push/notification-bell";

<header><NotificationBell /></header>
```

```tsx title="src/app/settings/notifications/page.tsx"
import { PushToggle } from "@/components/better-push/push-toggle";
import { NotificationPreferences } from "@/components/better-push/notification-preferences";

export default function Page() {
  return (
    <>
      <PushToggle applicationServerKey={process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!} />
      <NotificationPreferences />
    </>
  );
}
```

Add `NEXT_PUBLIC_VAPID_PUBLIC_KEY` to `.env.local` with the same value `init`
wrote as `VAPID_PUBLIC_KEY` - the browser needs it and `NEXT_PUBLIC_` is how Next
exposes it.

Click **Enable push notifications**, allow the prompt, and send again. This time
it arrives as a real OS notification as well as in the feed.

## Where to go next [#where-to-go-next]

- [Typed notifications](/docs/typed-notifications): Declare your types once and get checked payloads at every call site.

- [Async delivery](/docs/async-delivery): Move sends off the request path with a queue and a worker.

- [Deployment](/docs/deployment): Which pieces to run on Vercel, Railway, Fly or Docker.

- [Studio](/docs/studio): Every delivery, device, and job, from your own database.


---

# TanStack Start walkthrough

> See the TanStack Start plus Prisma demo running.

Canonical documentation: /docs/walkthroughs/tanstack



[Open the TanStack demo](https://demo.better-push.com/tanstack/) or inspect [`apps/demos/tanstack`](https://github.com/hesennivas/better-push/tree/main/apps/demos/tanstack).

The real Start application commits its generated route tree, mounts splat server handlers for auth, push and Studio, and exercises `@better-push/core/react` below a non-root router base path.
