Docs

Fastify recipe

Implement ATM checkout and signed webhook verification in a Fastify app.

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

Install SDK

Fastify needs a raw-body strategy for ATM webhooks. Checkout routes can use ordinary JSON request bodies.

sh
npm install @atmosphere-money/app-node@beta fastify

Create checkout route

Create an app order first, check the recipient's payout status, confirm creator app approval, then ask ATM to create the hosted checkout. The route returns only the checkout URL and token to the browser.

ts
import Fastify from "fastify";
import { createAtmAppClient } from "@atmosphere-money/app-node";

const fastify = Fastify();
const atm = createAtmAppClient({
  getServiceAuthToken: ({ lxm, aud }) => mintAppServiceAuthJwt({ lxm, aud })
});

fastify.post("/checkout", async (request, reply) => {
  const body = request.body as { recipientDid: string; amountCents: number };
  const payout = await atm.getPayoutStatus(body.recipientDid);
  if (!payout.payable) {
    return reply.code(409).send({ error: "RecipientNotPayable" });
  }

  const approval = await atm.requestRecipientApproval({
    recipientDid: body.recipientDid,
    environment: "test",
    paymentTypes: ["shop"],
    feeShareBps: 300,
    requestReason: "Enable Fastify checkout"
  });
  if (approval.status !== "approved") {
    return reply.code(409).send({
      error: "RecipientAppApprovalRequired",
      approvalUrl: approval.dashboardUrl
    });
  }

  const order = await createAppOrder(body);
  const checkout = await atm.initiatePayment({
    environment: "test",
    recipient: order.recipientDid,
    amount: order.amountCents,
    currency: "usd",
    paymentType: "shop",
    returnUrl: `https://app.example/orders/${order.id}/return`,
    cancelUrl: `https://app.example/orders/${order.id}`,
    metadata: { appOrderId: order.id }
  });

  return reply.send({ url: checkout.url, token: checkout.token });
});

Verify webhook or XRPC receiver

Fulfillment should come from verified ATM events. Signed HTTP webhooks are the default; XRPC receiver callbacks are optional for apps that already host an AT Protocol service surface.

ts
import { constructTypedAtmWebhookEvent } from "@atmosphere-money/app-node";

fastify.post("/webhooks/atm", async (request, reply) => {
  const rawBody = String((request as typeof request & { rawBody?: unknown }).rawBody ?? "");
  const event = constructTypedAtmWebhookEvent({
    rawBody,
    secret: process.env.ATM_WEBHOOK_SECRET!,
    expectedType: "payment.completed",
    headers: {
      signature: request.headers["atm-signature"] as string | undefined,
      deliveryId: request.headers["atm-delivery-id"] as string | undefined,
      event: request.headers["atm-event"] as string | undefined,
      apiVersion: request.headers["atm-api-version"] as string | undefined,
      environment: request.headers["atm-environment"] as string | undefined
    }
  });

  if (await hasHandled(event.id)) return reply.send({ ok: true, duplicate: true });
  const metadata = event.data.payment.metadata as
    | { appOrderId?: string }
    | undefined;
  const appOrderId = String(metadata?.appOrderId ?? "");
  if (!appOrderId) return reply.code(422).send({ error: "MissingAppOrderId" });

  await fulfillOrder(appOrderId, event.data.payment.id);
  await markHandled(event.id);
  return reply.send({ ok: true });
});

Fulfill payment or ticket

The fulfillment step is the same in Fastify: deduplicate the ATM delivery id, map the ATM payment or ticket event back to your app order, write the app-side fulfillment state once, and store the ATM id for support and reconciliation.

  1. 01

    Deduplicate

    Insert the ATM delivery id with a unique constraint before side effects.

  2. 02

    Match order

    Read appOrderId, ticket hold id, listing ref, or another private app correlation id from event metadata.

  3. 03

    Fulfill

    Grant access, issue app content, reveal tickets, update a subscription, or notify the buyer.

  4. 04

    Reconcile

    Store the ATM payment id and event id beside the app order for refunds, disputes, and redrive.

Run local test fixture

Use the runnable starter when one exists, or generate a signed webhook fixture with @atmosphere-money/testing. Your test should prove raw-body verification, duplicate delivery handling, and the app fulfillment mutation.

sh
node --test test/atm-webhook.test.js
# Make the fixture assert that Fastify receives the exact raw JSON body

Runtime notes

Raw bodyRegister a raw-body parser/plugin and store exact bytes before parsing JSON.
Snippetdocs/developer/examples/fastify-webhook-route.ts is the copyable route example.
IdempotencyUse a unique delivery-id table before fulfillment side effects.
Fastify recipe - Atmosphere Money Docs