Skip to content

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.

Open interactive diagram

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:

HeaderMeaning
Content-TypeJSON media type.
X-Delivery-Webhook-IdEvent ID used for deduplication.
X-Delivery-Webhook-TimestampUnix epoch seconds included in the signing input.
X-Delivery-Webhook-Key-IdSigning-key identifier.
X-Delivery-Webhook-Signaturev1= 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.

js
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 };
}
py
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:

FieldContract
versionString constant 2026-07-01.
event_idString matching evt_[a-z0-9_]{1,92}; test events use evt_test_ plus 32 lowercase hex characters.
event_typeOne of the compatible values below; pod.bundle_available uses the separate EVT430 envelope.
event_codeThe matching EVT code below.
shipment_idUUID string for normal events; null for webhook.test.
tracking_numberShipment tracking string.
package_codeClient-safe package identifier string.
statusEvent-compatible status listed below.
status_descriptionString explanation for the event.
scanned_atUTC date-time string in YYYY-MM-DDTHH:MM:SS.ffffffZ form.
gpsObject with nullable string latitude, longitude, and accuracy_meters; current compatibility payloads may keep all three null.
pod_idUUID 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 typeCodeAllowed status
webhook.testEVT000test
shipment.createdEVT010created
shipment.updatedEVT020Any normal status
package.pickup_scannedEVT100picked_up
package.inbound_scannedEVT200in_warehouse
package.loadedEVT300out_for_delivery or return_to_sender
package.delivery_scannedEVT350delivered
pod.completedEVT400delivered
shipment.deliveredEVT410delivered
shipment.partially_deliveredEVT420delivered, returned_to_sender, or cancelled
shipment.exceptionEVT500exception
shipment.return_requestedEVT600return_to_sender
shipment.returned_to_senderEVT700returned_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.

jsonc
{
  "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:

TriggerEvent typeCodeEmits whenDoes not emit when
Package pickup acceptedpackage.pickup_scannedEVT100An 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 acceptedpackage.inbound_scannedEVT200An 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 acceptedpackage.loadedEVT300An 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 deliveredshipment.deliveredEVT410The 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 deliveredshipment.partially_deliveredEVT420A 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 completedpod.completedEVT400A package's POD completion is accepted by the POD workflow.A delivery scan or audit record without accepted POD completion is recorded.
POD bundle availablepod.bundle_availableEVT430The 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.

http
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:

json
{
  "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.

json
{
  "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:

json
{
  "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_id is 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 / EVT000 passes with the fixed values above.

Continue with POD API when a pod.completed event tells your backend to fetch media.

M Express server-to-server integration guide