-
Notifications
You must be signed in to change notification settings - Fork 10
BSKY_PUSH_NOTIFICATION_SERVICE_SPEC
Written by Claude Fable 5
The normative interface between a Bluesky client (an app built on the
app.bsky.* appview and chat.bsky.* chat services) and a push
notification service covering that surface. A reference service
design exists.
"MUST"/"SHOULD" are used informally.
A service is a DID whose document contains:
{
"service": [
{
"id": "#bsky_notif",
"type": "BskyNotificationService",
"serviceEndpoint": "https://notifs.example.com"
}
]
}This is the exact shape PDS request forwarding already resolves
(packages/common-web/src/did-doc.ts:80). did:web on the service's
own origin is the expected common case, but any DID method that can
publish the entry works. Everything below is served from the
serviceEndpoint origin.
GET <serviceEndpoint>/.well-known/notif-service.json
Unauthenticated. MUST be served with permissive CORS
(Access-Control-Allow-Origin: *) — clients fetch it cross-origin
from the browser.
{
"name": "Example Notifs",
"vapidPublicKey": "...",
"authUrl": "https://notifs.example.com/oauth/start"
}-
name— label for client settings and consent copy. -
vapidPublicKey— the service's VAPID public key (base64url-encoded uncompressed P-256 point, as accepted bypushManager.subscribe()'sapplicationServerKey). Web Push subscriptions are bound to this key at creation time, so rotating it invalidates existing subscriptions. -
authUrl— entry point of the auth handoff. Absent for an unauthed service (e.g. a firehose consumer): clients then skip the auth handoff entirely. Unauthed services cannot deliver chat.
Clients ignore unknown fields, so the document can grow new fields without breaking older clients.
Clients assume every service supports the full notification surface (app notifications and chat). A capabilities descriptor can be added to this document later if that stops holding.
Applies only when the config document has an authUrl. The service
is its own OAuth client against the user's PDS; the client hands the
browser to it and receives it back:
- The client navigates to
authUrlwith query parameters:-
login_hint— the user's handle or DID -
return_url— where to redirect when done -
chat_previews—1or0; whether the grant should include the message-content scope (see "Grant tiers")
-
- The service runs the standard atproto OAuth flow under its own
client_idand stores the grant server-side. - The service MUST verify the completed grant's
submatches the hinted DID before storing it. - The service redirects to
return_url, appending standard OAuth callback parameters on failure (error,error_description). Noerrorparameter means success; on success the redirect MUST also echo the grant's effectivechat_previewsvalue, which the client persists as the confirmed tier.
startUrl MUST be idempotent and tier-aware: if the service already
holds a live grant for that DID at the requested tier, redirect
straight back success without touching the PDS; a tier mismatch runs
re-authorization for the new scope set.
The grant is account-level — one per DID per service — so a tier change from any device changes it for all of that account's devices. Both tiers are mandatory for conformant services; there is no per-service tier discovery.
-
Counts tier (
chat_previews=0): read-only scopes covering notification lists and unread counts. Nothing that can post, follow, message, or read message content. Chat pushes are generic and collapsed. -
Previews tier (
chat_previews=1): adds the chat log scope (chat.bsky.convo.getLog) so chat pushes carry sender and a message preview. This lets the service read message content; client consent copy MUST disclose that.
Grant lifecycle expectations:
- When an account has zero registered devices, the service MUST stop
polling/sending immediately and SHOULD revoke and delete the grant
after a grace window (~30 days is recommended). A device
registration during the window resumes service without re-consent
(via the idempotent
startUrlwalk). - An
invalid_grantfrom the PDS means the grant is dead (expired, or revoked by the user at their PDS): delete it and all stored state for that DID; the user re-authorizes throughstartUrl. PDS-side revocation is the immediate account-wide teardown path.
Registration uses the standard PDS-forwarded XRPC procedures, which
the service MUST implement at its serviceEndpoint:
app.bsky.notification.registerPush
app.bsky.notification.unregisterPush
The PDS forwards these with a service-auth JWT. The service MUST
verify it: signature against the caller's DID keys (iss is the user
DID), aud equal to the service's own DID, and lxm equal to the
called method. This JWT is the sole authentication — it is what makes
registration sound for unauthed services too, since nobody can
register a device against a DID they do not control.
Token formats by platform:
-
web—tokenis the serializedPushSubscriptionJSON:{"endpoint": ..., "keys": {"p256dh": ..., "auth": ...}}. -
ios—tokenis the opaque APNs device token string.
Registration MUST be an idempotent upsert keyed on (DID, token).
Clients re-assert registration on every launch — there is no API to
query registration state, and this is the self-healing mechanism for
rotated or lost tokens. unregisterPush deletes the single matching
device row.
appId identifies the client app and MUST be stored per device, not
assumed, so one service can serve several apps.
OAuth-authenticated clients need the RPC scope
rpc:app.bsky.notification.registerPush?aud=<serviceDid>#bsky_notif
(and the unregisterPush equivalent) — the PDS asserts exactly this
(packages/pds/src/api/app/bsky/notification/registerPush.ts:29).
App-password clients skip the check.
Web Push payloads are encrypted per RFC 8291 (aes128gcm) against
the subscription's p256dh/auth keys and signed with the service's
VAPID key. The plaintext is JSON:
{
"title": "Alice replied",
"body": "sounds good, see you then",
"url": "/profile/alice.example/post/3kabc",
"badge": 3,
"tag": "chat"
}-
title,body— required; the notification text. -
url— required; deeplink, path-relative to the client app's origin. The client's service worker resolves it against its own origin (the service does not know or care where the client is hosted). -
badge— optional; total unread count at send time. Best-effort: reads on other devices cannot update it until the next push. -
tag— optional collapse key: a new notification with the same tag replaces the previous one. Counts-tier chat pushes MUST use a stable tag so an unread pile-up is one notification, not a stack.
APNs payloads map the same fields: title/body into
aps.alert, badge into aps.badge, tag into
apns-collapse-id, and url as a custom key.
A 404 or 410 from a Web Push endpoint means the subscription is
dead: the service MUST delete the device row (this is the only
reliable unsubscribe signal, and it feeds the zero-device grant
lifecycle above). Other failures are retried with backoff.
- Verify
audandlxmon every service-auth JWT. Without theaudcheck, a JWT minted for another notification service is replayable here. - Verify
subagainstlogin_hintin the auth callback. - Grants are read-only by scope; the previews tier can read chat message content and clients must disclose that at consent.
- Web Push payload encryption is mandatory (RFC 8291) and is doing real work: payloads can carry message previews.
- Credentials held for users (tokens, subscription keys) SHOULD be encrypted at rest; compromise of a service exposes notification metadata, unread counts, and (previews tier) chat content, and allows sending arbitrary pushes — but never acting as the user.