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)
- 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. - Create a web push app once (or click "Add web push" on the dashboard's Push page). Oakstack generates its keys:
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)
- Add a service worker at your site's root,
public/push-sw.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));
});
- 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:
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()),
});
}
- On your backend, register it under the signed-in user (never put your Oakstack API key in browser code):
// 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.
- Send:
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:
- In your Apple Developer account: Certificates, Identifiers & Profiles → Keys → +, tick Apple Push Notifications service (APNs), and download the
.p8file (you can only download it once). Note the Key ID, and your Team ID (top right of the developer site). - Add it on the Oakstack dashboard's Push page (Add an iOS app), or with the API:
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.
- In the iOS app, ask for permission, register for remote notifications, and send the device token (as hex) to your backend, which registers it:
// 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
}
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)
- In the Firebase console: Project settings → Service accounts → Generate new private key. That downloads a JSON file.
- Add it on the dashboard's Push page (Add an Android app), or:
await oakstack.bell.apps.create({
platform: "fcm",
name: "Android app",
serviceAccount: fs.readFileSync("acme-firebase-adminsdk.json", "utf8"),
});
- In the app, get the registration token (
FirebaseMessaging.getInstance().token) and send it to your backend, which registers it withoakstack.bell.devices.register({ appId, userId, token }). Update it inonNewToken.
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
- Accepted (
queued): the target is resolved to the user's active devices (all apps, or onlyappId), and each device counts as one notification. With no active device, it's stored asno_devicesand not counted. - Delivered per device: each device is sent separately, usually within seconds.
deliveredCountandfailedCountadd up as they finish; the notification endssent(all),partial(some), orfailed(none). - Gone devices (an expired browser subscription, an uninstalled app, a token from another app) are marked
invalidautomatically, skipped from then on, and not counted. They're deleted after 30 days. - 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. - 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 withoakstack.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).