ReferenceWebhooks

Webhooks

The platform can POST signed deliveries to your endpoint when things happen in your store. Subscriptions are managed with your fdk_ credential (scope manage:own_webhooks) via POST /v1/merchant/webhooks — see the Tenant-facing API. This page describes what actually arrives at your endpoint and how to verify it.

Events you can subscribe to

Exactly one event is deliverable today:

EventFires when
commerce.order.placed.v1An order is placed (payment captured).

More events are planned; the API rejects any subscription naming an event outside this catalog with a 400, so you will never silently subscribe to something that cannot fire. The reserved event type platform.test.v1 is used only for test deliveries and cannot be subscribed to.

Delivery envelope

Every delivery is a JSON POST with this shape:

{
  "id": "01J9…",
  "type": "commerce.order.placed.v1",
  "tenant_ref": "nova-peptide",
  "created_at": "2026-09-11T17:04:05.000Z",
  "data": { "…": "the event payload" }
}
  • id — the event id; your idempotency key. Retries re-send the same envelope bytes with the same id, so dedupe on it.
  • 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 does not change it.
  • data — the event payload.

Headers

HeaderMeaning
x-webhook-event-idThe event id (same as the envelope’s id).
x-webhook-event-typeThe event name (same as the envelope’s type).
x-webhook-timestampUnix seconds at delivery-attempt time. A retry gets a fresh timestamp (and a fresh v1 signature).
x-webhook-signature-v1t=<ts>,v1=<hex> — the timestamped signature to verify (below).
x-webhook-signatureDeprecated. Legacy bare-hex HMAC-SHA256 of the raw body only — no replay protection. Sent during the migration window; do not build new integrations on it.
x-webhook-delivery-attempt1-based attempt number for this delivery.

Verifying a delivery

The v1 scheme signs ${timestamp}.${rawBody} with your subscription secret (HMAC-SHA256, hex). The timestamp is bound into the signature, so once the signature verifies you can trust the timestamp for a staleness check — reject anything older than a few minutes and a captured delivery cannot be replayed later.

Your subscription secret (whsec_…) is returned exactly once, in the POST /v1/merchant/webhooks response. It is never readable again — if you lose it, delete the subscription and create a new one.

import { createHmac, timingSafeEqual } from "node:crypto";
 
const TOLERANCE_SECONDS = 5 * 60;
 
/**
 * @param rawBody  the EXACT request body bytes as a string — do not
 *                 re-serialize parsed JSON, the signature is over the wire
 *                 bytes.
 */
function verifyWebhook(rawBody, headers, secret) {
  const header = headers["x-webhook-signature-v1"];
  if (typeof header !== "string") return false;
 
  // Parse `t=<ts>,v1=<hex>`.
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=", 2)),
  );
  const ts = Number(parts.t);
  const received = parts.v1;
  if (!Number.isInteger(ts) || typeof received !== "string") return false;
 
  // Constant-time compare of the expected digest.
  const expected = createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`)
    .digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(received, "hex");
  if (a.length !== b.length || !timingSafeEqual(a, b)) return false;
 
  // Only NOW trust the timestamp (it was signed): reject stale deliveries.
  const ageSeconds = Math.abs(Date.now() / 1000 - ts);
  return ageSeconds <= TOLERANCE_SECONDS;
}

Respond with any 2xx status within 10 seconds — the delivery attempt times out after that and counts as a failure. Do your processing after acknowledging if it might be slow.

Retries and dead-lettering

A failed attempt (non-2xx, timeout, or connection failure) is retried on a fixed ladder after the initial attempt:

1m -> 5m -> 30m -> 2h -> 12h

After 6 total attempts the delivery is dead-lettered: it stops retrying and is recorded with status: "dead_lettered" in the delivery ledger (GET /v1/merchant/deliveries), never silently dropped. An operator can redrive a dead-lettered delivery.

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 — a dead endpoint does not accumulate months of doomed deliveries. Any successful delivery resets the counter. Test deliveries never count toward this threshold.

Test deliveries

POST /v1/merchant/webhooks/:id/test fires a real signed delivery at your endpoint — same HMAC scheme, same headers, same delivery ledger — with the reserved event type platform.test.v1 and test: true in the payload, so you can verify your signature handling end-to-end before any real order 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 never counts toward auto-pause.