Webhook Handlers That Survive Production
What a cmssy webhook delivery is, how to verify its signature, what the retry ladder does, and how to write a handler that is safe to run twice.
A webhook handler looks like ten lines of code until the first time it double-charges a ledger, drops half its deliveries during a secret rotation, or silently stops receiving anything after a bad deploy. None of those are exotic. They are what happens when a handler assumes a delivery arrives once, in order, signed with the one secret it knows.
This post is the checklist for a cmssy webhook handler that survives production: what a delivery is, how to verify it, what the retry ladder does, and how to write a handler that is safe to run twice.
What a delivery is
Every delivery is a POST with a JSON body:
{
"id": "0b6f2c1e-…",
"event": "order.paid",
"createdAt": "2026-10-26T14:21:19.846Z",
"data": { "workspaceId": "…", "order": { … } }
}The id is the delivery id. It stays the same on every retry of that delivery, and it also arrives in the x-cmssy-webhook-id header, next to x-cmssy-event and x-cmssy-signature. Redelivering from the admin or with redeliver_webhook_delivery creates a new delivery with a new id, so a handler that deduplicates on the id treats a manual replay as new - which is usually what you want when you replay on purpose.
Payloads are lean: content events carry references, not content, and form.submitted carries the submission id, not the answers. Commerce events carry the order snapshot your integration needs - status, totals, tax treatment, buyer, parcels and invoices.
Verify the signature

The header reads t=<unix-ms>,v1=<hex>. The hex is an HMAC-SHA256 over <t>.<raw body>, keyed with the endpoint's secret. Three details trip hand-written verifiers:
- Rotation sends two signatures. While a secret is being rotated, the header carries one
v1per active secret. A verifier that checks only the last one rejects every delivery signed with the other, for as long as the rotation lasts. - The body must be raw. Parse JSON and stringify it again and the bytes change - key order, whitespace, unicode escapes. Verify
await request.text(), then parse. - Old timestamps must fail. Without a tolerance window a captured delivery can be replayed forever.
The SDK helper does all three: it reads every v1, compares in constant time, rejects a timestamp older than five minutes and returns the typed event. It ships in @cmssy/next, @cmssy/remix and @cmssy/astro, and in @cmssy/core for anything else.
import { verifyCmssyWebhook, CmssyWebhookError } from "@cmssy/next";
export async function POST(request: Request) {
const body = await request.text();
try {
const event = await verifyCmssyWebhook({
body,
signatureHeader: request.headers.get("x-cmssy-signature"),
secret: [process.env.CMSSY_WEBHOOK_SECRET, process.env.CMSSY_WEBHOOK_SECRET_PREVIOUS]
.filter((s): s is string => !!s),
});
await handle(event);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof CmssyWebhookError) {
return new Response(null, { status: 400 });
}
throw error;
}
}The secret option takes an array, so your handler can hold the new secret and the previous one during your own rotation window and accept deliveries signed with either.
The retry ladder

cmssy waits 15 seconds for an answer. Anything other than a 2xx - and any timeout - is retried: eight attempts in all, backing off 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours. A handler that is down for an afternoon loses nothing.
- Redirects are failures. A 3xx is not followed. Register the final URL.
- Only 410 Gone stops the ladder. Every other 4xx is retried, because "this endpoint is finished for good" and "the deploy that owns this route is briefly answering 404" look the same from outside. Return 410 when you mean it.
- Retry-After is honored when it asks for longer than the ladder would wait, capped at 10 hours. It never shortens the backoff.
- Twenty consecutive failures disable the endpoint. Fix the handler, then re-enable it; that resets the counter.
Deliveries are queued per endpoint, one attempt in flight at a time, so a slow handler queues its own retries instead of being hammered by a fan-out.
Write a handler that is safe to run twice
At-least-once delivery means your handler will, sooner or later, see the same delivery twice - a timeout on your side that cmssy counted as a failure, a deploy that answered 502 after the work was done. And because delivery is not ordered, a retry can land after a newer event about the same order.
- Acknowledge fast, work after. Do the verification, store the delivery, return 2xx. A handler that calls three APIs before answering will eventually take longer than 15 seconds and be retried while it is still working.
- Deduplicate on the delivery id. A unique index on
idin your own table turns a duplicate into a no-op. - Read current state before side effects. The payload is a snapshot from the moment of the event. When it matters - issuing an invoice, sending an email, moving stock in another system - read the order again and act on what is true now.
- Never key on arrival order. Compare the state in the payload, or the fresh read, against what you already applied.
When something does go wrong
Every attempt is logged: list_webhook_deliveries (or Settings → Webhooks in the admin) shows the status, the response code, and for staff with webhooks:manage the first 8 KiB of your handler's response body - usually the stack trace you were looking for. Once the handler is fixed, redeliver the failed ones. A redelivery is a fresh delivery with its own id; it does not count against the original's ladder or the endpoint's failure streak.
One subscription or many?
For cache invalidation, subscribe once to content.changed and invalidate on any change; it covers pages, records, models, forms, media and settings. For automation, subscribe to the granular events you need - order.*, shipment.*, page.published, form.submitted and the rest. list_webhook_event_types returns the full allowlist, filtered by what your role may subscribe to.
The full reference is in the webhooks docs. For a worked example of an idempotent handler, see invoicing a headless store with webhooks.
