Service-auth cookbook
Concrete service-auth recipes for app calls, buyer assertions, XRPC receivers, ticket holds, and replay protection.
Compatible with the closed-beta ATM app APIs and versioned ATM event headers. Check atm-api-version on every webhook or XRPC receiver event.
Which token to use
ATM uses short-lived service-auth in two directions. The app proves its own DID when it calls ATM. The app may also include a user assertion when it is acting for a buyer or organizer who is already signed into the app.
| App calls ATM | Use app service-auth. iss is the app DID and lxm is the exact ATM method. |
|---|---|
| Buyer present in app | Include buyerAssertionJwt beside buyerDid inside private checkout or ticket requests. |
| Organizer configures tickets | Include organizerAssertionJwt when the app creates or changes ticket tiers for an organizer. |
| ATM calls app XRPC receiver | ATM sends service-auth to the app; the app verifies ATM as caller. |
| HTTP webhook | Use raw-body HMAC signature verification, not service-auth. |
App service-auth
App service-auth protects app fee attribution, environment access, webhook routing, module permissions, and app-visible payment rows. Do not trust an app DID supplied only as a URL parameter.
const token = await getServiceAuth({
iss: appDid,
aud: "did:plc:atm-broker#AttestedNetwork",
lxm: "network.attested.payment.initiate",
exp: Math.floor(Date.now() / 1000) + 60,
jti: crypto.randomUUID()
});| Field | Type | Required | Description |
|---|---|---|---|
| iss | did | yes | Registered app DID. |
| aud | did#service | yes | ATM broker or method-specific service audience. |
| lxm | nsid | yes | Exact XRPC method being called. |
| exp | integer | yes | Short expiry, usually about one minute. |
| jti | string | yes | Unique token id used for replay protection. |
Buyer assertion
A buyer assertion is a low-friction proof that the app saw the signed-in buyer for this action. It is not an OAuth grant and does not let ATM write to the buyer's PDS.
const buyerAssertionJwt = await getServiceAuth({
iss: buyerDid,
aud: "did:plc:atm-broker#AttestedNetwork",
lxm: "money.atmosphere.payment.assertPayer",
exp: Math.floor(Date.now() / 1000) + 60,
jti: crypto.randomUUID()
});
const checkoutEnvelope = {
payerDid: buyerDid,
buyerAssertionJwt,
returnUrl,
cancelUrl,
metadata: { appOrderId }
};Organizer assertion
Ticketing apps may need to prove that an organizer authorized ticket tier or capacity changes inside the app. Use the same pattern, but scope the assertion to the ticket method being called.
const organizerAssertionJwt = await getServiceAuth({
iss: organizerDid,
aud: "did:plc:atm-broker#Tickets",
lxm: "tickets.atmosphere.createTicketTier",
exp: Math.floor(Date.now() / 1000) + 60,
jti: crypto.randomUUID()
});ATM receiver callback
XRPC receiver callbacks are optional. They are useful for apps that already expose AT Protocol service surfaces and want ATM delivery to match the rest of their app architecture.
import {
createAtmXrpcReceiverAudience,
} from "@atmosphere-money/app-node";
export async function receiveAtmEvent(request: Request) {
const expectedAudience = createAtmXrpcReceiverAudience(appDid);
await verifyServiceAuth({
header: request.headers.get("authorization"),
iss: "did:plc:atm-broker",
aud: expectedAudience,
lxm: "money.atmosphere.event.receive"
});
const event = await request.json();
await fulfillIdempotently(event.deliveryId, event);
return Response.json({ ok: true });
}Replay protection
- Use short expiries for every service-auth JWT.
- Store jti values for the replay window before performing side effects.
- Reject a token if iss, aud, lxm, exp, kid, signature, or jti is wrong.
- Use one token for one request; do not reuse buyer assertions across checkout attempts.
- Deduplicate app events by deliveryId and business object id.
Common failures
| Code | HTTP | Retry | Cause | Developer action |
|---|---|---|---|---|
| AppUnauthorized | 401 | no | The app DID is not registered, disabled, or not enabled for the requested module. | Check app registration and environment module settings. |
| InvalidAudience | 401 | no | The JWT aud does not match ATM's expected service audience. | Mint a fresh token with the exact documented audience. |
| InvalidMethod | 401 | no | The JWT lxm does not match the XRPC method being called. | Use the exact method NSID in lxm. |
| TokenReplay | 409 | no | The jti has already been seen. | Mint a fresh token per request and check retry logic. |
| BuyerAssertionInvalid | 401 | no | The buyer assertion issuer, audience, method, expiry, or signature failed. | Mint a fresh buyer assertion from the signed-in buyer context. |