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
| Property | Value |
|---|---|
| Method | POST |
| Content-Type | application/json |
| Timeout | 5 seconds |
| Retries | None (best-effort, single attempt) |
| Failure impact | A 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:
| Header | Example | Description |
|---|---|---|
Content-Type | application/json | The body is always JSON. |
Idempotency-Key | evt_live_abc123 | The event ID. Stable across any duplicate delivery of this event. |
X-Decal-Event-Id | evt_live_abc123 | The event ID (same value as Idempotency-Key). |
X-Decal-Delivery-Id | 4821 | Unique per delivery attempt. Differs even for the same event. |
X-Decal-Event-Type | checkout.session.completed | The event name, also present in the body as event. |
User-Agent | Decal-Webhooks/1.0 | Identifies 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.