courier #
A Bluesky Push Notification Service — the server half of real push notifications for a third-party Bluesky client.
Bluesky's own notification service is not reusable by third-party clients: 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; this is one.
Holds a narrow, read-only OAuth grant per subscriber, watches their unread
counts, and sends a Web Push (or APNs) notification when something happens. The
grant cannot post, follow, or message. With JETSTREAM_URL set, likes,
replies, mentions, quotes, reposts and follows arrive within seconds: the
network's own event stream says when to look. Chat has no event stream, so it
is polled, one request per cycle when nothing changed.
Forked from courierbench's reference implementation, which is built to calibrate a conformance suite rather than to be deployed. See DESIGN.md for what changed and why, what this can carry, and what to do when it cannot carry any more.
Passes 75/75 core and 9/9 bonus cases of the courierbench conformance suite.
Running it #
nix develop # or bring your own Rust toolchain
cargo build --release
Everything is configured from the environment; there is no config file.
Required #
| Variable | Notes |
|---|---|
PORT |
binds 127.0.0.1, so put a TLS terminator in front |
DATA_DIR |
holds courier.db |
SERVICE_DID |
did:web: of this deployment's origin |
SERVICE_NAME |
shown in client settings and consent copy |
APP_IDS |
comma-separated app ids to accept registrations for |
PLC_DIRECTORY_URL |
https://plc.directory. did:plc: only — a did:web: identity is fetched from its own origin and never touches this |
BSKY_APPVIEW_DID |
did:web:api.bsky.app |
BSKY_CHAT_DID |
did:web:api.bsky.chat |
OAUTH_CLIENT_ID |
URL of this deployment's client metadata, on its own origin |
OAUTH_PRIVATE_JWK |
secret; P-256 private JWK for private_key_jwt |
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY |
secret; P-256 keypair, base64url |
VAPID_SUBJECT |
mailto: or https: contact |
TOKEN_ENCRYPTION_KEY |
secret; 32 random bytes, base64url |
POLL_ACTIVE_MS |
poll cadence for recently-active accounts (e.g. 10000); with JETSTREAM_URL set this is the chat cadence |
POLL_IDLE_MS |
cadence for everyone else (e.g. 60000) |
POLL_ACTIVE_WINDOW_MS |
how long activity counts as recent (e.g. 300000) |
GRACE_WINDOW_MS |
grant retention after the last device unregisters |
MAX_POLL_FAILURES |
consecutive failures before a grant is dropped |
Optional #
| Variable | Default | Notes |
|---|---|---|
POLL_CONCURRENCY |
32 |
accounts polled at once — see DESIGN.md for sizing |
POLL_LEASE_MS |
60000 |
upper bound on how long a hung poll strands its row |
SENT_RETENTION_MS |
3 days | how long the dedupe ledger remembers a delivery |
PRUNE_INTERVAL_MS |
1 hour | retention sweep interval |
JETSTREAM_URL |
— | Jetstream v2 subscribeEvents endpoint, e.g. wss://jetstream.us-east.bsky.network/xrpc/network.bsky.jetstream.subscribeEvents. Set, app notifications are checked when the network says something touched a subscriber (seconds) instead of on every poll; unset, every poll checks both counts — see DESIGN.md |
NOTIF_SAFETY_NET_MS |
10 minutes | with the lane up, the longest an account goes without a notification check |
LIST_ACTIVITY_SUBSCRIPTIONS |
off | 1 asks grants for listActivitySubscriptions (read-only), so a post by anyone a subscriber gets subscribed-post notifications for is hinted from their exact list, re-read every 10 minutes. Beyond the spec's scope set (its conformance suite's T2.07 fails with it on); existing grants are re-authorized on the client's next startUrl walk |
CHAT_FRESH_WINDOW_MS |
12 hours | how recent a chat message must be for a previews-tier push, so a stale message from an unrelated conversation can't surface as a wrong-chat banner |
APNS_BASE_URL, APNS_KEY_ID, APNS_TEAM_ID, APNS_PRIVATE_JWK, APNS_TOPIC |
— | omit for Web Push only |
None of the secrets ship with the repo. Generate your own:
# 32-byte keys, base64url, no padding
openssl rand 32 | basenc --base64url | tr -d '='
A did:web document #
SERVICE_DID must resolve to a DID document with a #bsky_notif service entry
pointing at this origin — served at https://<host>/.well-known/did.json, with
permissive CORS, since browsers fetch it cross-origin:
{
"id": "did:web:notifs.example.com",
"service": [
{
"id": "#bsky_notif",
"type": "BskyNotificationService",
"serviceEndpoint": "https://notifs.example.com"
}
]
}
Docker #
cp .env.example .env # fill in secrets; keep it chmod 600
docker compose up -d --build
ca-certificates is not optional in the image: without a trust store every
outbound HTTPS call (PLC directory, appview, chat, subscribers' PDSes) fails
TLS verification.
Migrating from the reference implementation #
Point DATA_DIR at the existing directory and start. An existing state.json
is imported into SQLite on first run and renamed to state.json.imported;
grants, devices, cursors and the dedupe ledger all carry over, so nobody has to
re-authorize. The import runs only when the database is empty.
Operating it #
GET /status is unauthenticated and aggregate-only — no per-account detail.
The number to watch is schedulingLagMaxMs: flat is healthy, monotonically
rising means poll throughput has stopped keeping up. With the lane on,
jetstreamOnline and jetstreamLagMs say whether it is up and how far behind
the network it runs; safetyNetHits counts notifications no hint announced,
chaseExhausted hinted records no listing showed within 2 minutes, and
replyChainHints replies found only through a recorded reply chain. See
DESIGN.md.
cargo run --release --bin loadgen -- --subscribers 5000 --latency-ms 200
drives the real poll loop against a stub PDS and prints scheduling lag over
time, which is the way to size POLL_CONCURRENCY before a surge rather than
during one.
Conformance #
cargo run -p cb-suite --bin score -- \
--courier-cmd /path/to/courier/target/release/courier
from a courierbench checkout.
Security #
- Credentials are envelope-encrypted at rest under
TOKEN_ENCRYPTION_KEY. Rotate by re-encrypting rows, not by invalidating grants. - Compromise of this service exposes notification metadata, unread counts, and — on the 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.
/statusis public by design and must stay aggregate-only.
Licence #
MIT.