Events
| Event | When it fires |
|---|---|
| order.placed | A shopper completed checkout. The first moment an order exists. |
| order.state_changed | An order moved between states (e.g. shipped, cancelled). Carries fromState and toState. |
| product.created | A product was added. |
| product.updated | A product was edited. |
| product.deleted | A product was removed. |
| customer.created | A customer account was created in this store. |
| customer.updated | A customer record changed. |
| return.requested | A shopper opened a return. The phone number is deliberately not included. |
| return.status_changed | The merchant moved a return forward (approved, refunded, closed…). |
| ticket.replied | A support ticket got a reply. Tells you a reply happened, not what it said. |
| shop.plan_changed | The store's subscription plan changed. Carries fromPlan and toPlan. |
| shipment.created | An order was handed to a courier. The customer phone is deliberately not included. |
| shipment.status_changed | A 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.
| Header | Meaning |
|---|---|
| x-ms-signature | sha256=<hex hmac> |
| x-ms-timestamp | Milliseconds since the epoch; part of the signed string |
| x-ms-event | The event name, so you can route before parsing |
| x-ms-delivery-id | Same 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.