Home » Free ACP Payments Module » Order Webhook

The Order Webhook: Receiving Completed Orders

The order webhook is where the ACP Payment Module ends and your business begins. The moment a checkout session completes, the module POSTs one JSON payload, order, buyer, line items, and totals, to the URL you configure, signed with an HMAC if you provide a secret, carrying a stable idempotency key, and then it forgets the order. Fulfillment, receipts, licenses, and customer records are deliberately yours: the module is a checkout, not a system of record.

The Hand-Off Philosophy

Most commerce platforms want to own your orders, because owned data is lock-in. This module takes the opposite stance: on a successful payment it delivers the order to the merchant and keeps nothing beyond the TTL-bound session that produced it. That single decision simplifies almost everything around it. There is no order database to migrate, back up, or breach, no second copy of customer PII to govern, and no sync problem between the module's records and your real systems, your CRM, your licensing service, your accounting stack. The order webhook is therefore not an optional integration, it is the product's output. Configure orderWebhookUrl in config.json from day one of production; without it, a no-op sink logs a loud warning per order and drops the payload, acceptable in a demo, a revenue leak anywhere else.

The Payload

Your endpoint receives a POST with Content-Type: application/json and a body shaped like this:

{
  "order": {
    "id": "order_5f0c4c2e-9b1f-4f7a-8e3a-2d1d9d6a7b21",
    "checkout_session_id": "5f0c4c2e-9b1f-4f7a-8e3a-2d1d9d6a7b21",
    "permalink_url": "https://yourtool.com/orders/order_5f0c..."
  },
  "buyer": { "first_name": "Dana", "email": "dana@example.com" },
  "currency": "usd",
  "line_items": [
    {
      "id": "1a2b3c...",
      "item_id": "pro-5seat",
      "title": "Pro License (5 seats)",
      "quantity": 1,
      "unit_amount": 39900,
      "total": 39900
    }
  ],
  "totals": [
    { "type": "items_base_amount", "display_text": "$399.00", "amount": 39900 },
    { "type": "subtotal", "display_text": "$399.00", "amount": 39900 },
    { "type": "total", "display_text": "$399.00", "amount": 39900 }
  ]
}

The fields your systems usually key on: order.id (stable and unique per order), line_items[].item_id (the sellable id from your products file, a variant id when the product has variants, which is what tells a licensing system which SKU to provision), quantity and the integer-cent amounts, and buyer.email when the flow captured one. The totals array includes a tax entry when tax applied, with per-line tax available on the line items, which keeps your books straight as covered in the tax guide. The permalink_url is built from your configured permalinkBase and is the order link ACP platforms may show the shopper, point it at a page your systems render.

Delivery Headers and Verification

Two headers accompany every delivery. Idempotency-Key carries the order id, and because it is stable per order, any duplicate delivery, a webhook retry, a Stripe event redelivered, a network race, collapses into one fulfillment as long as your endpoint deduplicates on it. Treat that as a requirement of your receiver, not an optimization: insert the order id with a unique constraint first, act second.

Signature appears when you set ORDER_WEBHOOK_SECRET: a base64-encoded SHA-256 HMAC of the exact request body, computed with your secret. Verify it before trusting anything in the payload, the recipe in Node.js:

import { createHmac, timingSafeEqual } from "node:crypto";

function verifyOrder(rawBody, signatureHeader, secret) {
  const expected = createHmac("sha256", secret)
    .update(rawBody).digest("base64");
  const a = Buffer.from(signatureHeader);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}

Hash the raw bytes you received, not a re-serialized object, JSON re-encoding reorders nothing in theory and breaks signatures in practice. With the signature verified you know two things: the order came from your store's deployment, and the body was not altered in transit. An unsigned configuration still works (the secret is optional), but production endpoints exposed to the internet should require it, the same reasoning as the inbound rules in the security model.

Delivery Semantics and Failure Behavior

The module's delivery policy is best effort with loud failure, a deliberate ordering of harms. A successful charge must never be rolled back because a merchant webhook was down, so order emission happens after payment is final, waits up to ten seconds for your endpoint, treats any non-2xx response as failure, and on failure logs a prominent error with the order and session ids rather than throwing. The session retains its completed state and its order either way, and because emission is tracked per session, a subsequent webhook-driven confirmation of the same session will not double-send an already-delivered order.

The operational consequence: monitor your application logs for failed order deliveries, they represent money received with fulfillment pending. Recovery is straightforward since the log line names the session, and your endpoint's idempotency makes a manual replay safe. Keeping the receiving endpoint simple is the best prevention, accept, verify, enqueue, and return 200 in milliseconds, doing slow work (license generation, email sending) asynchronously behind the acknowledgment. This accept-then-process pattern is the same one recommended across our order processing automation guide.

Building the Receiving Endpoint

A production-shaped receiver fits in a page. Verify the signature against the raw body. Parse the JSON. Upsert on order.id, exiting quietly when it already exists. Then branch on item_id: provision the license seats, credit the API account, schedule the onboarding call, whatever each product means in your systems, using quantity and the amounts as the authoritative figures. Send your receipt or fulfillment email from your side, the module deliberately sends no buyer email, your brand owns the relationship. Finally, record the revenue split, amount, tax, currency, into your books. Teams selling licenses and digital goods can lean on our guides to digital product delivery and licensing for the fulfillment patterns on the other side of this webhook.

One design note for embedders: the webhook sink is just the default implementation of a one-method OrderSink interface. If your fulfillment lives in the same codebase as your MCP server, implementing the interface directly, no HTTP hop, no signature, just a function call into your provisioning code, is fully supported and removes a network failure mode. The embedding guide shows where the sink plugs in.

Reconciliation: Tying Orders to Stripe

Every completed session also stores a payment reference, the Stripe PaymentIntent id, and the order's checkout_session_id appears in the Stripe payment's metadata, so the three records, your fulfillment row, the order payload, and the Stripe charge, link bidirectionally. A monthly reconciliation becomes a join: every Stripe payment with a checkout_session_id should match exactly one fulfilled order id on your side, and anything unmatched in either direction is worth a human look. The webhook handler already flags the rarest mismatch loudly, a paid Stripe event carrying no session id, which usually means a charge was created on the same Stripe account outside the module. Keeping this loop tight from launch is far cheaper than rebuilding history later, and it is the foundation for the revenue reporting workflows in our reconciliation guide.

Privacy advantage worth stating: because orders pass through and are not retained, the module adds no long-term PII store to your architecture. Buyer details exist transiently in the TTL-bound session and in the payload delivered to you, so your existing privacy policy and data inventory remain accurate, a meaningful simplification under GDPR-style regimes, see data privacy for online sellers.