Docs

Helper snippets

Copy-paste server helpers for webhooks, service-auth, buyer assertions, checkout, Tickets, and idempotent fulfillment.

Closed beta@atmosphere-money/app-nodeSDK beta: 0.0.0-beta.3ATM API beta: 2026-0672 lexicons

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

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

ts
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

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

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

ts
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

ts
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

ts
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

ts
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

ts
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 };
}
Helper snippets - Atmosphere Money Docs