Webhooks

Receive Decal events over HTTP — setting up a callback URL, handling payloads, delivery semantics, and payment verification.

Webhooks deliver Decal Events to your server as HTTP POST requests. Register a callbackUrl when creating a checkout session and Decal will POST the event payload to that URL whenever a matching event fires.

  • Event reference: See Events for the full event catalog and payload schemas.
  • Authentication: See Authentication

How it works

Take the following example of a customer checkout flow using Decal's hosted checkout page and the Checkout Sessions API:

1. Customer completes payment on hosted checkout page
        |
        v
2. Decal fires `checkout.session.completed` to your `callbackUrl`
        |
        v
3. Your server receives the POST (full session + order data included)
        |
        v
4. Customer is redirected to your `successUrl`

The webhook is delivered before the customer is redirected, so your server knows about the payment before the customer arrives at your success page.

Setting a callback URL

Pass a callbackUrl when creating a checkout session:

curl -X POST https://api.usedecal.com/v0/checkout/sessions \
  -H "Authorization: Bearer sk_live_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      { "name": "Classic Burger", "quantity": 1, "unitPrice": 1299 }
    ],
    "callbackUrl": "https://yoursite.com/webhooks/decal",
    "successUrl": "https://yoursite.com/order/confirmed"
  }'

You can also set a default callbackUrl in your organization's checkout settings via the dashboard. Per-session values override the default.

The callbackUrl supports {SESSION_ID} and {ORDER_ID} placeholders — see URL template variables.

Handling webhooks

Every webhook POST body has the shape { event, createdAt, session } — the event name, an ISO-8601 timestamp of when the event fired, and the full CheckoutSession (including its nested order). See Checkout events for the complete payload schema.

Important

Your webhook endpoint must respond within 5 seconds or the request will time out. Quick operations like updating a database row or writing to Redis are fine — avoid anything that could block for an unpredictable amount of time (calling third-party APIs, sending emails, running batch jobs). If your fulfillment logic is heavier, return 200 immediately and push the work to a background queue. A timed-out webhook is not retried at this time.

app.post("/webhooks/decal", (req, res) => {
  const { event, session } = req.body;

  if (event === "checkout.session.completed") {
    const order = session.order;

    console.log(`Payment received for order ${order.id}`);
    console.log(`Amount paid: ${order.amounts.paid} ${order.currency}`);

    // Fulfil the order in your system
  }

  // Always return 2xx to acknowledge receipt
  res.status(200).send("ok");
});
export async function POST(req: Request) {
  const { event, session } = await req.json();

  if (event === "checkout.session.completed") {
    const order = session.order;
    // Fulfil the order in your system
  }

  return new Response("ok", { status: 200 });
}

Delivery semantics

PropertyValue
MethodPOST
Content-Typeapplication/json
Timeout5 seconds
RetriesNone (best-effort, single attempt)
Failure impactA slow or failing callbackUrl never blocks payment or customer redirect.

If your endpoint returns a non-2xx status or does not respond within 5 seconds, the delivery is logged but not retried. The payment still completes normally.

Delivery headers

Every webhook request carries these headers:

HeaderExampleDescription
Content-Typeapplication/jsonThe body is always JSON.
Idempotency-Keyevt_live_abc123The event ID. Stable across any duplicate delivery of this event.
X-Decal-Event-Idevt_live_abc123The event ID (same value as Idempotency-Key).
X-Decal-Delivery-Id4821Unique per delivery attempt. Differs even for the same event.
X-Decal-Event-Typecheckout.session.completedThe event name, also present in the body as event.
User-AgentDecal-Webhooks/1.0Identifies the Decal webhook sender.

Use Idempotency-Key (the event ID) to dedupe: record the IDs you have already processed and skip any you have seen before. Because the key is the event ID — not the per-delivery ID — it stays stable if the same event is ever delivered more than once, so your handler stays idempotent.

Verifying payment

Webhooks are not signed

Decal webhooks carry no HMAC or signature header today — TLS and the secrecy of your URL are the only delivery guarantees. Do not treat the POST body alone as proof of payment for anything that matters (fulfillment, refunds, shipping). Confirm by re-fetching GET /v0/checkout/sessions/:sessionId with your secret API key, which is authenticated and authoritative. Signed webhooks are tracked as a future enhancement.

Even though the webhook payload includes the full order, you can verify payment server-side for high-value transactions by fetching the session directly:

// After receiving the webhook
const order = session.order;

// Option 1: trust the webhook payload
if (order.paymentStatus === "paid") {
  fulfillOrder(order.id);
}

// Option 2: verify by fetching the session from the API
const verified = await fetch(`https://api.usedecal.com/v0/checkout/sessions/${session.id}`, {
  headers: { Authorization: `Bearer ${apiKey}` },
});
const { checkoutSession } = await verified.json();

if (checkoutSession.order.paymentStatus === "paid") {
  fulfillOrder(checkoutSession.order.id);
}

Test mode

Webhooks fire in both test and live mode. Use your sk_test_ key to create test sessions with a callbackUrl — the webhook will be delivered to your endpoint with test-mode IDs (cs_test_..., ord_test_..., cust_test_...).

Tools like webhook.site or ngrok are useful for inspecting webhook payloads during development.