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-Type | JSON media type。 |
X-Delivery-Webhook-Id | 用于去重的 event ID。 |
X-Delivery-Webhook-Timestamp | Unix epoch seconds,参与签名。 |
X-Delivery-Webhook-Key-Id | signing key identifier。 |
X-Delivery-Webhook-Signature | v1= HMAC-SHA256 signature。 |
签名输入必须是 <timestamp>.<event_id>.<raw_body>。使用 HMAC-SHA256 计算后,以 constant-time comparison 比较完整 v1= 值;timestamp 超过 300 秒容差就拒绝。只有验证完成后才 parse JSON。
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}在任何业务副作用前持久化或 claim event_id。如果 claim 已存在,直接返回成功,不重复执行副作用。返回及时且未超出大小限制的任意 2xx 即可;没有必需的 ACK JSON 或 body。慢任务应在响应后异步执行。
读取 payload
每个 payload 的 version 都是 2026-07-01,并包含这些 required fields:
| 字段 | 契约 |
|---|---|
version | string 常量 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_id | normal event 为 UUID string;webhook.test 为 null。 |
tracking_number | shipment tracking string。 |
package_code | client-safe package identifier string。 |
status | 必须符合下表对应事件。 |
status_description | 事件说明 string。 |
scanned_at | UTC date-time,格式为 YYYY-MM-DDTHH:MM:SS.ffffffZ。 |
gps | object,含 nullable string latitude、longitude、accuracy_meters;兼容 payload 可全部为 null。 |
pod_id | UUID string 或 null;pod.completed 和 shipment.returned_to_sender 有值,test 也为 null。 |
Event、code 和允许的 status
| Event type | Code | 允许的 status |
|---|---|---|
webhook.test | EVT000 | test |
shipment.created | EVT010 | created |
shipment.updated | EVT020 | 任意 normal status |
package.pickup_scanned | EVT100 | picked_up |
package.inbound_scanned | EVT200 | in_warehouse |
package.loaded | EVT300 | out_for_delivery 或 return_to_sender |
package.delivery_scanned | EVT350 | delivered |
pod.completed | EVT400 | delivered |
shipment.delivered | EVT410 | delivered |
shipment.partially_delivered | EVT420 | delivered、returned_to_sender 或 cancelled |
shipment.exception | EVT500 | exception |
shipment.return_requested | EVT600 | return_to_sender |
shipment.returned_to_sender | EVT700 | returned_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 表示。
{
"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 type | Code | 产生条件 | 不产生条件 |
|---|---|---|---|---|
| 包裹取件已接受 | package.pickup_scanned | EVT100 | 已接受的取件业务事件使包裹进入取件状态。 | 仅创建/更新 Shipment,或只记录内部派送审计扫描。 |
| 包裹入库已接受 | package.inbound_scanned | EVT200 | 已接受的入库业务事件使包裹进入仓库状态。 | 仅创建/更新 Shipment,或只记录内部派送审计扫描。 |
| 包裹装车/出车已接受 | package.loaded | EVT300 | 已接受的装车/出车业务事件使包裹进入派送状态。 | 仅创建/更新 Shipment,或只记录内部派送审计扫描。 |
| Shipment 已派送完成 | shipment.delivered | EVT410 | Shipment 的派送结果已完成,包括获授权的人工完成结果。 | 只记录派送审计扫描;审计 scan_time 永远不会进入 webhook。 |
| Shipment 部分派送 | shipment.partially_delivered | EVT420 | 派送结果完成至少一个包裹,而另一个包裹仍未解决、已退回或已取消。 | 只记录派送审计扫描。 |
| 每个包裹 POD 已完成 | pod.completed | EVT400 | POD 流程已接受该包裹的 POD 完成。 | 只有派送扫描或审计记录、没有被接受的 POD 完成。 |
| POD bundle 可用 | pod.bundle_available | EVT430 | endpoint 已订阅,且一个 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。
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 如下:
{
"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。
{
"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 如下:
{
"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 和媒体。