Helper snippets
Copy-paste server helpers for webhooks, service-auth, buyer assertions, checkout, Tickets, and idempotent fulfillment.
Compatible with the closed-beta ATM app APIs and versioned ATM event headers. Check atm-api-version on every webhook or XRPC receiver event.
How to use these
These snippets are intentionally small and server-side. Treat them as building blocks for a real SDK, not as browser code. Replace the token-minting stubs with your AT Protocol auth stack.
Verify ATM webhook
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyAtmWebhook(input: {
rawBody: string;
signatureHeader: string | null;
deliveryId: string | null;
secret: string;
}) {
if (!input.signatureHeader) throw new Error("Missing Atm-Signature");
if (!input.deliveryId) throw new Error("Missing Atm-Delivery-Id");
const fields = new Map<string, string[]>();
for (const piece of input.signatureHeader.split(",")) {
const index = piece.indexOf("=");
if (index < 0) continue;
const key = piece.slice(0, index).trim().toLowerCase();
const value = piece.slice(index + 1).trim();
const values = fields.get(key) ?? [];
values.push(value);
fields.set(key, values);
}
const timestamp = fields.get("t")?.[0];
const candidates = fields.get("v1") ?? [];
if (!timestamp || candidates.length === 0) {
throw new Error("Malformed Atm-Signature");
}
const signedPayload =
timestamp + "." + input.deliveryId + "." + input.rawBody;
const expected = createHmac("sha256", input.secret)
.update(signedPayload)
.digest("hex");
const expectedBuffer = Buffer.from(expected, "hex");
const dummyBuffer = Buffer.alloc(expectedBuffer.length);
let matched = false;
for (const candidate of candidates) {
const wellFormed = /^[0-9a-fA-F]{64}$/.test(candidate);
const candidateBuffer = wellFormed
? Buffer.from(candidate, "hex")
: dummyBuffer;
const ok = timingSafeEqual(expectedBuffer, candidateBuffer) && wellFormed;
matched = ok || matched;
}
if (!matched) throw new Error("Invalid ATM signature");
}Webhook route helper
Use the middleware helper when your runtime exposes the standard Web Request object, including Next route handlers, Hono, and many Node runtimes. Drop to the lower-level verifier when you need custom raw-body plumbing.
import { createNodeWebhookHandler } from "@atmosphere-money/app-node";
export const POST = createNodeWebhookHandler({
secret: process.env.ATM_WEBHOOK_SECRET!,
expectedType: "payment.completed",
deliveryStore: {
claim: claimWebhookDelivery,
complete: completeWebhookDelivery,
release: releaseWebhookDelivery
},
onEvent: async (event) => {
const metadata = event.data.payment.metadata as
| { appOrderId?: string }
| undefined;
const appOrderId = String(metadata?.appOrderId ?? "");
if (!appOrderId) return { status: 422, body: { error: "MissingAppOrderId" } };
await markOrderPaid(appOrderId, event.data.payment.id);
return { body: { ok: true } };
}
});Receive XRPC events
import { constructAtmXrpcReceiverEvent } from "@atmosphere-money/app-node";
export async function receiveAtmEvent(request: Request) {
const event = await constructAtmXrpcReceiverEvent({
appDid: "did:plc:your-app",
rawBody: await request.text(),
headers: {
authorization: request.headers.get("authorization"),
deliveryId: request.headers.get("atm-delivery-id")
},
// Bring your own JWT verifier (for example @atproto/xrpc-server); the
// helper enforces iss = ATM's broker DID, your #AtmEventReceiver
// audience, and lxm = money.atmosphere.event.receive on top of it.
verifyServiceAuthJwt
});
const claim = await claimWebhookDelivery(event.id);
if (claim.status === "completed") {
return Response.json({ ok: true, duplicate: true });
}
if (claim.status === "busy") {
return Response.json(
{ error: "AtmEventDeliveryBusy" },
{ status: 503, headers: { "retry-after": "1" } }
);
}
try {
await fulfillPaidOrder(event.data);
await completeWebhookDelivery(event.id, claim.claimId);
return Response.json({ ok: true });
} catch (error) {
await releaseWebhookDelivery(event.id, claim.claimId);
throw error;
}
}Mint app service-auth
The exact function depends on your AT Protocol auth implementation. The important part is to mint a short-lived token for the exact ATM method being called.
async function mintAtmServiceAuth(lxm: string) {
return await getServiceAuth({
iss: appDid,
// ATM_BROKER_SERVICE_AUDIENCE in @atmosphere-money/app-node
aud: "did:plc:7srqsetux75b6flzbbyag2ro#AttestedNetwork",
lxm,
exp: Math.floor(Date.now() / 1000) + 60,
jti: crypto.randomUUID()
});
}Attach buyer assertion
A buyer assertion proves the originating app saw a signed-in buyer. It is not an ATM OAuth session and does not let ATM write to the buyer's repo.
async function buildBuyerAssertion(buyerDid: string) {
return await getServiceAuth({
iss: buyerDid,
aud: "did:plc:7srqsetux75b6flzbbyag2ro#AttestedNetwork",
lxm: "money.atmosphere.payment.assertPayer",
exp: Math.floor(Date.now() / 1000) + 60,
jti: crypto.randomUUID()
});
}Start ATM checkout
async function initiateAtmCheckout(product: string) {
const lxm = "network.attested.payment.initiate";
const serviceAuth = await mintAtmServiceAuth(lxm);
const response = await fetch(
"https://checkout.atmosphere.money/xrpc/" + lxm,
{
method: "POST",
headers: {
authorization: "Bearer " + serviceAuth,
"content-type": "application/json"
},
body: JSON.stringify({ product })
}
);
if (!response.ok) throw new Error(await response.text());
return response.json() as Promise<{ token: string; url: string }>;
}Create ticket hold
async function createTicketHold(input: {
eventUri: string;
tierId: string;
quantity: number;
buyerDid: string;
buyerAssertionJwt: string;
}) {
const lxm = "tickets.atmosphere.createTicketHold";
const serviceAuth = await mintAtmServiceAuth(lxm);
const response = await fetch(
"https://checkout.atmosphere.money/xrpc/" + lxm,
{
method: "POST",
headers: {
authorization: "Bearer " + serviceAuth,
"content-type": "application/json"
},
body: JSON.stringify({
environment: "test",
eventUri: input.eventUri,
buyerDid: input.buyerDid,
buyerAssertionJwt: input.buyerAssertionJwt,
items: [{ tierId: input.tierId, quantity: input.quantity }]
})
}
);
if (!response.ok) throw new Error(await response.text());
return response.json();
}Claim free ticket
async function claimFreeTicket(input: {
eventUri: string;
tierId: string;
buyerDid: string;
buyerAssertionJwt: string;
}) {
const lxm = "tickets.atmosphere.claimFreeTicket";
const serviceAuth = await mintAtmServiceAuth(lxm);
const idempotencyKey =
"claim:" + input.eventUri + ":" + input.buyerDid + ":" + input.tierId;
const response = await fetch(
"https://checkout.atmosphere.money/xrpc/" + lxm,
{
method: "POST",
headers: {
authorization: "Bearer " + serviceAuth,
"content-type": "application/json"
},
body: JSON.stringify({ ...input, environment: "test", idempotencyKey })
}
);
if (!response.ok) throw new Error(await response.text());
return response.json();
}Deduplicate delivery
async function fulfillIdempotently(deliveryId: string, event: AtmEvent) {
const claim = await claimWebhookDelivery(deliveryId);
if (claim.status === "completed") return { ok: true, duplicate: true };
if (claim.status === "busy") return { ok: false, retry: true, status: 503 };
try {
if (event.type === "payment.completed") {
await markOrderPaid(event.data.appOrderId, event.data.paymentId);
}
if (event.type === "tickets.issued") {
await showTicketsToBuyer(event.data.orderId);
}
await completeWebhookDelivery(deliveryId, claim.claimId);
return { ok: true };
} catch (error) {
await releaseWebhookDelivery(deliveryId, claim.claimId);
throw error;
}
}