Skip to content

Webhooks ​

当 shipment 事实变化时,用 Webhook 推动你的后端更新。M Express 按至少一次投递;你的接收端应分别完成验签、持久化、去重、快速确认和异步处理。

打开交互式流程图

Webhook 投递:M Express 向客户服务器发送事件,客户返回 2xx 确认收到;可选部分表示另行读取 EVT430 媒体。

图中只表达 M Express 与客户之间的接口交互。客户内部如何存储、排队、处理,以及处理耗时,均不由这张图规定。接收端要求和失败重试规则见下文。

文字等价:M Express 向客户服务器 POST 带签名的事件;客户返回 HTTP 2xx。若收到 EVT430 且需要媒体,客户可使用组合媒体凭据,另行 GET 事件中的媒体链接;M Express 返回照片或签名文件。2xx 表示确认收到,不表示客户内部处理已经完成。

先保留 raw body,再验签 ​

接收 POST 时要求 Content-Type: application/json。先保存未改动的 raw bytes,校验 Content-Type 和全部四个 X-Delivery-Webhook-* headers,再使用 X-Delivery-Webhook-Key-Id 查找对应的 signing secret 并验证请求:

Header用途
Content-TypeJSON media type。
X-Delivery-Webhook-Id用于去重的 event ID。
X-Delivery-Webhook-TimestampUnix epoch seconds,参与签名。
X-Delivery-Webhook-Key-Idsigning key identifier。
X-Delivery-Webhook-Signaturev1= HMAC-SHA256 signature。

签名输入必须是 <timestamp>.<event_id>.<raw_body>。使用 HMAC-SHA256 计算后,以 constant-time comparison 比较完整 v1= 值;timestamp 超过 300 秒容差就拒绝。只有验证完成后才 parse JSON。

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}

在任何业务副作用前持久化或 claim event_id。如果 claim 已存在,直接返回成功,不重复执行副作用。返回及时且未超出大小限制的任意 2xx 即可;没有必需的 ACK JSON 或 body。慢任务应在响应后异步执行。

读取 payload ​

每个 payload 的 version 都是 2026-07-01,并包含这些 required fields:

字段契约
versionstring 常量 2026-07-01。
event_id匹配 evt_[a-z0-9_]{1,92} 的 string;test event 为 evt_test_ 加 32 个小写十六进制字符。
event_type下表的兼容值之一;pod.bundle_available 使用独立的 EVT430 envelope。
event_code与 event type 对应的 EVT code。
shipment_idnormal event 为 UUID string;webhook.test 为 null。
tracking_numbershipment tracking string。
package_codeclient-safe package identifier string。
status必须符合下表对应事件。
status_description事件说明 string。
scanned_atUTC date-time,格式为 YYYY-MM-DDTHH:MM:SS.ffffffZ。
gpsobject,含 nullable string latitude、longitude、accuracy_meters;兼容 payload 可全部为 null。
pod_idUUID string 或 null;pod.completed 和 shipment.returned_to_sender 有值,test 也为 null。

Event、code 和允许的 status ​

Event typeCode允许的 status
webhook.testEVT000test
shipment.createdEVT010created
shipment.updatedEVT020任意 normal status
package.pickup_scannedEVT100picked_up
package.inbound_scannedEVT200in_warehouse
package.loadedEVT300out_for_delivery 或 return_to_sender
package.delivery_scannedEVT350delivered
pod.completedEVT400delivered
shipment.deliveredEVT410delivered
shipment.partially_deliveredEVT420delivered、returned_to_sender 或 cancelled
shipment.exceptionEVT500exception
shipment.return_requestedEVT600return_to_sender
shipment.returned_to_senderEVT700returned_to_sender

pod.bundle_available / EVT430 是独立的 POD-batch-scoped bundle envelope。M Express 只在一到三张照片完成 promotion 和 verification 后冻结 一个 event ID:evt_pod_bundle_{pod_id}。同一 Client 的 bundle 可以包含多个 shipment,并为每项提供 tracking_number 和 scanned_barcodes;这是唯一允许出现 barcode 的 webhook。事件提供受保护的媒体 link;只有在冻结时已验证的 signature 才会包含,晚到的 signature 不会修改或重新发送已冻结事件。只有照片时, payload 会省略 signer_name 和 signature,而不是发送 null。这个 bundle 以 pod_id 标识 POD batch;一次交付有多个 batch 时,每个 batch 各自发送一个事件, 接收端应分别处理。仅当已完成 batch 的 POD location status 为 captured,且坐标与精度 完整、有效时,payload 才会包含可选顶层字段 location。signed 和 unsigned bundle 都可以带此字段。字段缺失表示此事件没有可用的位置;旧 payload 也会省略它,因此接收端 不得要求该字段,也不要把缺失当成 null 或 false。之后更新 GPS 不会修改或重新发送 已冻结事件。字段存在时,status 为 captured,latitude、longitude 和 accuracy_meters 是从 Decimal 值序列化得到的 string。值缺失、无效、超界或 status 不是 captured 时,整个字段都会省略。该 location 只含 POD 完成位置,不含 raw scan、 device、location evidence 或 source,也不含司机当前实时位置。这个 bundle 独立于保持不变、 按 tracking_number 查询的 shipment-scoped Client POD manifest/media API;该 API 保留 自己的 nullable location 表示。

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"
    }
  ]
}

