Webhooks
Use Webhooks to update your backend when shipment facts change. M Express delivers at least once, so verify, persist, deduplicate, acknowledge, and process each event as separate receiver responsibilities.
Webhook delivery: M Express sends an event to your server; your server returns 2xx. The optional section shows a separate request for EVT430 media.
Only exchanges across the M Express/customer boundary are shown. Customer storage, queues, processing order, and processing duration are implementation choices, not steps prescribed by this diagram. Receiver requirements and retry behavior are explained below.
Text equivalent: M Express sends a signed event by POST; your server confirms receipt with HTTP 2xx. For EVT430, if media is needed, your server can separately GET an event media URL using the composite media credential; M Express returns photo or signature bytes. The acknowledgement does not mean that customer processing has finished.
Receive and verify before parsing
Accept POST with Content-Type: application/json. Preserve the exact raw bytes. Validate Content-Type and all four X-Delivery-Webhook-* headers, then use X-Delivery-Webhook-Key-Id to look up the matching signing secret before verifying the request:
| Header | Meaning |
|---|---|
Content-Type | JSON media type. |
X-Delivery-Webhook-Id | Event ID used for deduplication. |
X-Delivery-Webhook-Timestamp | Unix epoch seconds included in the signing input. |
X-Delivery-Webhook-Key-Id | Signing-key identifier. |
X-Delivery-Webhook-Signature | v1= HMAC-SHA256 signature. |
The exact signing input is <timestamp>.<event_id>.<raw_body>. Compute HMAC-SHA256, compare the complete v1= value in constant time, and reject a timestamp outside the 300-second receiver tolerance. Parse JSON only after these checks.
function lookupWebhookSecret(secretByKeyId, keyId) {
if (!keyId || !secretByKeyId) return undefined;
if (typeof secretByKeyId === "function") return secretByKeyId(keyId);
if (secretByKeyId instanceof Map) return secretByKeyId.get(keyId);
if (
typeof secretByKeyId === "object" &&
Object.hasOwn(secretByKeyId, keyId)
) {
return secretByKeyId[keyId];
}
return undefined;
}
function validTimestamp(timestamp, now) {
const value = Number(timestamp);
return (
/^\d+$/.test(String(timestamp)) &&
Number.isFinite(value) &&
Math.abs(now - value) <= WEBHOOK_CLOCK_TOLERANCE_SECONDS
);
}
export function verifyWebhookSignature({
secretByKeyId,
rawBody,
eventId,
timestamp,
keyId,
signature,
now = Math.floor(Date.now() / 1000),
} = {}) {
const secret = lookupWebhookSecret(secretByKeyId, keyId);
if (!secret || !eventId || !validTimestamp(timestamp, now)) return false;
const expected = `v1=${createHmac("sha256", secret)
.update(`${timestamp}.${eventId}.`)
.update(bytes(rawBody))
.digest("hex")}`;
const actual = String(signature ?? "");
const expectedBytes = Buffer.from(expected);
const actualBytes = Buffer.from(actual);
return expectedBytes.length === actualBytes.length && timingSafeEqual(expectedBytes, actualBytes);
}
export async function receiveWebhook({
rawBody,
headers,
secretByKeyId,
eventStore,
now = Math.floor(Date.now() / 1000),
} = {}) {
const contentType = header(headers, "Content-Type");
const eventId = header(headers, "X-Delivery-Webhook-Id");
const timestamp = header(headers, "X-Delivery-Webhook-Timestamp");
const keyId = header(headers, "X-Delivery-Webhook-Key-Id");
const signature = header(headers, "X-Delivery-Webhook-Signature");
if (
typeof contentType !== "string" ||
contentType.split(";", 1)[0].trim().toLowerCase() !== "application/json"
) {
throw new Error("Webhook Content-Type must be application/json.");
}
if (
!verifyWebhookSignature({
secretByKeyId,
rawBody,
eventId,
timestamp,
keyId,
signature,
now,
})
) {
throw new Error("Invalid webhook signature, timestamp, or key id.");
}
const payload = JSON.parse(new TextDecoder().decode(bytes(rawBody)));
if (!eventStore || typeof eventStore.claim !== "function") {
throw new Error("eventStore.claim is required for durable idempotency.");
}
const firstDelivery = await eventStore.claim(eventId, payload);
return { status: 200, duplicate: !firstDelivery, event: payload };
}def _lookup_webhook_secret(secret_by_key_id, key_id: str):
if not key_id or not secret_by_key_id:
return None
if callable(secret_by_key_id):
return secret_by_key_id(key_id)
return secret_by_key_id.get(key_id)
def verify_webhook_signature(
*,
secret_by_key_id,
raw_body: bytes,
event_id: str,
timestamp: str,
key_id: str,
signature: str,
now: int | None = None,
) -> bool:
secret = _lookup_webhook_secret(secret_by_key_id, key_id)
if not secret or not event_id or not isinstance(timestamp, str) or not timestamp.isdigit():
return False
now = int(time.time()) if now is None else now
try:
timestamp_value = int(timestamp)
except (TypeError, ValueError):
return False
if abs(now - timestamp_value) > WEBHOOK_CLOCK_TOLERANCE_SECONDS:
return False
signed = f"{timestamp}.{event_id}.".encode() + raw_body
expected = "v1=" + hmac.new(
secret.encode(), signed, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, str(signature or ""))
def receive_webhook(
*, raw_body: bytes, headers, secret_by_key_id, event_store, now: int | None = None
):
content_type = _header(headers, "Content-Type")
event_id = _header(headers, "X-Delivery-Webhook-Id")
timestamp = _header(headers, "X-Delivery-Webhook-Timestamp")
key_id = _header(headers, "X-Delivery-Webhook-Key-Id")
signature = _header(headers, "X-Delivery-Webhook-Signature")
if (
not isinstance(content_type, str)
or content_type.split(";", 1)[0].strip().lower() != "application/json"
):
raise ValueError("Webhook Content-Type must be application/json.")
if not verify_webhook_signature(
secret_by_key_id=secret_by_key_id,
raw_body=raw_body,
event_id=event_id or "",
timestamp=timestamp or "",
key_id=key_id or "",
signature=signature or "",
now=now,
):
raise ValueError("Invalid webhook signature, timestamp, or key id.")
payload = json.loads(raw_body.decode("utf-8"))
if not hasattr(event_store, "claim"):
raise TypeError("event_store.claim is required for durable idempotency.")
first_delivery = event_store.claim(event_id, payload)
return {"status": 200, "duplicate": not first_delivery, "event": payload}Persist or claim event_id before a business side effect. If the claim already exists, return success without applying the effect again. Return any timely, normal-sized 2xx; there is no required acknowledgement JSON or body. Queue slow work after that response.
At-least-once delivery and retries mean your receiver should tolerate duplicates and possible out-of-order arrival. Present customer history by payload scanned_at, not by request arrival time. This is receiver guidance, not a promise about transport ordering.
Read the payload
Every payload uses version 2026-07-01 and contains these required fields:
| Field | Contract |
|---|---|
version | String constant 2026-07-01. |
event_id | String matching evt_[a-z0-9_]{1,92}; test events use evt_test_ plus 32 lowercase hex characters. |
event_type | One of the compatible values below; pod.bundle_available uses the separate EVT430 envelope. |
event_code | The matching EVT code below. |
shipment_id | UUID string for normal events; null for webhook.test. |
tracking_number | Shipment tracking string. |
package_code | Client-safe package identifier string. |
status | Event-compatible status listed below. |
status_description | String explanation for the event. |
scanned_at | UTC date-time string in YYYY-MM-DDTHH:MM:SS.ffffffZ form. |
gps | Object with nullable string latitude, longitude, and accuracy_meters; current compatibility payloads may keep all three null. |
pod_id | UUID string for pod.completed and shipment.returned_to_sender; otherwise null, including test events. EVT430 carries its UUID as pod_id in its bundle envelope. |
The event/code/status contract is:
| Event type | Code | Allowed status |
|---|---|---|
webhook.test | EVT000 | test |
shipment.created | EVT010 | created |
shipment.updated | EVT020 | Any normal status |
package.pickup_scanned | EVT100 | picked_up |
package.inbound_scanned | EVT200 | in_warehouse |
package.loaded | EVT300 | out_for_delivery or return_to_sender |
package.delivery_scanned | EVT350 | delivered |
pod.completed | EVT400 | delivered |
shipment.delivered | EVT410 | delivered |
shipment.partially_delivered | EVT420 | delivered, returned_to_sender, or cancelled |
shipment.exception | EVT500 | exception |
shipment.return_requested | EVT600 | return_to_sender |
shipment.returned_to_sender | EVT700 | returned_to_sender |
pod.bundle_available / EVT430 is a separate POD-batch-scoped bundle envelope. M Express freezes exactly one event ID, evt_pod_bundle_{pod_id}, only after one to three photos are promoted and verified. A same-Client bundle may group multiple member shipments and includes each tracking_number with its scanned_barcodes; this is the only barcode exception. The event contains protected media links, and includes a signature only when it was verified at the freeze point. A late signature does not mutate or resend the frozen event. A photo-only payload omits signer_name and signature rather than sending them as null. If one delivery has multiple POD batches, each batch has its own pod_id and event; process each event independently. The optional top-level location is included only when that completed batch has captured status and complete, valid POD completion coordinates and accuracy. The same optional field can appear in either a signed or unsigned bundle. If it is absent, treat location as unavailable; older payloads also omit it, so receivers must not require it or interpret absence as null or false. When present, it has status: "captured" and string-valued latitude, longitude, and accuracy_meters serialized from Decimal values. Missing, invalid, out-of-range, or non-captured values omit the whole field. Updating GPS later does not change or resend an already-frozen event. location contains only completion coordinates; it excludes raw scan data, device data, location evidence or source, and live driver position. This bundle is independent of the unchanged shipment-scoped Client POD manifest/media API, which is queried by tracking_number and keeps its own nullable location representation.
{
"version": "1",
"event_id": "evt_pod_bundle_11111111-1111-4111-8111-111111111111",
"event_type": "pod.bundle_available",
"event_code": "EVT430",
"pod_id": "11111111-1111-4111-8111-111111111111",
"pod_completed_at": "2026-07-14T03:04:05.000000Z",
"location": {
"status": "captured",
"latitude": "-36.8484600",
"longitude": "174.7633320",
"accuracy_meters": "4.50"
},
"shipments": [
{
"tracking_number": "MX123456789",
"scanned_barcodes": [
"PKG-0001"
]
}
],
"photos": [
{
"photo_id": "33333333-3333-4333-8333-333333333333",
"url": "/api/v1/client/webhook-pods/11111111-1111-4111-8111-111111111111/photos/33333333-3333-4333-8333-333333333333/file/",
"content_type": "image/jpeg"
}
]
}The photos array contains one to three verified files. The optional signature uses the same protected route family and may be image/svg+xml, image/jpeg, or image/png. Fetch these links with the composite media credential Authorization: Bearer <signing_key_id>.<signing_secret>; rotation, deactivation, or deletion invalidates the prior credential.
Normal status values are created, picked_up, in_warehouse, out_for_delivery, delivered, exception, return_to_sender, returned_to_sender, and cancelled. The test event is the only test status.
Active business triggers
New webhook facts and FMS subscriptions are limited to these seven triggers:
| Trigger | Event type | Code | Emits when | Does not emit when |
|---|---|---|---|---|
| Package pickup accepted | package.pickup_scanned | EVT100 | An accepted pickup business event moves a package into pickup status. | A shipment is merely created/updated, or an internal delivery-audit scan is recorded. |
| Package inbound accepted | package.inbound_scanned | EVT200 | An accepted inbound business event moves a package into the warehouse. | A shipment is merely created/updated, or an internal delivery-audit scan is recorded. |
| Package loaded / load-out accepted | package.loaded | EVT300 | An accepted loading/load-out business event moves a package out for delivery. | A shipment is merely created/updated, or an internal delivery-audit scan is recorded. |
| Shipment delivered | shipment.delivered | EVT410 | The shipment delivery result is complete, including an authorized manual-delivery result. | A delivery-audit scan alone is recorded; audit scan_time is never a webhook field. |
| Shipment partially delivered | shipment.partially_delivered | EVT420 | A delivery result completes at least one package while another package remains unresolved, returned, or cancelled. | A delivery-audit scan alone is recorded. |
| Per-package POD completed | pod.completed | EVT400 | A package's POD completion is accepted by the POD workflow. | A delivery scan or audit record without accepted POD completion is recorded. |
| POD bundle available | pod.bundle_available | EVT430 | The endpoint is subscribed and one to three photos for a POD batch are promoted and verified; one event is frozen for that batch. | Photos are unverified or the batch is historical (no backfill); later GPS/signature changes never resend the frozen event. |
The shipment Delivery result (EVT410 or EVT420) and per-package POD result (EVT400) are independent facts. Either may arrive first, and webhook delivery does not guarantee ordering; deduplicate by event_id and order customer history with scanned_at when needed. Shipment create/update, exception, return-request, return-to-sender, delivery-scan (EVT350), and audit-only events do not create new customer webhook facts. All compatible event/code pairs above remain valid for payload validation and already-frozen deliveries.
An empty event_types list disables business notifications. An explicit list selects only the seven active triggers. A newly saved * means all seven and the FMS GET response projects the seven concrete event types. A wildcard stored before EVT430 was introduced is migrated to the six pre-existing explicit events, so it does not silently subscribe to the new bundle event.
Trace a real shipment journey
When an accepted inbound scan moves PKG-0001 into the warehouse, that one physical/business change can lead M Express to post package.inbound_scanned / EVT200 with status: "in_warehouse". The payload describes the customer-safe result; it does not expose the raw scan, an internal model, or a delivery job.
The following request shows representative headers. The signature is deliberately a notation for the HMAC computed from the exact raw JSON bytes, not a reusable secret or a copy-paste credential.
POST /webhooks/status HTTP/1.1
Content-Type: application/json
X-Delivery-Webhook-Id: evt_0123456789abcdef0123456789abcdef
X-Delivery-Webhook-Timestamp: 1783937472
X-Delivery-Webhook-Key-Id: customer-key-2026-07
X-Delivery-Webhook-Signature: v1=<computed-HMAC-SHA256-for-this-raw-body>The complete inbound payload is:
{
"version": "2026-07-01",
"event_id": "evt_0123456789abcdef0123456789abcdef",
"event_type": "package.inbound_scanned",
"event_code": "EVT200",
"shipment_id": "11111111-1111-1111-1111-111111111111",
"tracking_number": "MX123456789",
"package_code": "PKG-0001",
"status": "in_warehouse",
"status_description": "Parcel has arrived at sorting facility",
"scanned_at": "2026-07-13T10:11:12.123456Z",
"gps": {
"latitude": null,
"longitude": null,
"accuracy_meters": null
},
"pod_id": null
}When proof of delivery is completed for the same shipment, M Express can post pod.completed / EVT400 with status: "delivered". Here pod_id is non-null because the event points to the POD manifest; an inbound event has pod_id: null.
{
"version": "2026-07-01",
"event_id": "evt_abcdef0123456789abcdef0123456789",
"event_type": "pod.completed",
"event_code": "EVT400",
"shipment_id": "11111111-1111-1111-1111-111111111111",
"tracking_number": "MX123456789",
"package_code": "PKG-0001",
"status": "delivered",
"status_description": "Delivered",
"scanned_at": "2026-07-14T03:04:05.000000Z",
"gps": {
"latitude": null,
"longitude": null,
"accuracy_meters": null
},
"pod_id": "11111111-1111-4111-8111-111111111111"
}The fixed webhook.test payload is:
{
"version": "2026-07-01",
"event_id": "evt_test_00000000000040008000000000000001",
"event_type": "webhook.test",
"event_code": "EVT000",
"shipment_id": null,
"tracking_number": "TEST-WEBHOOK",
"package_code": "TEST-PACKAGE",
"status": "test",
"status_description": "Webhook test event",
"scanned_at": "2026-07-13T10:11:12.123456Z",
"gps": {
"latitude": null,
"longitude": null,
"accuracy_meters": null
},
"pod_id": null
}Respond to delivery outcomes
M Express treats any non-oversized 2xx as success. It does not require an acknowledgement body. Network errors, connect/read/total timeouts, and 408, 425, 429, or 5xx responses are retryable. Other 4xx, blocked destinations, non-public DNS, TLS validation failure, disallowed redirects, invalid frozen bodies, or a response body over 8192 bytes are permanent for that attempt.
There are at most eight attempts. The seven retry delays are 60, 300, 900, 3600, 10800, 21600, and 43200 seconds. Keep your receiver fast and bounded; do not make M Express wait for downstream processing.
Verify the receiver
- [ ] Raw bytes, all five headers, timestamp tolerance,
v1=HMAC, and constant-time comparison are checked before JSON parsing. - [ ]
event_idis durably claimed before a side effect, and duplicates are safe no-ops. - [ ] Customer history uses
scanned_at, while the receiver tolerates possible out-of-order arrival. - [ ] Every valid event uses the matching event/code/status row.
- [ ] Success is a timely normal-sized
2xx; slow work is asynchronous. - [ ]
webhook.test/EVT000passes with the fixed values above.
Continue with POD API when a pod.completed event tells your backend to fetch media.