Developer docs
Any atproto app can ask users to receive notifications via atmo.pub. Users approve in the dashboard; the relay delivers via Telegram.
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 or
via Telegram. 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.
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. 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).
7. 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).