Webhooks
The envelope, signature verification in four languages, delivery and retry semantics, and topics.
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#
{
"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.
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 });import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyDukkanWebhook(secret, headers, rawBody) {
const timestamp = headers["x-dukkan-timestamp"];
const signature = headers["x-dukkan-hmac-sha256"];
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(timestamp).update(".").update(rawBody).digest();
const received = Buffer.from(signature, "hex");
return expected.length === received.length && timingSafeEqual(expected, received);
}// Cloudflare Workers, Deno, Bun: request.arrayBuffer() gives the raw bytes.
export async function verifyDukkanWebhook(secret, request, rawBody) {
const timestamp = request.headers.get("x-dukkan-timestamp") ?? "";
const signature = request.headers.get("x-dukkan-hmac-sha256") ?? "";
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"],
);
const prefix = new TextEncoder().encode(timestamp + ".");
const message = new Uint8Array(prefix.length + rawBody.byteLength);
message.set(prefix, 0);
message.set(new Uint8Array(rawBody), prefix.length);
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, message));
const received = Uint8Array.from(signature.match(/../g) ?? [], (h) => parseInt(h, 16));
if (received.length !== mac.length) return false;
let diff = 0;
for (let i = 0; i < mac.length; i++) diff |= mac[i] ^ received[i];
return diff === 0;
}import hashlib
import hmac
import time
def verify_dukkan_webhook(secret: str, headers: dict, raw_body: bytes) -> bool:
timestamp = headers.get("x-dukkan-timestamp", "")
signature = headers.get("x-dukkan-hmac-sha256", "")
if not timestamp or not signature:
return False
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(
secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)<?php
function verifyDukkanWebhook(string $secret, array $headers, string $rawBody): bool
{
$timestamp = $headers['x-dukkan-timestamp'] ?? '';
$signature = $headers['x-dukkan-hmac-sha256'] ?? '';
if ($timestamp === '' || $signature === '') {
return false;
}
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}Danger
Never process a payload before verification succeeds, and never write the secret to logs. The secret is shown once, on creation or rotation; if it is lost, rotate it with rotate_secret: true.
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#
- Verify the signature, then answer
2xximmediately and process in the background; slow processing counts as a timeout. - Record
idin a unique store before processing; a conflict means a duplicate, so ignore it. - Keep the last processed
sequenceperstore_id; an event with a smaller sequence arrived late, and a larger one with a gap is normal. - 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:
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.