Webhooks
Events are POSTed to your registered URL as JSON. Every request carries an X-Merdi-Signature header. Respond 2xx within 5s or we retry with exponential backoff for 24h.
Event catalog
| Event | Description |
|---|---|
| shipment.created | Shipment booked, awaiting pickup. |
| shipment.picked_up | Pickup scan registered at origin. |
| shipment.in_transit | Departed origin country. |
| shipment.out_for_delivery | Out for delivery (final mile). |
| shipment.delivered | Delivered, with proof-of-delivery attached. |
| shipment.exception | Exception (customs hold, address invalid, etc). |
| shipment.returned | Returned to sender. |
| shipment.cancelled | Cancelled before pickup. |
Event payload
Every delivered request body is the envelope below. The top-level fields are event_id / event_type / livemode / created_at / data (not type / id). The event-specific payload lives entirely under data, whose fields depend on event_type. livemode is true for shipments created with an mk_live_* key, false in sandbox.
Headers on delivery: X-Merdi-Signature (t=<unix>,v1=<hex>) · X-Merdi-Event-Id · X-Merdi-Event-Type · Content-Type: application/json
{ "event_id": "evt_9c0f1d8e7b62", "event_type": "shipment.delivered", "livemode": false, "created_at": "2026-06-01T07:44:23.908Z", "data": { "tracking_number": "IM-2026-0528-9ZNCP3-1", "status": "delivered", "delivered_at": "2026-06-01T07:44:23.908Z" } } // event_type is one of: // shipment.created · shipment.picked_up · shipment.in_transit // shipment.out_for_delivery · shipment.exception // shipment.delivered · shipment.returned · shipment.cancelled // Fields under "data" are event-type-specific; tracking_number + status are always present.
Signature verification
The X-Merdi-Signature header has the form t=<unix>,v1=<hex>. Compute HMAC-SHA256 of `${ts}.${raw_body}` with your signing_secret and compare the v1 portion using a timing-safe comparator.
import crypto from 'node:crypto'; const verify = (rawBody, signature, secret) => { const [tPart, v1Part] = signature.split(','); const ts = tPart.split('=')[1]; const sig = v1Part.split('=')[1]; const exp = crypto.createHmac('sha256', secret) .update(`${ts}.${rawBody}`).digest('hex'); return crypto.timingSafeEqual(Buffer.from(exp), Buffer.from(sig)); };
⚠ Verify against the RAW body
The signature is computed over the raw request body bytes exactly as received. If your framework parses the body (Express bodyParser.json(), Next route handlers) and you verify against JSON.stringify(req.body), key order / whitespace can differ → the HMAC won't match → it fails every time. This is the #1 webhook-integration bug.
Get the raw body: Express express.raw({ type: 'application/json' }) · Next await req.text() · verify against that string, then JSON.parse separately for your business logic.
Deduplication (at-least-once)
Delivery is at-least-once: a retry carries the same X-Merdi-Event-Id (matching event_id in the body). Your receiver should record processed event IDs and, on a duplicate, return 200 without repeating the side-effect.
Retry policy
Non-2xx (or no response in 5s) is treated as failure. We retry with exponential backoff: 1m, 5m, 30m, 2h, 6h, 24h. After 24h the event is marked failed and surfaced in the dashboard.
Testing (real delivery)
Use POST /api/v1/webhooks/{id}/test to fire a synthetic event — Merdi actually POSTs to your registered URL. The response includes your endpoint's response code + latency. NOTE: if your URL isn't publicly reachable (private staging / localhost), Merdi can't reach it — use the offline verification below instead.
Offline signature verification (no public URL)
GET /api/v1/webhooks/{id}/sample returns a genuinely-signed sample event without delivering it anywhere — works on private staging, localhost, or CI. Feed the returned raw_body + headers["X-Merdi-Signature"] straight into your verifier and confirm, before production, that your HMAC check accepts a real Merdi signature.
# Fetch a real signed sample (no delivery — works behind NAT). curl https://merdiexpress.com/api/v1/webhooks/<webhook_id>/sample \ -H "Authorization: Bearer mk_test_..." | jq .data # → { # "raw_body": "{\"event_id\":\"...\",\"event_type\":\"shipment.delivered\",...}", # "payload": { ... parsed copy, DO NOT re-stringify to verify ... }, # "headers": { # "X-Merdi-Signature": "t=1700000000,v1=<hex>", # "X-Merdi-Event-Id": "...", # "X-Merdi-Event-Type": "shipment.delivered" # } # } # # Then in your test: verify(sample.raw_body, sample.headers["X-Merdi-Signature"], YOUR_SECRET) # should return true. If it does, your receiver is production-ready.