Webhooks
Flightdeck can POST a signed JSON notification to an endpoint you control when something happens in your store. Rather than polling the API, you register an endpoint once and the platform delivers to it — signed with a per-subscription secret so you can prove the request came from us and reject anything stale.
This page describes what a delivery looks like and how the platform behaves around it. For a step-by-step guide to registering an endpoint and verifying a signature in code, see Integrating webhooks.
How delivery works
Webhooks are dispatched by the outbound edge — one of the two network boundaries in the platform topology. When an internal event fires for your tenant, the edge finds your active subscriptions whose allowlist contains that event, writes a delivery row to a durable ledger, signs the exact JSON body with your subscription secret, and POSTs it to your URL. The outcome of every attempt — success, pending retry, or dead-lettered — is recorded on that row. Nothing is delivered by a side channel and nothing is dropped silently.
Subscriptions are managed with your merchant (fdk_) credential under the
manage:own_webhooks scope. The credential carries the tenant, so a
subscription is always bound to the tenant that created it and only that
tenant’s events can reach it.
Events you can subscribe to
The set of events delivered to merchant endpoints is deliberately a small, explicit allowlist — not “every internal event”. A new internal event never silently widens what your endpoint receives. Today the allowlist is exactly one event:
| Event | Fires when |
|---|---|
commerce.order.placed.v1 | An order is placed. |
More events are planned. The registration API rejects any subscription that
names an event outside this allowlist with a 400, so you can never subscribe
to something that cannot fire.
One further event type reaches your endpoint but is not subscribable:
| Event | Purpose |
|---|---|
platform.test.v1 | The reserved type used only for test deliveries. It never crosses the internal event bus and cannot appear in a subscription’s allowlist. |
The delivery envelope
Every delivery is an HTTP POST with a content-type: application/json body of
this shape:
{
"id": "evt_01J9…",
"type": "commerce.order.placed.v1",
"tenant_ref": "your-tenant-slug",
"created_at": "2026-09-14T17:04:05.000Z",
"data": { "…": "the event-specific payload" }
}| Field | Meaning |
|---|---|
id | The event id. Stable across retries — use it as your idempotency key. |
type | The event name, or platform.test.v1 for a test fire. |
tenant_ref | Your tenant slug. |
created_at | When the envelope was created (ISO 8601). Fixed at first dispatch; a retry re-sends the identical bytes, so this does not change. |
data | The self-contained event payload. A consumer never needs to call back to interpret it. |
The signature is computed over the exact bytes of this body. When you verify a delivery, sign the raw request body you received — do not parse and re-serialize it first, or whitespace and key ordering differences will break the comparison.
Headers
Every delivery carries these headers:
| Header | Meaning |
|---|---|
x-webhook-event-id | The event id (same value as the envelope’s id). |
x-webhook-event-type | The event name (same value as the envelope’s type). |
x-webhook-timestamp | Unix seconds at delivery-attempt time. A retry gets a fresh timestamp, and therefore a fresh x-webhook-signature-v1. |
x-webhook-signature-v1 | The signature to verify: t=<ts>,v1=<hex>. Replay-safe — see below. |
x-webhook-signature | Deprecated. Legacy bare-hex HMAC-SHA256 of the body only, with no timestamp and no replay protection. Sent during the migration window for back-compat; do not build new integrations on it. |
x-webhook-delivery-attempt | The 1-based attempt number for this delivery. |
Verify x-webhook-signature-v1, never x-webhook-signature. The unversioned
header is a bare HMAC of the body with no bound timestamp, so a captured
delivery replays against it forever. It exists only so receivers written
before the v1 scheme keep working during the migration window, and it will be
removed once that window closes.
The v1 scheme signs ${timestamp}.${rawBody} with your subscription secret
(HMAC-SHA256, hex digest) and puts the timestamp inside the header value. Once
the signature verifies, the timestamp is trustworthy, so you can reject stale
deliveries and close the replay window. The full verification recipe, with
copy-pasteable code, is in Integrating webhooks.
Delivery semantics
- At-least-once. A delivery is marked delivered only after your endpoint
returns a
2xx. If your endpoint received the request but the acknowledgement did not reach us (a timeout, a dropped connection after processing), the delivery is retried and your endpoint sees the same event again. Dedupe on the eventid— see handling retries idempotently. - A single delivery is never duplicated by dispatch itself. The ledger keys
each delivery on
(subscription, event id), so a defensive re-publish of the same event never creates a second delivery row. At-least-once comes from retries of one delivery, not from re-dispatch. - No ordering guarantee. Deliveries are independent. A retry of an earlier event can arrive after a later event, and two subscriptions on the same tenant are delivered independently. Order your own processing by the payload’s own fields (for example an order’s timestamps), not by arrival order.
- 10-second timeout. Each attempt aborts after 10 seconds and counts as a
failure. Acknowledge quickly with a
2xxand do slow work afterward.
The delivery ledger
Every attempt against every delivery is recorded. You read your own tenant’s
ledger with GET /v1/merchant/deliveries (scope manage:own_webhooks), which
returns rows carrying:
| Field | Meaning |
|---|---|
id | The delivery id. |
subscription_id | Which subscription this delivery belongs to. |
event_id / event_type | The event id and name. |
endpoint_url | The URL the delivery targets. |
payload | The exact envelope that was (or will be) sent. |
status | pending, delivered, or dead_lettered. |
attempt_count | How many attempts have been made. |
next_retry_at | When the next retry is due, while pending. |
last_status_code / last_attempt_error | The most recent HTTP status and error text. |
created_at / delivered_at / dead_lettered_at | Lifecycle timestamps. |
Response bodies from your endpoint are not stored — only the status code and error text of the last attempt.
Retries and dead-lettering
A failed attempt (a non-2xx status, a timeout, or a connection failure) is retried on a fixed ladder after the initial attempt:
1m -> 5m -> 30m -> 2h -> 12hAfter 6 total attempts (the initial attempt plus five retries) the delivery
is dead-lettered: it stops retrying and its row is set to
status: "dead_lettered" with dead_lettered_at stamped. A dead-lettered
delivery is a real, recorded terminal outcome — never a silent drop — and an
operator can redrive it. A retry that finds its subscription has been deleted is
dead-lettered immediately, since there is no URL left to POST to.
Auto-pause
Each subscription tracks consecutive_failures (visible on
GET /v1/merchant/webhooks). After 10 consecutive failed attempts the
subscription is deactivated automatically and auto_paused_at is set, so a dead
endpoint stops accumulating doomed deliveries. Any successful delivery resets the
counter to zero. Re-activate a paused subscription by fixing the endpoint and
re-registering.
Test deliveries
POST /v1/merchant/webhooks/:id/test fires a real signed delivery at your
endpoint — the same HMAC scheme, the same headers, the same ledger — carrying
the reserved event type platform.test.v1 and test: true in data. It lets
you verify your signature handling end-to-end before a real order ever flows.
The response reports the honest outcome: your endpoint’s HTTP status and measured
latency, or the connection error. A failed test is recorded dead_lettered
immediately (tests never enter the retry ladder), and test outcomes never touch
the consecutive-failure counter, so poking a broken endpoint cannot auto-pause
your real traffic.