# Oakbell: push notifications

Send push notifications to browsers (Chrome, Edge, Firefox, Safari, including iPhone home-screen web apps), iPhones and iPads (Apple Push Notification service), and Android (Firebase Cloud Messaging). Register each person's devices under your own user id, then send to that user with one call: Oakstack fans it out to every device, removes devices that are gone, and logs what happened on each.

## Quick start (website)

1. Get an API key at https://oakstack.dev/dashboard/api-keys. Use a **test key** (`ok_test_...`) while building: everything is checked and logged, but nothing reaches a device.
2. Create a web push app once (or click "Add web push" on the dashboard's Push page). Oakstack generates its keys:

```ts
import { Oakstack } from "oakstack";
const oakstack = new Oakstack();

const app = await oakstack.bell.apps.create({
  platform: "web",
  name: "Website",
  subject: "mailto:you@yourapp.com",
});
app.id; // store as OAKSTACK_PUSH_APP_ID
app.config.vapidPublicKey; // goes to the browser (it's public)
```

3. Add a service worker at your site's root, `public/push-sw.js`:

```js
self.addEventListener("push", (event) => {
  const n = event.data ? event.data.json() : {};
  event.waitUntil(
    self.registration.showNotification(n.title || "Notification", {
      body: n.body || "",
      tag: n.tag || undefined,
      data: { url: n.url || "/" },
    }),
  );
});

self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  event.waitUntil(clients.openWindow(event.notification.data.url));
});
```

4. In the browser, after the person clicks something like "Turn on notifications" (browsers block permission prompts that aren't triggered by a click), subscribe and send the subscription to **your own** backend:

```js
async function turnOnNotifications() {
  const reg = await navigator.serviceWorker.register("/push-sw.js");
  const permission = await Notification.requestPermission();
  if (permission !== "granted") return;
  const subscription = await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: VAPID_PUBLIC_KEY, // app.config.vapidPublicKey
  });
  await fetch("/api/push/register", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(subscription.toJSON()),
  });
}
```

5. On your backend, register it under the signed-in user (never put your Oakstack API key in browser code):

```ts
// POST /api/push/register
await oakstack.bell.devices.register({
  appId: process.env.OAKSTACK_PUSH_APP_ID!,
  userId: session.user.id, // your own id for the person
  subscription: await request.json(),
});
```

Registering is safe to repeat: the same subscription refreshes the existing device instead of creating a new one. Doing it on every visit keeps devices current.

6. Send:

```ts
const n = await oakstack.bell.notifications.send({
  userId: "user_42",
  title: "Your order shipped",
  body: "Arriving Thursday.",
  url: "https://yourapp.com/orders/1042",
});
// n.status === "queued" (or "no_devices" if the user has none: not counted)
const detail = await oakstack.bell.notifications.get(n.id); // a delivery per device
```

Or with an agent: the MCP tools `bell_list_apps`, `bell_create_web_app`, `bell_send_notification`, and `bell_get_notification`.

**iPhone and iPad web push** works only for sites added to the Home Screen (Share → Add to Home Screen), on iOS 16.4 and later, and needs a web app manifest (`manifest.json` with `"display": "standalone"`).

## iPhone and iPad apps (APNs)

Apple only lets a team push to its own apps, so you bring your own key, once:

1. In your Apple Developer account: **Certificates, Identifiers & Profiles → Keys → +**, tick **Apple Push Notifications service (APNs)**, and download the `.p8` file (you can only download it once). Note the **Key ID**, and your **Team ID** (top right of the developer site).
2. Add it on the Oakstack dashboard's Push page (Add an iOS app), or with the API:

```ts
await oakstack.bell.apps.create({
  platform: "apns",
  name: "iOS app",
  teamId: "ABCDE12345",
  keyId: "KEY1234567",
  privateKey: fs.readFileSync("AuthKey_KEY1234567.p8", "utf8"),
  bundleId: "com.acme.app",
  environment: "production", // "sandbox" for builds run from Xcode
});
```

One key works for every app in your team; add one Oakstack app per bundle ID. Builds installed from Xcode use the **sandbox** environment, TestFlight and App Store builds use **production**: create one of each if you test from Xcode.

3. In the iOS app, ask for permission, register for remote notifications, and send the device token (as hex) to your backend, which registers it:

```swift
// After UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])
// and UIApplication.shared.registerForRemoteNotifications():
func application(_ app: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
  let token = deviceToken.map { String(format: "%02x", $0) }.joined()
  // POST it to your backend
}
```

```ts
await oakstack.bell.devices.register({ appId: IOS_APP_ID, userId, token });
```

The notification's `url` and `data` arrive in the payload next to `aps` (`userInfo["url"]`, `userInfo["orderId"]`, ...).

## Android apps (FCM)

1. In the Firebase console: **Project settings → Service accounts → Generate new private key**. That downloads a JSON file.
2. Add it on the dashboard's Push page (Add an Android app), or:

```ts
await oakstack.bell.apps.create({
  platform: "fcm",
  name: "Android app",
  serviceAccount: fs.readFileSync("acme-firebase-adminsdk.json", "utf8"),
});
```

3. In the app, get the registration token (`FirebaseMessaging.getInstance().token`) and send it to your backend, which registers it with `oakstack.bell.devices.register({ appId, userId, token })`. Update it in `onNewToken`.

`data` values arrive as strings in the message's data (non-string values are sent as JSON), plus `url` and `notificationId`.

## Test mode

With an `ok_test_` key, notifications are checked, counted, and logged, but nothing reaches a device: each delivery is simulated as delivered a moment later. A device whose token contains `invalid` simulates an expired device (marked invalid). Register test devices with any token, e.g. `token: "test-device-1"` on an FCM app.

## What happens to a notification

1. **Accepted** (`queued`): the target is resolved to the user's active devices (all apps, or only `appId`), and each device counts as one notification. With no active device, it's stored as `no_devices` and not counted.
2. **Delivered per device**: each device is sent separately, usually within seconds. `deliveredCount` and `failedCount` add up as they finish; the notification ends `sent` (all), `partial` (some), or `failed` (none).
3. **Gone devices** (an expired browser subscription, an uninstalled app, a token from another app) are marked `invalid` automatically, skipped from then on, and not counted. They're deleted after 30 days.
4. **Throttling and outages** retry for about 1.5 hours, but never past `ttlSeconds` (default 1 day). A notification that couldn't be delivered isn't counted.
5. **Rejected credentials** (a revoked Apple key, a deleted Firebase service account) fail that app's deliveries, show on the app as `lastError`, and email the workspace's owners. Fix the key with `oakstack.bell.apps.update(id, {...})`.

Schedule up to 30 days ahead with `scheduledAt`, and cancel before then with `oakstack.bell.notifications.cancel(id)`.

Payloads are limited to about 3,500 bytes (title, body, url, and data together): send an id and let the app fetch details if you need more.

## Limits

| Plan    | Notifications a month | Devices | Push apps |
| ------- | --------------------- | ------- | --------- |
| Free    | 1,000                 | 100     | 2         |
| Builder | 50,000                | 10,000  | 10        |
| Studio  | 250,000               | 100,000 | 30        |

Each device a notification goes to counts once. At the limit, sends return `usage_limit_reached` and nothing is sent. At most 1,000 devices per notification (send in batches of users for more).

## Endpoints

| Method | Path                                    | What                                                            |
| ------ | --------------------------------------- | --------------------------------------------------------------- |
| POST   | `/api/v1/bell/apps`                     | add an app (`platform` web, apns, or fcm)                       |
| GET    | `/api/v1/bell/apps`                     | list apps                                                       |
| GET    | `/api/v1/bell/apps/:id`                 | one app                                                         |
| PATCH  | `/api/v1/bell/apps/:id`                 | rename or replace credentials                                   |
| DELETE | `/api/v1/bell/apps/:id`                 | delete an app and its devices                                   |
| POST   | `/api/v1/bell/devices`                  | register `{ appId, userId?, subscription \| token, metadata? }` |
| GET    | `/api/v1/bell/devices`                  | list (`?userId=`, `?appId=`, `?status=`)                        |
| GET    | `/api/v1/bell/devices/:id`              | one device                                                      |
| DELETE | `/api/v1/bell/devices/:id`              | remove one (e.g. on sign-out)                                   |
| POST   | `/api/v1/bell/notifications`            | send                                                            |
| GET    | `/api/v1/bell/notifications`            | list                                                            |
| GET    | `/api/v1/bell/notifications/:id`        | one, with `deliveries`                                          |
| POST   | `/api/v1/bell/notifications/:id/cancel` | cancel a scheduled one                                          |

Adding, changing, and deleting apps and canceling notifications need a Full access key; Restricted keys can register devices and send.

Errors: `invalid_request` (400: the message says which field), `not_allowed` (403), `usage_limit_reached` (429).
