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.json on your app's domain.
  • Generate a P-256 keypair and put the public key in the DID document as a verificationMethod whose 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:

bash
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.

ts
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):

ts
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:

bash
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.

ts
// 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.

ts
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 (see Retry-After).
  • InvalidRequest — malformed body (e.g. bad senderDid).