Webhooks
Use Webhooks when your backend needs M Express delivery events as they occur. Delivery is at least once and events can arrive out of order, so receiving, verification, deduplication, ordering, acknowledgement, and asynchronous work are one complete integration journey.
Before you receive an event
Provide an HTTPS receiver that can retain raw request bytes and persist an event record. Keep signing secrets server-side, indexed by key ID; do not put them in browser code, mobile apps, logs, or screenshots.
Build the receiver flow
- Read the unchanged raw request bytes before parsing JSON.
- Select the active signing secret from the key ID.
- Verify the HMAC-SHA256 signature over the raw bytes.
- Record
event_iddurably before triggering side effects. - Ignore a repeated
event_id; it must not cause a second business effect. - Return a quick, normal-size
2xx, then perform slower downstream work.
For customer-facing ordering, use the payload's scanned_at, not delivery arrival order. Persist event_id before a business side effect, then return a quick acknowledgement and run slower downstream work asynchronously.
Read the request and verify its signature
The request includes Content-Type: application/json and these four webhook headers:
| Header | Purpose |
|---|---|
X-Delivery-Webhook-Id | The event identifier used for deduplication. |
X-Delivery-Webhook-Timestamp | Unix epoch seconds included in the HMAC input. |
X-Delivery-Webhook-Key-Id | Identifies which active signing secret to use. |
X-Delivery-Webhook-Signature | A versioned v1= HMAC-SHA256 value. |
POST <your receiver URL>
Content-Type: application/json
X-Delivery-Webhook-Id: evt_...
X-Delivery-Webhook-Timestamp: 1751422800
X-Delivery-Webhook-Key-Id: whk_...
X-Delivery-Webhook-Signature: v1=...The exact HMAC-SHA256 input is:
<timestamp>.<event_id>.<raw_body>raw_body is the unchanged request data. Reject an invalid signature before parsing or acting on the JSON body. The examples are contract examples: they show the verification operation, not a complete HTTP receiver.
import crypto from "node:crypto";
function validWebhook({ timestamp, eventId, signature, secret, rawBody }) {
const input = Buffer.concat([
Buffer.from(`${timestamp}.${eventId}.`, "utf8"),
rawBody,
]);
const expected = `v1=${crypto.createHmac("sha256", secret).update(input).digest("hex")}`;
const received = Buffer.from(signature, "utf8");
const wanted = Buffer.from(expected, "utf8");
return received.length === wanted.length && crypto.timingSafeEqual(received, wanted);
}import hashlib
import hmac
def valid_webhook(timestamp, event_id, signature, secret, raw_body):
signing_input = b".".join((
timestamp.encode("ascii"), event_id.encode("utf-8"), raw_body,
))
expected = "v1=" + hmac.new(
secret.encode("utf-8"), signing_input, hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, expected)Acknowledge safely
| Receiver result | Delivery outcome |
|---|---|
Any normal-size 2xx | Acknowledged. |
408, 425, 429, or 5xx | Retryable. |
3xx or any other 4xx | Permanent failure. |
Response body larger than 8192 bytes, for any status | Permanent failure. |
Persist event_id before processing side effects, then return the acknowledgement before slow downstream work. This keeps retry handling safe if a worker is delayed or restarted.
Event catalogue
webhook.test is an acceptance event for verifying your receiver. Every other entry is a business event. pod.completed is only an event name here; it does not expose POD media through this public guide.
| Event | Code |
|---|---|
webhook.test | EVT000 |
shipment.created | EVT010 |
shipment.updated | EVT020 |
package.pickup_scanned | EVT100 |
package.inbound_scanned | EVT200 |
package.loaded | EVT300 |
package.delivery_scanned | EVT350 |
pod.completed | EVT400 |
shipment.delivered | EVT410 |
shipment.partially_delivered | EVT420 |
shipment.exception | EVT500 |
shipment.return_requested | EVT600 |
shipment.returned_to_sender | EVT700 |
Verification checklist
- [ ] The receiver retains raw bytes before JSON parsing.
- [ ] All four headers are present and HMAC uses
<timestamp>.<event_id>.<raw_body>with constant-time comparison. - [ ]
event_idis durable and idempotent before any side effect. - [ ]
scanned_at, not arrival time, controls customer-visible ordering. - [ ] Acknowledgements are
2xxwith at most8192response bytes. - [ ]
webhook.test/EVT000completes successfully before release.
Next step
Return to Get started for the production release checklist, or use Public Tracking for customer lookup.