Fastify recipe
Implement ATM checkout and signed webhook verification in a Fastify app.
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.
npm install @atmosphere-money/app-node@beta fastifyCreate 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. Persist ATM's app status token beside the order before returning only the checkout URL to the browser. The URL contains a separate browser bearer.
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 }
});
await saveAtmStatusToken(order.id, checkout.token);
return reply.send({ url: checkout.url });
});Add event receiver
New AT Protocol-native apps use the canonical#AtmEventReceiver / money.atmosphere.event.receiveEvent wake-up transport. A conventional web app can explicitly select the signed HTTP webhook compatibility path shown by this framework recipe. Verify the configured receiver before using it to wake an immediate canonical status/query read.
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
}
});
const metadata = event.data.payment.metadata as
| { appOrderId?: string }
| undefined;
const appOrderId = String(metadata?.appOrderId ?? "");
if (!appOrderId) return reply.code(422).send({ error: "MissingAppOrderId" });
const claim = await claimWebhookDelivery(event.id, event);
if (claim.status === "completed") {
return reply.send({ ok: true, duplicate: true });
}
if (claim.status === "busy") {
return reply.code(503).header("retry-after", "1").send({ error: "DeliveryBusy" });
}
try {
await fulfillOrder(appOrderId, event.data.payment.id);
await completeWebhookDelivery(event.id, claim.claimId, event);
return reply.send({ ok: true });
} catch (error) {
await releaseWebhookDelivery(event.id, claim.claimId, event, error);
throw error;
}
});Fulfill payment or ticket
The fulfillment step is the same in Fastify: poll with the stored status handle, map the completed ATM payment back to your app order, and write the app-side fulfillment state once. Deduplicate each receiver delivery id before waking the same poller.
- 01
Deduplicate
Apply the same completed status once; optionally claim a push delivery id before waking reconciliation.
- 02
Match order
Load the app order that stores the initiation status token and private correlation data.
- 03
Fulfill
Grant access, issue app content, reveal tickets, update a subscription, or notify the buyer.
- 04
Reconcile
Store canonical ATM state and any optional event id beside the app order for refunds and disputes.
Run local test fixture
Use the runnable starter when one exists. Your core test should prove status-token persistence, authenticated polling, duplicate terminal handling, and the app fulfillment mutation. If you enable push, also generate a signed fixture with@atmosphere-money/testing and prove raw-body verification plus duplicate delivery handling.
node --test test/atm-webhook.test.js
# Make the fixture assert that Fastify receives the exact raw JSON bodyRuntime notes
| Raw body | Register a raw-body parser/plugin and store exact bytes before parsing JSON. |
|---|---|
| Snippet | docs/developer/examples/fastify-webhook-route.ts is the copyable route example. |
| Idempotency | Use an atomic claim with an expiring lease; complete only after success and release failures for redrive. |