包裹状态回调(Webhook)
接口概述
每当包裹状态发生变化,WMG 会把新状态推送到你登记的 URL。这与本文档中其他所有接口方向相反:WMG 是客户端,你的端点是服务端。
推送使用 HMAC-SHA256 签名,便于你验证请求确实来自 WMG。你的端点必须以 {"success": true} 确认每次推送;返回其他内容都会被判定为投递失败,该事件之后会被重新推送。
NOTE
回调 URL 和 Webhook 密钥在后台配置,位置见下图。

请求信息
- Method: POST
- Path: 你登记的回调 URL
- Authentication: HMAC-SHA256 签名(
x-wmg-hmac-sha256头) - 方向: WMG → 你的端点
请求头
| 字段 | 说明 |
|---|---|
| Content-Type | application/json |
| x-wmg-timestamp | 生成本次推送时的 UNIX 时间戳(10 位,秒)。以字符串形式发送,并以字符串参与签名。 |
| x-wmg-hmac-sha256 | 请求体的 HMAC-SHA256 签名,Base64 编码,见 认证方式。 |
请求参数
请求体为 JSON 格式,包含以下字段:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| tracking_num | string | 是 | 包裹的 WMG 运单号。 |
| custom_tracking_num | string | 是 | 你自己的包裹跟踪号。包裹没有时为空字符串。 |
| status | string | 是 | 状态码,如 DR_DR、OK_OK——见 物流状态码。 |
| remark | string | 否 | 该状态码的标准描述文本。该状态码没有文本时为空字符串。 |
| status_time | integer | 是 | 状态事件本身发生的 UNIX 时间戳(秒)。以数字发送,不是字符串。 |
| files | array<string> | 否 | 该事件附带文件的完整 http/https URL,例如 ePOD 照片。没有时为空数组。 |
请求体示例
{
"tracking_num": "CY180000662SG",
"custom_tracking_num": "CUST0001234567",
"status": "OK_OK",
"remark": "Item has been delivered",
"status_time": 1784128800,
"files": [
"https://files.wmgdelivery.com/epod/CY180000662SG-1.jpg"
]
}签名串示例
参与签名的字符串是「请求体字段 + 以 x-wmg 开头的请求头(排除 x-wmg-hmac-sha256)」合并后按键名升序排序,再按 PHP 默认转义规则序列化为紧凑 JSON——正斜杠转义为 \/,非 ASCII 字符转义为 \uXXXX:
{"custom_tracking_num":"CUST0001234567","files":["https:\/\/files.wmgdelivery.com\/epod\/CY180000662SG-1.jpg"],"remark":"Item has been delivered","status":"OK_OK","status_time":1784128800,"tracking_num":"CY180000662SG","x-wmg-timestamp":"1784128805"}注意两种不同的值类型:status_time 是不带引号的整数,而 x-wmg-timestamp 是带引号的字符串。
认证方式
每次推送都带有签名,用你的 Webhook 密钥验证:
Signature = Base64( HMAC-SHA256( JSON String, Secret Key ) )消息在前、密钥在后,与 PHP hash_hmac($algo, $data, $key) 的参数顺序一致。
重建该 JSON 字符串的步骤:
- 取请求体的全部字段。
- 加入所有以
x-wmg开头的请求头,排除x-wmg-hmac-sha256。实际上就是x-wmg-timestamp,其值以字符串参与签名。 - 对合并后的映射按键名升序排序(ASCII 顺序)。只排顶层。
- 序列化为紧凑 JSON,正斜杠转义为
\/,非 ASCII 转义为\uXXXX。 - 用你的密钥对该字符串计算 HMAC-SHA256,取原始二进制摘要,再做 Base64 编码。
- 用恒定时间比较把结果与
x-wmg-hmac-sha256对比。
转义规则不是各语言的默认行为
第 4 步对应的是 PHP json_encode() 不带任何 JSON_UNESCAPED_* 标志的行为。多数其他语言默认恰好相反:Python 和 JavaScript 都不转义正斜杠,JavaScript 连非 ASCII 也不转义。由于 files 里始终是 URL,只要漏了 / 的转义,签名必然对不上。下面的示例都显式做了这两种转义。
响应信息
你的端点返回的响应为 JSON 格式。
响应格式
| 字段 | 类型 | 说明 |
|---|---|---|
| success | boolean | true 表示已接收并处理成功。其他值或缺失该字段都算失败。 |
| msg | string | 可选。失败时该文本会被 WMG 记录为失败原因。也接受 message 作为别名。 |
成功响应
- Status Code: 200
{
"success": true
}错误响应
返回不含 success: true 的响应体。请附上原因,便于在 WMG 的投递日志中排查:
{
"success": false,
"msg": "signature mismatch"
}结果码
本页描述的是推送给你的请求,因此标准的 {Code, Message, Data} 结构不适用。WMG 根据你响应体中的 success 字段判定结果:
| success | 说明 |
|---|---|
true | 投递成功,该事件不会再次推送。 |
| 其他值或缺失 | 投递失败,该事件会进入重推队列。 |
示例
以下示例用于验证收到的推送。body 是你收到的原始请求体,timestamp 是 x-wmg-timestamp 头的值。
Bash
SECRET="your_webhook_secret"
TS="1784128805" # x-wmg-timestamp 头
RECEIVED="signature_from_x_wmg_hmac_sha256_header"
# jq -S 按键排序,-c 紧凑输出,-a 把非 ASCII 转义为 \uXXXX;
# 随后用 sed 转义正斜杠,PHP 的 json_encode() 同样会转义它。
SIGNING_JSON=$(jq -acS --arg ts "$TS" '. + {"x-wmg-timestamp":$ts}' body.json | sed 's|/|\\/|g')
EXPECTED=$(printf '%s' "$SIGNING_JSON" \
| openssl dgst -sha256 -hmac "$SECRET" -binary | base64)
[ "$EXPECTED" = "$RECEIVED" ] && echo "signature ok" || echo "signature mismatch"Windows PowerShell
$SECRET = "your_webhook_secret"
$TS = "1784128805" # x-wmg-timestamp 头
$RECEIVED = "signature_from_x_wmg_hmac_sha256_header"
$payload = Get-Content body.json -Raw | ConvertFrom-Json -AsHashtable
$payload["x-wmg-timestamp"] = $TS
# 手工拼接 JSON:整个映射过 ConvertTo-Json 会打乱键顺序并加空格,
# 但对单个值调用它能得到正确的转义结果。
$parts = foreach ($key in ($payload.Keys | Sort-Object)) {
"`"$key`":$($payload[$key] | ConvertTo-Json -Compress)"
}
$signingJson = "{$($parts -join ',')}"
# PHP 的 json_encode() 会转义正斜杠和非 ASCII,PowerShell 两者都不转义。
$signingJson = $signingJson -replace '/', '\/'
$signingJson = [regex]::Replace($signingJson, '[^\x00-\x7F]', {
param($m) '\u{0:x4}' -f [int][char]$m.Value
})
$hmac = New-Object System.Security.Cryptography.HMACSHA256
$hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($SECRET)
$expected = [Convert]::ToBase64String($hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($signingJson)))
if ($expected -ceq $RECEIVED) { "signature ok" } else { "signature mismatch" }Python
import base64
import hashlib
import hmac
import json
def signing_string(payload: dict, timestamp: str) -> str:
combined = {**payload, "x-wmg-timestamp": str(timestamp)}
# ensure_ascii=True(默认值)与 PHP 的 \uXXXX 转义一致;
# replace() 补上 PHP 会做、而 Python 不做的正斜杠转义。
encoded = json.dumps(dict(sorted(combined.items())), separators=(",", ":"))
return encoded.replace("/", "\\/")
def verify(raw_body: bytes, timestamp: str, received_signature: str, secret: str) -> bool:
message = signing_string(json.loads(raw_body), timestamp).encode()
expected = base64.b64encode(
hmac.new(secret.encode(), message, hashlib.sha256).digest()
).decode()
return hmac.compare_digest(expected, received_signature)Node.js / TypeScript
import {createHmac, timingSafeEqual} from "node:crypto";
function signingString(payload: Record<string, unknown>, timestamp: string): string {
const combined: Record<string, unknown> = {...payload, "x-wmg-timestamp": String(timestamp)};
const sorted = Object.keys(combined).sort()
.reduce<Record<string, unknown>>((acc, k) => (acc[k] = combined[k], acc), {});
// PHP 的 json_encode() 会转义正斜杠和非 ASCII,JSON.stringify 两者都不做,
// 因此这里显式补上。先转义斜杠——\uXXXX 那一步不会引入新的斜杠。
return JSON.stringify(sorted)
.replace(/\//g, "\\/")
.replace(/[^\x00-\x7F]/g, (c) =>
"\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
}
export function verify(rawBody: string, timestamp: string,
receivedSignature: string, secret: string): boolean {
const message = signingString(JSON.parse(rawBody), timestamp);
const expected = createHmac("sha256", secret).update(message).digest("base64");
const a = Buffer.from(expected);
const b = Buffer.from(receivedSignature);
return a.length === b.length && timingSafeEqual(a, b);
}PHP
<?php
function build_signature(string $secret, array $data, array $headers = [], string $algo = 'sha256'): string
{
$header_data = [];
foreach ($headers as $k => $v) {
$k = strtolower($k);
// 只取以 'x-wmg' 开头的头,并排除签名头本身
if (str_starts_with($k, 'x-wmg') && 'x-wmg-hmac-sha256' !== $k) {
$header_data[$k] = (string) $v;
}
}
$d = [...$data, ...$header_data];
ksort($d);
// 这里不要加 JSON_UNESCAPED_* 标志:发送方用的是 PHP 默认转义,
// 因此斜杠保持 \/,非 ASCII 保持 \uXXXX。
$str = json_encode($d);
return base64_encode(hash_hmac($algo, $str, $secret, true));
}
$raw_body = file_get_contents('php://input');
$payload = json_decode($raw_body, true);
$received = $_SERVER['HTTP_X_WMG_HMAC_SHA256'] ?? '';
$expected = build_signature('your_webhook_secret', $payload, [
'x-wmg-timestamp' => $_SERVER['HTTP_X_WMG_TIMESTAMP'] ?? '',
]);
if (!hash_equals($expected, $received)) {
http_response_code(200);
echo json_encode(['success' => false, 'msg' => 'signature mismatch']);
exit;
}
// ... 以 tracking_num + status + status_time 为键做幂等处理 ...
echo json_encode(['success' => true]);
?>注意事项
- 你的端点必须是幂等的。 投递失败的事件会被定时任务重新入队并重试,重试携带的是同一个事件。请按
tracking_num+status+status_time去重,不要假设每次推送都是新事件。 - 判定结果只看响应体,HTTP 状态码会被忽略。 返回
200但没有success: true会被记为失败并重试;返回500但响应体含success: true会被记为投递成功。请始终显式返回 JSON 确认。 - 投递失败会一直重试到成功为止。契约中没有固定的重试间隔,也没有明确的重试次数上限,因此一个永不确认的端点会持续收到同样的事件。
- 并非所有内部状态都会推送。只有出现在 物流状态码 中的状态码会被推送;仅内部使用的状态变更会被静默跳过,既不推送也不重试。
- 只有在你账号的回调方式设为 webhook 且已配置回调 URL 时才会推送。任一项被清空,推送即停止。
status_time是状态事件发生的时间;x-wmg-timestamp是生成本次推送的时间。两者不同,重试时时间戳头会更新,而status_time保持不变。- 如需防重放,请自行校验时效:WMG 不会在你这一侧强制时间窗口。请把
x-wmg-timestamp与你自己的时钟比较,并为旧事件的重试留出足够宽的容差。 - 只对映射的顶层排序。
files保持 WMG 发送时的顺序——重排其元素会改变签名。 - 请在框架重新序列化之前读取原始请求体,并按示例用解析后的值重建签名串。不要直接对原始请求体签名:签名覆盖的是「请求体 + 时间戳头」的合并结果。
remark是状态码的标准描述,不是逐条事件录入的自由文本备注。- 所有字段名和取值都区分大小写。
错误码
以下是 WMG 在投递日志中为一次推送记录的原因。它们来自你的响应,因此请用 msg 给出有意义的说明:
| success | msg | 说明 |
|---|---|---|
false | 你的端点拒绝了该事件。你放在 msg(或 message)里的内容会被存为失败原因。 | |
| 缺失 | fail | 你的响应没有 success 字段,或不是合法 JSON。记录为 fail。 |
| — | 请求未能完成——DNS 失败、TLS 错误、连接被拒、超时等。传输错误文本会被记录,该事件会重试。 |