Parcel Status Webhook
API Overview
Whenever a parcel's status changes, WMG pushes the new status to a URL you register. This is the opposite direction from every other page in this documentation: WMG is the client and your endpoint is the server.
The push is signed with HMAC-SHA256 so you can verify it came from WMG. Your endpoint must acknowledge each push with {"success": true}; anything else is treated as a failed delivery and the event is sent again later.
NOTE
Configure your callback URL and webhook secret key in the portal, as shown below.

Request Information
- Method: POST
- Path: your registered callback URL
- Authentication: HMAC-SHA256 signature (
x-wmg-hmac-sha256header) - Direction: WMG → your endpoint
Request Headers
| Field | Description |
|---|---|
| Content-Type | application/json |
| x-wmg-timestamp | UNIX timestamp (10-digit seconds) at the moment the push was generated. Sent as a string and signed as a string. |
| x-wmg-hmac-sha256 | Base64-encoded HMAC-SHA256 signature of the payload, see Authentication. |
Request Parameters
The request body is in JSON format and includes the following fields:
| Parameter | Type | Required | Description |
|---|---|---|---|
| tracking_num | string | Yes | The WMG tracking number of the parcel. |
| custom_tracking_num | string | Yes | Your own tracking number for the parcel. Empty string if the parcel has none. |
| status | string | Yes | Status code, e.g. DR_DR, OK_OK — see Delivery Status. |
| remark | string | No | Standard description text for that status code. Empty string when the code has no text. |
| status_time | integer | Yes | UNIX timestamp (seconds) of the status event itself. Sent as a number, not a string. |
| files | array<string> | No | Fully-qualified http/https URLs of files attached to the event, such as ePOD photos. Empty array when there are none. |
Request Body Example
{
"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"
]
}Signature Payload Example
The signed string is the request body merged with the x-wmg-* headers (excluding x-wmg-hmac-sha256), sorted ascending by key, then serialised as compact JSON using PHP's default escaping — forward slashes become \/ and non-ASCII characters become \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"}Note the two different value types: status_time is an unquoted integer, while x-wmg-timestamp is a quoted string.
Authentication
Each push carries a signature you verify with your webhook secret key:
Signature = Base64( HMAC-SHA256( JSON String, Secret Key ) )The message comes first and the key second, matching the argument order of PHP's hash_hmac($algo, $data, $key).
To rebuild the JSON string:
- Take every field of the JSON body.
- Add every request header whose name starts with
x-wmg, excludingx-wmg-hmac-sha256. In practice this isx-wmg-timestamp, and its value is signed as a string. - Sort the combined map ascending by key (ASCII order). Only the top level is sorted.
- Serialise it as compact JSON with forward slashes escaped as
\/and non-ASCII escaped as\uXXXX. - Compute HMAC-SHA256 over that string with your secret key, take the raw binary digest, and Base64-encode it.
- Compare the result with
x-wmg-hmac-sha256using a constant-time comparison.
Escaping is not the language default
Step 4 matches PHP's json_encode() without any JSON_UNESCAPED_* flags. Most other languages do the opposite by default: Python and JavaScript never escape forward slashes, and JavaScript does not escape non-ASCII either. Since files always contains URLs, an unescaped / will make every signature mismatch. The examples below apply both escapes explicitly.
Response Information
The response your endpoint returns is in JSON format.
Response Format
| Field | Type | Description |
|---|---|---|
| success | boolean | true = the event was received and processed. Any other value, or a missing field, counts as a failure. |
| msg | string | Optional. On failure, recorded by WMG as the failure reason. message is accepted as an alias. |
Success Response
- Status Code: 200
{
"success": true
}Error Response
Return a body without success: true. Include a reason so it shows up in WMG's delivery log:
{
"success": false,
"msg": "signature mismatch"
}Code Reference
This page documents a push to you, so the standard {Code, Message, Data} envelope does not apply. WMG decides the outcome from the success field of your response body:
| success | Description |
|---|---|
true | Delivered. The event is not sent again. |
| anything else, or absent | Failed. The event is queued for redelivery. |
Example
The examples below verify an incoming push. body is the raw request body you received and timestamp is the x-wmg-timestamp header.
Bash
SECRET="your_webhook_secret"
TS="1784128805" # x-wmg-timestamp header
RECEIVED="signature_from_x_wmg_hmac_sha256_header"
# jq -S sorts keys, -c compacts, -a escapes non-ASCII as \uXXXX;
# sed then escapes forward slashes, which PHP's json_encode() also does.
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 header
$RECEIVED = "signature_from_x_wmg_hmac_sha256_header"
$payload = Get-Content body.json -Raw | ConvertFrom-Json -AsHashtable
$payload["x-wmg-timestamp"] = $TS
# Assemble the JSON by hand: ConvertTo-Json on the whole map reorders keys and adds
# whitespace, but running it on a single value escapes that value correctly.
$parts = foreach ($key in ($payload.Keys | Sort-Object)) {
"`"$key`":$($payload[$key] | ConvertTo-Json -Compress)"
}
$signingJson = "{$($parts -join ',')}"
# PHP's json_encode() escapes forward slashes and non-ASCII; PowerShell does neither.
$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 (the default) matches PHP's \uXXXX escaping; the replace()
# adds the forward-slash escaping that PHP applies and Python does not.
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's json_encode() escapes forward slashes and non-ASCII; JSON.stringify does
// neither, so both escapes are applied here. Slashes first — the \uXXXX pass
// never introduces one.
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);
// include headers starting with 'x-wmg', excluding the signature itself
if (str_starts_with($k, 'x-wmg') && 'x-wmg-hmac-sha256' !== $k) {
$header_data[$k] = (string) $v;
}
}
$d = [...$data, ...$header_data];
ksort($d);
// No JSON_UNESCAPED_* flags: the sender uses PHP's default escaping,
// so slashes stay as \/ and non-ASCII stays as \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;
}
// ... process the event idempotently, keyed on tracking_num + status + status_time ...
echo json_encode(['success' => true]);
?>Notes
- Your endpoint must be idempotent. A failed delivery is re-queued by a scheduled task and retried, and the retry carries the same event. Deduplicate on
tracking_num+status+status_timerather than assuming each push is unique. - Only the response body decides the outcome — the HTTP status code is ignored. A
200withoutsuccess: trueis recorded as a failure and retried; a500whose body containssuccess: trueis recorded as delivered. Always return the JSON acknowledgement explicitly. - Failed deliveries are retried until they succeed. There is no fixed retry interval in the contract and no documented attempt limit, so an endpoint that never acknowledges will keep receiving the same events.
- Not every internal status is pushed. Only status codes that appear in Delivery Status are sent; internal-only transitions are skipped silently, with no push and no retry.
- Pushes are only sent while your account's callback type is set to webhook and a callback URL is configured. Clearing either one stops delivery.
status_timeis when the status event happened;x-wmg-timestampis when the push was generated. They differ, and on a retry the timestamp header is newer whilestatus_timestays the same.- Verify freshness yourself if you need replay protection: WMG does not enforce a time window on your side. Compare
x-wmg-timestampagainst your own clock, and allow enough slack for retries of older events. - Sort only the top level of the map.
fileskeeps the order WMG sent it — re-ordering its elements changes the signature. - Read the raw request body before any framework re-serialises it, and rebuild the signing string from the parsed values as shown. Do not sign the raw body itself: the signature covers the body merged with the timestamp header.
remarkis the standard description of the status code, not a free-text note entered per event.- All field names and values are case-sensitive.
Error Codes
These are the reasons WMG records against a push in its delivery log. They come from your response, so use msg to make them meaningful:
| success | msg | Description |
|---|---|---|
false | Your endpoint rejected the event. Whatever you put in msg (or message) is stored as the failure reason. | |
| absent | fail | Your response contained no success field, or was not valid JSON. Recorded as fail. |
| — | The request never completed — DNS failure, TLS error, connection refused, timeout. The transport error text is recorded and the event is retried. |