Skip to content

BSKY_PUSH_NOTIFICATION_SERVICE

kindgracekind edited this page Aug 6, 2026 · 1 revision

Written by Claude Fable 5

Push Notification Service (reference implementation)

A reference design for a service implementing the service half of the Bluesky Push Notification Service Spec. The spec is the only coupling to clients — a conforming service can be built from the spec alone and may diverge from everything here. This design targets clients of the Bluesky appview/chat services whose primary form is a browser app (public OAuth clients with browser-held DPoP keys), and is deployable for any such client as configuration, not code changes.

Background

Bluesky's own notification service (courier) is not reusable: it keys device tokens by appId and holds Bluesky's APNs/FCM credentials, with no self-serve onboarding — registering another app id returns 200 and delivers nothing. Every third-party client needs its own service.

The spec deliberately does not constrain the event source. This design uses per-user OAuth polling because a firehose consumer cannot see chat and would require reimplementing preference/mute/block filtering; the price is holding narrow per-user credentials.

Deployment target

A single Cloudflare Worker on its own origin, deployed from its own repo, with its own secrets — decoupled from any client app's deploys and signing keys.

Concern Piece
/.well-known/did.json Worker fetch handler, static JSON
/.well-known/notif-service.json Worker fetch handler, static JSON
OAuth start + callback Worker fetch handler
registerPush / unregisterPush XRPC Worker fetch handler
Subscriptions, devices, cursors D1
Poll scheduling + grant GC Cron Trigger
Polling + sending Queue consumer
VAPID key, token-encryption key, OAuth key Worker secrets

The cron handler is a dispatcher, not the poller: it selects due rows from D1 and enqueues them. A Queue consumer does the work, which buys concurrency, retries, and a dead-letter queue. Polling inline in the cron handler hits subrequest and wall-clock limits once there are more than a few hundred subscribers, and has no retry story.

Event source: per-user OAuth polling

The service is its own OAuth client, with a client_id distinct from the client app's. This is forced, not stylistic: browser-based atproto apps are public OAuth clients whose access tokens are DPoP-bound to a keypair generated and held in the browser, so tokens minted by the app are unusable server-side by construction — and widening the app's own grant to cover the notifier's scopes would grow the app's standing authorization for everyone, wanted or not.

Consequently the user grants a second, separate authorization through the spec's auth handoff. The scope sets by tier:

atproto
rpc:app.bsky.notification.getUnreadCount?aud=<appview-did>#bsky_appview
rpc:app.bsky.notification.listNotifications?aud=<appview-did>#bsky_appview
rpc:chat.bsky.convo.getUnreadCounts?aud=<chat-did>#bsky_chat
rpc:chat.bsky.convo.getLog?aud=<chat-did>#bsky_chat   (previews tier only)

Read-only, and nothing that can post, follow, or message.

Deploy as a confidential client (private_key_jwt: client metadata publishes a JWKS, and the token endpoint is authenticated with a signed client assertion). This is what makes server-held grants practical: confidential clients get a 2-year session and 3-month refresh lifetime, against 2 weeks for public clients (packages/oauth/oauth-provider/src/oauth-constants.ts:42-51 in the atproto reference implementation). A public client would drop every user fortnightly.

The code exchange happens in the Worker, with a Worker-held DPoP key. The callback verifies the grant's sub against the login_hint and echoes the effective chat_previews per the spec.

Storage

CREATE TABLE subscriptions (
  did                TEXT PRIMARY KEY,
  app_id             TEXT NOT NULL,
  pds_url            TEXT NOT NULL,
  access_token       BLOB NOT NULL,   -- envelope-encrypted
  refresh_token      BLOB NOT NULL,   -- envelope-encrypted
  dpop_jwk           BLOB NOT NULL,   -- envelope-encrypted
  access_expires_at  INTEGER NOT NULL,
  last_notif_count   INTEGER NOT NULL DEFAULT 0,
  last_chat_count    INTEGER NOT NULL DEFAULT 0,
  chat_previews      INTEGER NOT NULL DEFAULT 0,
  chat_log_cursor    TEXT,
  last_active_at     INTEGER,
  next_poll_at       INTEGER NOT NULL,
  failure_count      INTEGER NOT NULL DEFAULT 0,
  gc_at              INTEGER           -- set when device count hits 0
);

CREATE TABLE devices (
  id         TEXT PRIMARY KEY,        -- token, or hash(endpoint)
  did        TEXT NOT NULL REFERENCES subscriptions(did) ON DELETE CASCADE,
  app_id     TEXT NOT NULL,
  kind       TEXT NOT NULL,           -- 'webpush' | 'apns'
  payload    BLOB NOT NULL,           -- encrypted; shape depends on kind
  created_at INTEGER NOT NULL
);

