Developer docs
Any atproto app can ask users to receive notifications via atmo.pub. Users approve in the dashboard and choose where alerts go — web push, Telegram, email, Bluesky DM, or a webhook — while everything also lands in their inbox.
Two endpoints, two auth mechanisms. requestPermission proves the user authorized this request (user OAuth); send proves the sender identity (your app's own DID key).
Prefer a working example? A complete, ~300-line app wiring up both flows is live at example.atmo.pub — try it, then read the source ↗.
1. Get a DID for your app
Needed for send. The simplest option is did:web:
- Host
/.well-known/did.jsonon your app's domain. - Generate a P-256 keypair and put the public key in the DID document as a
verificationMethodwhose id ends in#atproto. - Reference: atproto DID spec ↗
2. Request permission (user OAuth)
The user signs into your app via atproto OAuth. Add just the requestPermission method to your app's OAuth scope — send uses your app's own key, not the user's session,
so it doesn't belong here:
atproto rpc?lxm=pub.atmo.notify.requestPermission&aud=*
Then mint a service-auth JWT on the user's PDS via com.atproto.server.getServiceAuth and call:
curl -X POST https://relay.atmo.pub/xrpc/pub.atmo.notify.requestPermission \
-H "Authorization: Bearer $USER_JWT" \
-H "Content-Type: application/json" \
-d '{
"senderDid": "did:web:yourapp.example",
"title": "Bookhive",
"description": "New comments on your books"
}'Returns { id, status } (pending or alreadyGranted). The user approves in their dashboard. title ≤ 50 chars, description ≤ 200 chars, optional iconUrl.
3. Send a notification (your app's key)
Once granted, sign with your app's own key (no user involved) and send. Field limits: title ≤ 100, body ≤ 500, optional uri and threadKey. Optional category (≤ 64) tags the notification so the user can
route that category separately; categoryDescription labels
it in their settings. The response is { id, delivered } — delivered: 0 is normal (saved to the inbox, but the user
may have muted you, routed you to “off”, or connected no channels).
import { createServiceJwt } from '@atcute/xrpc-server/auth';
// Signed with YOUR app's key — this proves the sender identity.
const jwt = await createServiceJwt({
keypair: yourKeypair,
issuer: 'did:web:yourapp.example',
audience: 'did:web:relay.atmo.pub',
lxm: 'pub.atmo.notify.send'
});Easiest with @atcute/client (pass the JWT per call):
import { Client, simpleFetchHandler } from '@atcute/client';
const client = new Client({
handler: simpleFetchHandler({ service: 'https://relay.atmo.pub' })
});
await client.post('pub.atmo.notify.send', {
headers: { authorization: `Bearer ${jwt}` },
input: {
recipient: 'did:plc:recipient',
title: 'New reply',
body: 'alice replied to your post',
uri: 'https://yourapp.example/thread/123'
}
});…or any HTTP client:
curl -X POST https://relay.atmo.pub/xrpc/pub.atmo.notify.send \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"recipient": "did:plc:recipient",
"title": "New reply",
"body": "alice replied to your post",
"uri": "https://yourapp.example/thread/123"
}'4. Cross-app login (optional)
Let users jump from your app into atmo.pub already signed in, with no login form — handy for a “Configure notifications” link that lands on your app's settings page. Your app mints a single-use, short-lived identity token on the user's PDS; atmo.pub verifies the signature against their DID and starts a session. It's an identity proof only — it grants no access to the user's data.
// 1. Add to your app's OAuth scope: rpc?lxm=pub.atmo.auth&aud=*
// 2. Mint a single-use, ~60s identity token on the signed-in user's PDS:
const { data } = await client.get('com.atproto.server.getServiceAuth', {
params: { aud: 'did:web:relay.atmo.pub', lxm: 'pub.atmo.auth' }
});
// 3. Open atmo.pub already signed in — deep-link to YOUR app's settings page.
// (Open the tab on the click first to keep the user gesture.)
const url = `https://atmo.pub/applogin?token=${encodeURIComponent(data.token)}`
+ `&redirect=${encodeURIComponent('/apps/did:web:yourapp.example')}`;
window.open(url);The token is single-use and expires in ~60s; redirect must be a relative path. It's a bearer link, so don't log it.
5. Let users enable you from atmo.pub (optional)
Users can turn on notifications for your app from inside atmo.pub — without visiting your
app first. When they do, the relay POSTs a relay-signed subscriberChanged callback to /xrpc/pub.atmo.notify.subscriberChanged on your service: { recipient, enabled, changedAt }. Verify it's
really the relay, then start (or stop) sending to that user with send.
import { ServiceJwtVerifier } from '@atcute/xrpc-server/auth';
import { CompositeDidDocumentResolver, PlcDidDocumentResolver,
WebDidDocumentResolver } from '@atcute/identity-resolver';
// Endpoint the relay POSTs when a user enables/disables you from atmo.pub:
// POST /xrpc/pub.atmo.notify.subscriberChanged { recipient, enabled, changedAt }
const verifier = new ServiceJwtVerifier({
acceptAudiences: ['did:web:yourapp.example'], // tokens addressed to you
resolver: new CompositeDidDocumentResolver({ methods: {
plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver() } })
});
const { issuer } = await verifier.verifyRequest(request, {
lxm: 'pub.atmo.notify.subscriberChanged'
});
// CRITICAL: only the relay may tell you this — a valid signature isn't enough.
if (issuer !== 'did:web:relay.atmo.pub') throw new Error('issuer is not the relay');
const { recipient, enabled } = await request.json();
// enabled ? start sending to `recipient` via send : stop.You trust the relay's word that the user consented (the same trust you already place in it to deliver). Callbacks are idempotent state, so safe to retry; treat them as such. Apps are curated for now — get in touch to be listed.
6. Let users tune your app (optional)
If the user grants your app “manage its own settings”, it can read and adjust how its own notifications reach them — over dual-auth XRPC
(your app token in the header + a fresh user token in the body userToken, both scoped to the method). These only ever
touch your app's slice for that user.
getRouting/setRouting— read the user's routable targets (to render a picker) and set which channels your notifications use, app-wide and per-category.getCategories/setCategories/addCategory/removeCategory— declare your categories up front (e.g. one per webhook the user configures), each with a display title, so they show in routing before the first send.listNotifications/markRead,muteSelf,revokeSelf— read/ack your inbox slice; mute or remove yourself.
A route is a +-joined set
of channel tokens (push, telegram, email, dm, webhook; or channel:<id> for one specific device/chat), plus off, inbox (inbox
only), and the inherit sentinels default / app. Full method reference: the LLM integration guide or each lexicon
at /lexicons/<nsid>.
7. Rate limits
- At most 1 outstanding pending request per (sender, recipient).
requestPermission: 50 / hour per recipient and 100 / hour per sender.send: 1 / second and 100 / day per (sender, recipient).
8. Error handling
Common XRPC errors:
AuthenticationRequired— missing/invalid JWT.NotAuthorized— no active grant for this recipient.RateLimitExceeded— slow down (seeRetry-After).InvalidRequest— malformed body (e.g. badsenderDid).