Webhooks
Webhooks push an order's lifecycle events to your server so you never need to
poll. They are configured per order: give POST /v1/orders a webhook_url
and every event for that order is delivered there.
Setting a webhook
{
"external_reference": "PO-2026-0042",
"items": [ ... ],
"delivery": { ... },
"webhook_url": "https://example.com/merchable/orders",
"webhook_secret": "whsec_choose_a_long_random_value"
}
webhook_urlmust behttps://on a publicly resolvable host — private and internal addresses are rejected with422. Redirects are not followed.webhook_secretis optional. Deliveries for the order are signed with it; omit it and they are signed with the account signing secret instead (generate or rotate it under Developer → Webhook signing secret in the app). The secret is stored encrypted and never returned by the API —GET /v1/orders/{id}showswebhook.urlandwebhook.configured(whether the order carries its own secret) only.- Use the same URL for every order and tell them apart by
data.order.idor theexternal_referenceyou set when placing them.
Orders placed without a webhook_url send nothing.
Events
| Type | When |
|---|---|
order.submitted |
The order was placed |
order.approved |
Merchable approved it for production |
order.rejected |
Merchable rejected it (order is cancelled) |
order.in_production |
Sent to the supplier |
order.shipped |
A shipment left the supplier — one event per shipment, with tracking in data.event |
order.delivered |
The carrier reported delivery |
order.awaiting_collection |
Pickup items are ready in store |
order.completed |
The order was closed out |
order.cancelled |
The order was cancelled |
order.issue_opened / order.issue_closed |
An operations issue was raised or resolved |
GET /v1/orders/{id}/events lists the same events, so you can reconcile after
an outage.
Payload
{
"id": "evt_9f2c…",
"type": "order.shipped",
"api_version": "v1",
"created_at": "2026-09-12T03:04:05Z",
"account_id": "6a1d…",
"data": {
"order": { "...the GET /v1/orders/{id} representation..." },
"previous_status": "production",
"event": { "tracking_url": "https://…", "shipped_at": "…" }
}
}
Headers: Merchable-Event-Id, Merchable-Event-Type, Merchable-Delivery-Id,
Merchable-Signature, User-Agent: Merchable-Webhooks/1.0.
Verifying signatures
Every delivery is signed with HMAC-SHA256 over "<timestamp>.<raw body>":
Merchable-Signature: t=1726100000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
The key is the order's webhook_secret, or the account signing secret when the
order was placed without one.
import hmac, hashlib, time
def verify(header, secret, body: bytes, tolerance=300):
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts["t"])
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
const crypto = require("crypto");
function verify(header, secret, rawBody, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
Always compute the HMAC over the raw request body, before any JSON parsing.
Delivery and retries
- Respond with any
2xxwithin 10 seconds. The response body is ignored. - Anything else (including timeouts) is retried with backoff: 1 min, 5 min, 30 min,
2 h, 12 h, 24 h, 24 h — 8 attempts over about three days — after which the
delivery is marked
deadand Merchable is alerted. - Deliveries are at-least-once. Use
Merchable-Event-Id(oridin the body) to de-duplicate. Events for one order are dispatched in order but may arrive out of order after retries — usedata.order.statusas the source of truth.