Merchable APIReferenceGetting startedAuthentication & scopesDesignsPlacing an orderWebhooksPagination, errors & rate limitsVersioning & changelog

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"
}

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