CREATE INDEX devices_did ON devices(did);
CREATE INDEX subscriptions_due ON subscriptions(next_poll_at);

CREATE TABLE sent (
  did        TEXT NOT NULL,
  uri        TEXT NOT NULL,           -- notification/message URI
  sent_at    INTEGER NOT NULL,
  PRIMARY KEY (did, uri)
);

kind matters because the two transports store different things: APNs is an opaque token string, Web Push is {endpoint, p256dh, auth}. Keying devices on the token/endpoint hash rather than a synthetic id makes re-subscription idempotent, per the spec.

sent is the dedupe ledger. Notifications are deduped on URI, not count delta — counts are unreliable as an identity signal, and a count that decreases means the user read something elsewhere, which must resync state without notifying. Prune rows older than a few days.

Tokens are envelope-encrypted with AES-GCM under a key from a Worker secret. D1 rows are otherwise plaintext to anything holding the binding.

Session lifecycle

Implements the spec's grant-lifecycle expectations:

  • Zero devices (last unregisterPush, or last device pruned on 404/410) stops polling immediately — pushes would go nowhere — and sets gc_at to now plus the grace window. A device registration clears gc_at and resumes polling. When gc_at passes, the cron dispatcher revokes the OAuth grant against the PDS and deletes the row.
  • An invalid_grant during polling (expired, or revoked by the user at their PDS) deletes the row — the user must re-authorize.

Poll cycle

Every minute, the cron handler selects rows where next_poll_at <= now, in bounded batches, and enqueues them. Per user, the consumer:

  1. Refreshes the access token if within the expiry margin. Write back with a conditional update (WHERE refresh_token = <the value read>) so a lost race is a no-op retry rather than a clobber.
  2. Calls getUnreadCount and getUnreadCounts. These are cheap; do not call listNotifications on every poll.
  3. If the notification count went up, fetch the list, take entries newer than the stored watermark, drop any already in sent, and enqueue sends. If a chat count went up: on the previews tier, page chat.bsky.convo.getLog from chat_log_cursor, take new message events, drop any already in sent, and enqueue sends carrying sender and preview, advancing the cursor; on the counts tier, send a single collapsed "you have new messages" push (stable tag, per the spec). The counts path is also the degradation path when getLog fails, so it exists regardless of tier.
  4. If a count went down or stayed flat, update the stored counts and send nothing.
  5. Compute next_poll_at from last_active_at and reset failure_count; on failure, back off exponentially and drop the row after a threshold.

Adaptive cadence is a correctness requirement, not an optimization. Poll load falls on other people's PDSes: 1,000 users at a flat 60s cadence is 1.44M requests/day, almost all of them returning nothing. Tighten to ~30–60s for users active in the last hour, degrade to several minutes when idle. The whole policy lives in how next_poll_at is computed, so it is one function to tune.

Delivery

Web Push per the spec's payload format: RFC 8291 aes128gcm against the subscription's keys, VAPID-signed. 404/410 deletes the device row.

APNs covers native iOS wrappers (e.g. Capacitor), using token-based auth (a .p8 key signed into a JWT). Needed because iOS web push requires the user to install the PWA to the Home Screen (16.4+), which most users will not do.

Badges ride each send: the unread count the poller already holds (badge in the Web Push payload, aps.badge on APNs). Best-effort by design — the client clears on app open, and reads on other devices cannot clear a badge until the next push arrives.

Notification copy is the one piece likely to need real customization per deploying client; keep it in a single module rather than scattered through the sender.

Configuration for deployers

Everything client-specific is configuration:

Setting Notes
SERVICE_DID did:web: of the deployment's own origin
SERVICE_NAME shown in client settings and consent copy
APP_IDS app id(s) the deployment accepts registrations for
OAUTH_CLIENT_ID URL of the deployment's client metadata
OAUTH_PRIVATE_JWK secret; confidential-client signing key
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY secret
APNS_KEY_ID / APNS_TEAM_ID / APNS_PRIVATE_KEY / APNS_TOPIC secret; optional
TOKEN_ENCRYPTION_KEY secret
GRACE_WINDOW_DAYS grant GC delay after last device unregisters

A deployment with no APNs config serves Web Push only. app_id is carried in the schema rather than assumed, because one service can serve several apps.

Security notes

Beyond the spec's requirements:

  • Encrypt tokens at rest; rotate TOKEN_ENCRYPTION_KEY by re-encrypting rows, not by invalidating grants.
  • Compromise of this service exposes notification metadata, unread counts, and (previews tier) chat message content for subscribed users, and allows sending arbitrary pushes to their devices. It does not allow acting as them — the grant cannot post, follow, or message.

Clone this wiki locally