Docs

Examples

Runnable and copy-paste examples for ATM checkout, webhooks, status polling, and tickets.

Closed beta@atmosphere-money/app-nodeSDK beta: 0.0.0-beta.3ATM API beta: 2026-0671 published 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.

Local examples folder

The ATM repo includes an examples folder for closed-beta app developers. Treat these as starter-kit apps that exercise the same server-side contracts as the beta SDK scaffold.

sh
cd examples/atm-node-app
cp .env.example .env
npm install
npm run dev

Run the starter kit

The starter kit is designed to prove the integration shape before you copy helpers into your app. Start with the test environment, send a dashboard test event, then try checkout/status once your app can verify signed events.

  • Register the app in ATM test mode and enable the modules you want to test.
  • Copy the test webhook signing secret into examples/atm-node-app/.env.
  • Expose http://localhost:8787/webhooks/atm through a tunnel and save that as the test webhook URL.
  • Send a payment.completed test event and confirm the server logs one verified delivery.
  • Redrive the same delivery and confirm your app deduplicates by delivery id.
  • Call POST /checkout, open the returned ATM checkout URL, then poll GET /status?token=... on return.
sh
curl http://localhost:8787/health
# Then use the ATM dashboard test-event button to send a real signed delivery.

Minimal checkout server

A minimal integration creates the app order, builds a private ATM checkout envelope, calls the strict initiate route, and redirects the buyer to the returned ATM checkout URL.

ts
const checkout = await atm.initiatePayment({
  recipient: "did:plc:creator",
  amount: 1200,
  currency: "usd",
  paymentType: "shop",
  returnUrl: "https://app.example/return",
  cancelUrl: "https://app.example/product"
});

return Response.redirect(checkout.url);

Hosted checkout recipe

This is the launch payment recipe for apps. Your server owns the app order and envelope. ATM owns the checkout page, processor session, wallets, receipts, and proof coordination.

ts
async function startAtmCheckout(order: AppOrder) {
  const product = await buildPrivateAtmEnvelope({
    recipientDid: order.recipientDid,
    paymentType: order.kind,
    amount: order.amount,
    currency: "usd",
    listing: order.atmProductStrongRef,
    payerDid: order.buyerDid,
    buyerAssertionJwt: order.buyerAssertionJwt,
    returnUrl: "https://app.example/orders/" + order.id,
    cancelUrl: "https://app.example/products/" + order.productId
  });

  const serviceAuth = await mintServiceAuth({
    lxm: "network.attested.payment.initiate"
  });

  const response = await fetch(
    "https://checkout.atmosphere.money/xrpc/network.attested.payment.initiate",
    {
      method: "POST",
      headers: {
        authorization: "Bearer " + serviceAuth,
        "content-type": "application/json"
      },
      body: JSON.stringify({ product })
    }
  );

  if (!response.ok) throw new Error(await response.text());
  return (await response.json()) as { token: string; url: string };
}

Webhook receiver recipe

Fulfill from ATM events, not browser redirects. Store delivery ids before side effects so dashboard redrives and retries are harmless.

ts
export async function POST(request: Request) {
  const rawBody = await request.text();
  const event = JSON.parse(rawBody) as AtmEvent;
  verifyAtmWebhookSignature(
    rawBody,
    request.headers.get("atm-signature"),
    request.headers.get("atm-delivery-id"),
    process.env.ATM_WEBHOOK_SECRET!
  );

  // The envelope id IS the delivery id (matches Atm-Delivery-Id).
  const inserted = await insertDeliveryId(event.id);
  if (!inserted) return Response.json({ ok: true, duplicate: true });

  if (event.type === "payment.completed") {
    await markOrderPaid(event.data.appOrderId, event.data.paymentId);
  }

  return Response.json({ ok: true });
}

XRPC receiver recipe

AT Protocol-native apps can receive the same signed event envelope through an app-hosted XRPC method. This is ATM's default event delivery path for native apps; advertise the canonical receiver in the app DID document and pair it with authenticated status/query reconciliation. Pull-only exposes successfully delivered test receiver history for diagnostics only; every live environment requires the XRPC receiver or signed webhook.

http
POST /xrpc/money.atmosphere.event.receiveEvent
Authorization: Bearer <ATM service-auth jwt>
Content-Type: application/json

{
  "id": "whd_...",
  "type": "payment.completed",
  "createdAt": "2026-06-05T00:00:00.000Z",
  "apiVersion": "2026-07",
  "environment": "test",
  "data": {
    "$type": "money.atmosphere.event.defs#paymentCompleted",
    "paymentId": "pay_...",
    "appOrderId": "ord_123"
  }
}

Ticket purchase recipe

Paid tickets add one required step before checkout: create a hold with ATM Tickets. The hold reserves scarce capacity and returns the ATM checkout URL for the buyer.

ts
const availability = await tickets.getTicketAvailability({
  environment: "test",
  eventUri
});

if (!availability.items.find((item) => item.ticketTypeId === ticketTypeId)?.available) {
  throw new Error("Sold out");
}

const hold = await tickets.createTicketHold({
  environment: "test",
  eventUri,
  buyerDid,
  buyerAssertionJwt,
  items: [{ ticketTypeId, quantity: 2 }],
  returnUrl: "https://events.example/orders/123",
  cancelUrl: "https://events.example/e/abc"
});

await orders.saveAtmStatusToken("123", hold.statusToken);
return Response.redirect(hold.url);

Free ticket recipe

Limited free tickets should still use ATM Tickets because capacity is scarce. Use app service-auth plus a buyer assertion and issue the ticket immediately without checkout. Supply a delivery email on every new claim because Stripe never collects one on this path.

ts
const claim = await tickets.claimFreeTicket({
  environment: "test",
  eventUri,
  ticketTypeId,
  buyerDid,
  buyerAssertionJwt,
  customerEmail,
  idempotencyKey: "claim:" + eventUri + ":" + buyerDid + ":" + ticketTypeId
});

return Response.json({
  ticketId: claim.ticket.id,
  status: claim.ticket.status
});

Optional webhook receiver

The example webhook verifies the raw body, claims the delivery, and wakes canonical reconciliation. It completes the claim only after that wake-up is durable; fulfillment waits for authenticated status to return completed.

Status polling

A durable backend worker polls status using the initiation token, whether or not the browser returns. This canonical state drives fulfillment and can also resolve processing UI.

Ticket hold

Ticketing examples use the same ATM App Node SDK because Tickets is an ATM module. The atmosphere.tickets site carries the ticket-specific concepts, diagrams, generated reference, and scanner flows.