Skip to content

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

  1. Read the unchanged raw request bytes before parsing JSON.
  2. Select the active signing secret from the key ID.
  3. Verify the HMAC-SHA256 signature over the raw bytes.
  4. Record event_id durably before triggering side effects.
  5. Ignore a repeated event_id; it must not cause a second business effect.
  6. 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:

HeaderPurpose
X-Delivery-Webhook-IdThe event identifier used for deduplication.
X-Delivery-Webhook-TimestampUnix epoch seconds included in the HMAC input.
X-Delivery-Webhook-Key-IdIdentifies which active signing secret to use.
X-Delivery-Webhook-SignatureA versioned v1= HMAC-SHA256 value.
http
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:

text
<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.

js
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);
}
python
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 resultDelivery outcome
Any normal-size 2xxAcknowledged.
408, 425, 429, or 5xxRetryable.
3xx or any other 4xxPermanent failure.
Response body larger than 8192 bytes, for any statusPermanent 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.

EventCode
webhook.testEVT000
shipment.createdEVT010
shipment.updatedEVT020
package.pickup_scannedEVT100
package.inbound_scannedEVT200
package.loadedEVT300
package.delivery_scannedEVT350
pod.completedEVT400
shipment.deliveredEVT410
shipment.partially_deliveredEVT420
shipment.exceptionEVT500
shipment.return_requestedEVT600
shipment.returned_to_senderEVT700

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_id is durable and idempotent before any side effect.
  • [ ] scanned_at, not arrival time, controls customer-visible ordering.
  • [ ] Acknowledgements are 2xx with at most 8192 response bytes.
  • [ ] webhook.test / EVT000 completes successfully before release.

Next step

Return to Get started for the production release checklist, or use Public Tracking for customer lookup.

M Express server-to-server integration guide