Webhooks

The merchant registers your URL and picks the events they want. We POST a signed JSON body to it and retry on failure. Verify the signature before you trust anything in the body.

Events

EventWhen it fires
order.placedA shopper completed checkout. The first moment an order exists.
order.state_changedAn order moved between states (e.g. shipped, cancelled). Carries fromState and toState.
product.createdA product was added.
product.updatedA product was edited.
product.deletedA product was removed.
customer.createdA customer account was created in this store.
customer.updatedA customer record changed.
return.requestedA shopper opened a return. The phone number is deliberately not included.
return.status_changedThe merchant moved a return forward (approved, refunded, closed…).
ticket.repliedA support ticket got a reply. Tells you a reply happened, not what it said.
shop.plan_changedThe store's subscription plan changed. Carries fromPlan and toPlan.
shipment.createdAn order was handed to a courier. The customer phone is deliberately not included.
shipment.status_changedA shipment moved (out for delivery, delivered, refused…). Carries fromStatus and toStatus.

The envelope

Every delivery has the same outer shape. `id` is unique per delivery — use it as your idempotency key, because a retry sends the same id.

{
  "id": "4821",
  "event": "order.placed",
  "createdAt": "2026-08-09T18:20:11.004Z",
  "shop": { "slug": "aleppo-textiles", "token": "aleppo-textiles-token" },
  "data": { "code": "MS2408-0042", "totalWithTax": 185000, "currencyCode": "SYP" }
}

Verifying the signature

The signature is `sha256=<hex hmac>` over the string `"<timestamp>.<raw body>"`, keyed by the subscription secret. Sign the RAW body — not a re-serialized object, whose key order and whitespace will differ.

HeaderMeaning
x-ms-signaturesha256=<hex hmac>
x-ms-timestampMilliseconds since the epoch; part of the signed string
x-ms-eventThe event name, so you can route before parsing
x-ms-delivery-idSame as the envelope id — your idempotency key
// Node — verify an eMatjarak webhook
import crypto from 'node:crypto';

function verify(rawBody, headers, secret) {
  const signature = headers['x-ms-signature'];
  const timestamp = headers['x-ms-timestamp'];
  if (!signature || !timestamp) return false;

  // Reject anything older than five minutes: a signature over the body alone
  // could be replayed forever, which is why the timestamp is signed too.
  if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) return false;

  const expected =
    'sha256=' +
    crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
<?php
// PHP — verify an eMatjarak webhook
function ms_verify(string $rawBody, array $headers, string $secret): bool {
    $signature = $headers['x-ms-signature'] ?? '';
    $timestamp = $headers['x-ms-timestamp'] ?? '';
    if ($signature === '' || $timestamp === '') return false;

    if (abs(time() * 1000 - (int) $timestamp) > 5 * 60 * 1000) return false;

    $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals($expected, $signature);
}
Compare in constant time (timingSafeEqual / hash_equals), and reject old timestamps. A signature check that leaks timing, or that ignores the timestamp, is not much of a check.

Retries and failure

  • A non-2xx response, a timeout or a connection error is a failure and will be retried with backoff.
  • Return 2xx as soon as you have stored the delivery. Do your work afterwards — a slow handler looks like a failure and earns you duplicates.
  • An endpoint that keeps failing is disabled automatically, and the merchant is shown that it was.

Receiver requirements

  • HTTPS, on a public address. Private, loopback and link-local addresses are rejected when the endpoint is saved and again on every delivery.
  • No redirects. A 302 is not followed — it is a failure.
  • Respond within the delivery timeout.