Skip to content

Webhooks

The envelope, signature verification in four languages, delivery and retry semantics, and topics.

Updated 2 Sept 20264 min read
On this page

This page is for anyone receiving store events. By the end you have an endpoint that verifies the signature, deduplicates, orders events, and you know exactly what happens on failure.

Every event on a store where your app is installed arrives as a signed POST to that install's single endpoint. Delivery is at-least-once; always handle duplicates.

The envelope#

webhook-envelope.jsonJSON
{
  "id": "9b2f6c1e-7d3a-4f0b-9a52-1c8e2d4b6a70",
  "api_version": "v1",
  "topic": "order.created",
  "store_id": "3065a1f2-0c4d-4e8b-b7a9-5d2f8c1e9a44",
  "install_id": "8b2c1d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d",
  "sequence": 1661,
  "occurred_at": "2026-08-18T16:00:00.123Z",
  "data": {
    "order_id": "6f1d2c3b-4a59-4e8f-9b70-2d1c3e4f5a6b",
    "order_number": "1042",
    "status": "placed",
    "total_minor": 275000,
    "currency": "SYP"
  }
}
Field Meaning
id The stable event id. Your dedupe key; it never changes across attempts.
api_version Always v1 (the contract). The dated revision is in the X-Dukkan-Api-Version header.
topic The topic; the table is in Topics.
store_id The store where the event happened. Refuse a value that differs from the store you hold for install_id.
install_id The install this delivery was fanned out to. Look up the signing secret by it, never by a path segment you chose. Inside the signature.
sequence A per-store monotonic counter with gaps allowed. Order late arrivals by it; never count on contiguity.
occurred_at Always RFC 3339.
test Present and true only on a sandbox rehearsal fired through POST /webhooks/test; absent in production traffic.
data The topic payload; shapes are in the events reference.

Note

Order payloads carry the amount as total_minor with currency and no decimals. Fetch the order with GET /orders/{id} when you need the exponent for display; see Money in minor units.

Headers#

Header Example Meaning
X-Dukkan-Delivery-Id 7d0c2b1a-5e4f-4a3b-8c9d-0e1f2a3b4c5d The delivery id; stable across attempts of the same delivery
X-Dukkan-Install-Id 8b2c1d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d Unsigned copy of the envelope's install_id, so you can load the secret before verifying
X-Dukkan-Event order.created The same topic as in the envelope
X-Dukkan-Timestamp 1787150000 Unix seconds at send time
X-Dukkan-Api-Version 2026-09-09 The dated v1 revision that produced the payload
X-Dukkan-Hmac-Sha256 3f9a… (hex) The signature

Verify the signature#

The signature is HMAC-SHA256 with your dk_whsec_… secret over the string timestamp.body: the timestamp, a dot, then the raw request bytes exactly as received. Never re-serialize the JSON before verifying. Reject timestamps older than 5 minutes (replay protection) and compare in constant time.

TypeScript
import { verifyWebhookSignature } from "@dukkan.one/app-sdk/webhooks";

const verdict = await verifyWebhookSignature({
  rawBody,                                  // the request bytes, never re-serialised
  timestamp: headers.get("x-dukkan-timestamp"),
  signature: headers.get("x-dukkan-hmac-sha256"),
  secret,                                   // or [current, previous] during a rotation
});
if (!verdict.ok) return new Response(JSON.stringify({ error: verdict.reason }), { status: 401 });

Delivery and retries#

The canonical statement of these semantics is the webhooks.storeEvent description in the API specification: "Respond 2xx within the timeout; anything else is retried with exponential backoff up to 8 attempts, after which the delivery is dead-lettered and repeated failures disable the subscription." The numbers below are what the merchant runtime implements today:

Item Value
Timeout per attempt 10 seconds
Success any 2xx; the body is ignored
Retry schedule exponential backoff starting at one minute, doubling, capped at one hour: 1, 2, 4, 8, 16, 32, 60, 60 minutes
Attempts per delivery 8, then the delivery becomes dead
Subscription disabled when at least 8 consecutive failures and failures spanning at least 24 hours hold together; neither alone is enough
While disabled events keep accumulating for the subscription and are not lost; only sending pauses
Re-enable PUT /webhooks again; the backlog is delivered
Manual replay from the app page in the portal, one extra attempt per dead delivery
Ordering one in-flight delivery per install at a time; a slow delivery delays only what follows it in the same store
Retention delivery records are pruned after 30 days

The idempotent handler pattern#

  1. Verify the signature, then answer 2xx immediately and process in the background; slow processing counts as a timeout.
  2. Record id in a unique store before processing; a conflict means a duplicate, so ignore it.
  3. Keep the last processed sequence per store_id; an event with a smaller sequence arrived late, and a larger one with a gap is normal.
  4. When in doubt, fetch current state from the API instead of trusting arrival order.

Reconcile nightly#

Webhooks are the fast path, not the only path. Once a day, walk everything that changed since your last high-water mark and upsert it; a delivery that was dead-lettered or a handler that failed silently is corrected within a day instead of never:

TypeScript
import { syncOrders } from "@dukkan.one/app-sdk/commerce";

const result = await syncOrders(client, {
  since: lastHighWaterMark,
  onPage: async (orders) => { for (const order of orders) await upsert(order); },
});
await save(result.highWaterMark);

updated_at_min on the list endpoints is the primitive; the SDK helper adds a one-minute overlap so a record updated during the previous run is never skipped.

Topics#

Topic Required scope When
order.created orders:read an order is created from any source
order.status_changed orders:read every order status transition
order.paid orders:read when actually-collected funds cross the order total in the payments ledger
fulfillment.requested orders:read a fulfillment is created as pending: a pickup waiting to be booked
fulfillment.created orders:read any fulfillment is created
fulfillment.updated orders:read a fulfillment's status or tracking number changes
refund.created orders:read a refund is recorded
product.created, product.updated, product.deleted products:read a product or one of its variants changes
inventory.movement_created inventory:read a new inventory movement
app.uninstalled none the install is revoked; arrives even after tokens are revoked

The data payload of every topic is in the events reference. order.paid is fully decoupled from delivery; see the cash-on-delivery lifecycle.

Manage the subscription#

Operation Effect
GET /webhooks the current subscription or null; never returns the secret
PUT /webhooks creates or fully replaces the subscription (the whole topic list); rotate_secret: true mints a new secret shown once
DELETE /webhooks stops delivery for this install

endpoint_url must be a public HTTPS URL; private and loopback addresses are refused. Subscribing to a topic requires the scope that governs it, otherwise 403.