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 matched = candidates.some((candidate) => {
if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
return timingSafeEqual(expectedBuffer, Buffer.from(candidate, "hex"));
});
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",
insertDeliveryIdOnce: async (deliveryId) => {
return insertDeliveryIdOnce(deliveryId);
},
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
});
await fulfillIdempotently(event.deliveryId, event);
return Response.json({ ok: true });
}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 inserted = await insertDeliveryIdIfMissing(deliveryId);
if (!inserted) return { ok: true, duplicate: true };
if (event.type === "payment.completed") {
await markOrderPaid(event.data.appOrderId, event.data.paymentId);
}
if (event.type === "tickets.issued") {
await showTicketsToBuyer(event.data.orderId);
}
return { ok: true };
}