photos 必须包含一到三项已验证文件。可选 signature 使用同一受保护 route family,类型可以是 image/svg+xml、image/jpeg 或 image/png。使用组合媒体 credential Authorization: Bearer <signing_key_id>.<signing_secret> 获取这些 link;rotation、deactivation 或 deletion 会使旧 credential 失效。

Normal status 值是 created、picked_up、in_warehouse、out_for_delivery、delivered、exception、return_to_sender、returned_to_sender、cancelled。只有 test event 使用 test。

当前业务触发器 ​

新的 webhook fact 和 FMS 订阅只限以下七个触发器:

触发器Event typeCode产生条件不产生条件
包裹取件已接受package.pickup_scannedEVT100已接受的取件业务事件使包裹进入取件状态。仅创建/更新 Shipment,或只记录内部派送审计扫描。
包裹入库已接受package.inbound_scannedEVT200已接受的入库业务事件使包裹进入仓库状态。仅创建/更新 Shipment,或只记录内部派送审计扫描。
包裹装车/出车已接受package.loadedEVT300已接受的装车/出车业务事件使包裹进入派送状态。仅创建/更新 Shipment,或只记录内部派送审计扫描。
Shipment 已派送完成shipment.deliveredEVT410Shipment 的派送结果已完成,包括获授权的人工完成结果。只记录派送审计扫描;审计 scan_time 永远不会进入 webhook。
Shipment 部分派送shipment.partially_deliveredEVT420派送结果完成至少一个包裹,而另一个包裹仍未解决、已退回或已取消。只记录派送审计扫描。
每个包裹 POD 已完成pod.completedEVT400POD 流程已接受该包裹的 POD 完成。只有派送扫描或审计记录、没有被接受的 POD 完成。
POD bundle 可用pod.bundle_availableEVT430endpoint 已订阅,且一个 POD batch 的一到三张照片已 promotion 并 verification;该 batch 冻结一个事件。照片未验证,或历史 batch 不追补;冻结后的 GPS/signature 变化不会重发事件。

Shipment 的派送结果(EVT410 或 EVT420)与每个包裹的 POD 结果 (EVT400)是相互独立的 fact。两者可能先后任意到达,webhook 传输不保证 顺序;接收方应按 event_id 去重,必要时按 scanned_at 排列客户历史。 Shipment 创建/更新、exception、return request、return-to-sender、派送扫描 (EVT350)以及仅审计事件,都不会产生新的客户 webhook fact。上面的兼容 event/code pair 仍用于 payload 校验,并适用于已经冻结的历史 delivery。

event_types: [] 会关闭业务通知;显式列表只能选择七个 active trigger。新保存的 * 表示全部七个,FMS GET 会投影七个具体 event type。EVT430 引入前保存的 legacy wildcard 会迁移为原有六个显式 event,不会静默订阅新的 bundle event。

追踪一段真实 shipment journey ​

当一次被接受的 inbound scan 把 PKG-0001 移入仓库时,这一个 physical/business change 可能让 M Express 发布 package.inbound_scanned / EVT200,并带上 status: "in_warehouse"。payload 只描述客户可见的结果,不暴露 raw scan、内部 model 或 delivery job。

下面是代表性的 request headers。signature 特意写成“由 exact raw JSON bytes 计算得到的 HMAC”这一说明,不是可复用的 secret,也不是可以复制到生产请求的 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>

完整的 inbound payload 如下:

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
}

同一 shipment 完成 proof of delivery 后,M Express 可以发布 pod.completed / EVT400,并带上 status: "delivered"。这里的 pod_id 非空,因为事件指向 POD manifest;inbound event 的 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"
}

固定的 webhook.test payload 如下:

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
}

处理成功、重试和永久失败 ​

M Express 将任何未超出大小限制的 2xx 视为成功,不要求 ACK body。network error、connect/read/total timeout、408、425、429 和 5xx 属于 retryable。其他 4xx、destination blocked、非 public DNS、TLS validation failure、禁止 redirect、invalid frozen body,或超过 8192 bytes 的 response body 属于该次尝试的 permanent failure。

最多执行 8 次尝试。7 个重试延迟依次为 60、300、900、3600、10800、21600、43200 秒。接收端应快速返回,不要让 M Express 等待下游任务。

接收端验收 ​

  • [ ] 在 JSON parsing 前保留 raw bytes,并校验全部五个 headers、300 秒 timestamp 容差、v1= HMAC 与 constant-time comparison。
  • [ ] 业务副作用前 durable claim event_id,重复事件成为安全 no-op。
  • [ ] 用 scanned_at 展示客户历史,同时容忍至少一次投递造成的可能乱序。
  • [ ] 每个 event 使用匹配的 event/code/status 行。
  • [ ] 成功是及时且未超大的 2xx,慢任务异步执行。
  • [ ] 固定 webhook.test / EVT000 值可以通过接收端验收。

当 pod.completed 到达后,继续阅读 POD API 获取 manifest 和媒体。

M Express 服务端集成指南