POD API
当货件需要交付证据时,从客户服务器查询 POD 清单,再按需读取照片或签名文件。shipment-scoped manifest 和其媒体 endpoint 使用同一个带 pod:read 的 Bearer token;EVT430 webhook 媒体使用下方的组合 webhook credential。
POD API 请求:先按运单号查询货件 POD,再按需读取清单返回的媒体链接。
文字等价:客户服务器携带 pod:read Bearer token 查询货件 POD;M Express 返回清单及可用的媒体链接。若需要媒体,客户服务器用同一凭据读取返回的链接,取得照片或签名文件。图中不规定浏览器、代理或其他客户内部架构。
凭据只留在客户服务器。如果需要在浏览器展示媒体,可参考下方的可选展示示例;EVT430 媒体使用其独立章节说明的组合凭据。
三个 endpoint
| 方法 | 路径 | 用途 |
|---|---|---|
GET | /client/shipments/{tracking_number}/pod/ | 返回 shipment-scoped manifest。 |
GET | /client/shipments/{tracking_number}/pod/{pod_id}/photos/{photo_id}/file/ | 返回一个已由 manifest 选出的 photo。 |
GET | /client/shipments/{tracking_number}/pod/{pod_id}/signature/file/ | 返回一个 signature representation。 |
路径前缀为 https://mexpress.nz/api/v1。请求 header 使用 Authorization: Bearer <server-token>;token 必须有 pod:read。不要把 token 放入浏览器代码、URL、HTML、日志、移动端存储或 CDN。
读取 EVT430 webhook 媒体
当订阅的 endpoint 收到 pod.bundle_available / EVT430 时,payload 会包含一到 三张已验证照片的 link,也可能包含一条已验证 signature link。它们使用 /client/webhook-pods/{pod_id}/... route family,由你的后端使用以下 credential 获取:
Authorization: Bearer <signing_key_id>.<signing_secret>该 credential 由当前 endpoint 的 signing_key_id 与 signing_secret 派生。 rotation、endpoint deactivation 或 deletion 后旧 credential 会失效。只使用冻结 事件中的 link,绝不把 credential 交给浏览器,也不接受任意 URL。
获取 manifest
export async function fetchPodManifest(
trackingNumber,
{ token, baseUrl = API_BASE_URL, fetchImpl = globalThis.fetch } = {},
) {
if (!token) throw new Error("A pod:read bearer token is required.");
const response = await fetchImpl(
endpoint(baseUrl, `/client/shipments/${encodeURIComponent(trackingNumber)}/pod/`),
{
method: "GET",
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
},
);
if (response.status === 200) return await json(response);
if (response.status === 403) throw new Error("POD access was forbidden.");
if (response.status === 404) throw new Error("POD manifest was not found.");
throw new Error(`POD manifest request failed with HTTP ${response.status}.`);
}
function manifestMedia(manifest) {
return (manifest.delivery_batches ?? [])
.flatMap((batch) => [
...(batch.photos ?? []),
...(batch.signature ? [batch.signature] : []),
])
.filter((media) => media && typeof media.url === "string");
}
export function mediaKeyFromManifestUrl(url, { baseUrl = API_BASE_URL } = {}) {
const parsed = new URL(url, baseUrl);
return `${parsed.pathname}${parsed.search}`;
}
export async function fetchPodMedia({
trackingNumber,
manifest,
mediaKey,
token,
baseUrl = API_BASE_URL,
fetchImpl = globalThis.fetch,
} = {}) {
if (!token) throw new Error("A pod:read bearer token is required.");
if (!manifest || !mediaKey || typeof mediaKey !== "string") {
throw new Error("mediaKey must come from the POD manifest.");
}
const media = manifestMedia(manifest).find(
(item) => mediaKeyFromManifestUrl(item.url, { baseUrl }) === mediaKey,
);
if (!media) throw new Error("mediaKey is not present in the current POD manifest.");
const base = new URL(baseUrl);
const expectedPrefix = `${base.pathname.replace(/\/$/, "")}/client/shipments/${encodeURIComponent(
trackingNumber,
)}/pod/`;
const selected = new URL(mediaKey, base);
if (
selected.origin !== base.origin ||
!selected.pathname.startsWith(expectedPrefix)
) {
throw new Error("mediaKey is outside the current shipment POD prefix.");
}
const response = await fetchImpl(selected.toString(), {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
});
if (response.status === 403) throw new Error("POD media access was forbidden.");
if (response.status === 404) throw new Error("POD media was not found.");
if (response.status !== 200) {
throw new Error(`POD media request failed with HTTP ${response.status}.`);
}
const manifestContentType = media.content_type;
if (!ALLOWED_POD_MEDIA_TYPES.has(manifestContentType)) {
throw new Error("POD media manifest Content-Type is not allowed.");
}
const rawContentType = header(response.headers, "Content-Type");
if (!rawContentType) {
throw new Error("POD media response omitted Content-Type.");
}
const contentType = rawContentType.split(";", 1)[0].trim().toLowerCase();
if (!ALLOWED_POD_MEDIA_TYPES.has(contentType)) {
throw new Error("POD media response Content-Type is not allowed.");
}
if (contentType !== manifestContentType) {
throw new Error("POD media Content-Type does not match manifest.");
}
return {
body: new Uint8Array(await response.arrayBuffer()),
contentType,
cacheControl: "private, no-store",
referrerPolicy: "no-referrer",
contentTypeOptions: "nosniff",
headers: {
"Content-Type": contentType,
"Cache-Control": "private, no-store",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
},
};
}def fetch_pod_manifest(tracking_number: str, *, token: str, client, base_url: str = API_BASE_URL):
if not token:
raise ValueError("A pod:read bearer token is required.")
response = client.get(
_url(base_url, f"/client/shipments/{quote(tracking_number, safe='')}/pod/"),
headers={"Accept": "application/json", "Authorization": f"Bearer {token}"},
)
if response.status_code == 200:
return response.json()
if response.status_code == 403:
raise PermissionError("POD access was forbidden.")
if response.status_code == 404:
raise LookupError("POD manifest was not found.")
raise RuntimeError(f"POD manifest request failed with HTTP {response.status_code}.")
def _manifest_media(manifest):
media = []
for batch in manifest.get("delivery_batches", []):
media.extend(batch.get("photos", []))
if batch.get("signature"):
media.append(batch["signature"])
return [item for item in media if isinstance(item.get("url"), str)]
def media_key_from_manifest_url(url: str, *, base_url: str = API_BASE_URL) -> str:
parsed = urlparse(urljoin(base_url, url))
return parsed.path + (f"?{parsed.query}" if parsed.query else "")
def fetch_pod_media(
*,
tracking_number: str,
manifest,
media_key: str,
token: str,
client,
base_url: str = API_BASE_URL,
):
if not token:
raise ValueError("A pod:read bearer token is required.")
if not isinstance(media_key, str) or not media_key:
raise ValueError("mediaKey must come from the POD manifest.")
media = next(
(
item
for item in _manifest_media(manifest)
if media_key_from_manifest_url(item["url"], base_url=base_url)
== media_key
),
None,
)
if media is None:
raise ValueError("mediaKey is not present in the current POD manifest.")
base = urlparse(base_url)
selected = urlparse(urljoin(base_url, media_key))
expected_prefix = (
f"{base.path.rstrip('/')}/client/shipments/"
f"{quote(tracking_number, safe='')}/pod/"
)
if (
selected.scheme != base.scheme
or selected.netloc != base.netloc
or not selected.path.startswith(expected_prefix)
):
raise ValueError("mediaKey is outside the current shipment POD prefix.")
response = client.get(selected.geturl(), headers={"Authorization": f"Bearer {token}"})
if response.status_code == 403:
raise PermissionError("POD media access was forbidden.")
if response.status_code == 404:
raise LookupError("POD media was not found.")
if response.status_code != 200:
raise RuntimeError(f"POD media request failed with HTTP {response.status_code}.")
manifest_content_type = media.get("content_type")
if manifest_content_type not in ALLOWED_POD_MEDIA_TYPES:
raise ValueError("POD media manifest Content-Type is not allowed.")
raw_content_type = _header(response, "Content-Type")
if raw_content_type is None:
raise ValueError("POD media response omitted Content-Type.")
content_type = raw_content_type.split(";", 1)[0].strip().lower()
if content_type not in ALLOWED_POD_MEDIA_TYPES:
raise ValueError("POD media response Content-Type is not allowed.")
if content_type != manifest_content_type:
raise ValueError("POD media Content-Type does not match manifest.")
return {
"body": response.content,
"content_type": content_type,
"cache_control": "private, no-store",
"referrer_policy": "no-referrer",
"content_type_options": "nosniff",
"headers": {
"Content-Type": content_type,
"Cache-Control": "private, no-store",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
},
}响应 required 字段为 tracking_number、pod_status 和 delivery_batches。pod_status 是 pending、partial 或 complete:前者表示没有当前 package 有完成时间,中者表示部分 package 已完成,后者表示全部 package 已完成。delivery_batches 可以为空。
对 MX123456789,以下合成响应演示两个 batch、package mapping 与 nullable location:
{
"tracking_number": "MX123456789",
"pod_status": "partial",
"delivery_batches": [
{
"pod_id": "11111111-1111-4111-8111-111111111111",
"pod_completed_at": "2026-07-02T03:04:05.000000Z",
"signer_name": "A. Recipient",
"location": {
"status": "captured",
"latitude": "-36.8484600",
"longitude": "174.7633320",
"accuracy_meters": "4.50"
},
"packages": [
{
"tracking_number": "MX123456789",
"sequence_number": 1
}
],
"photos": [
{
"photo_id": "33333333-3333-4333-8333-333333333333",
"url": "/api/v1/client/shipments/MX123456789/pod/11111111-1111-4111-8111-111111111111/photos/33333333-3333-4333-8333-333333333333/file/",
"content_type": "image/png"
}
],
"signature": {
"url": "/api/v1/client/shipments/MX123456789/pod/11111111-1111-4111-8111-111111111111/signature/file/",
"content_type": "image/svg+xml"
}
},
{
"pod_id": "22222222-2222-4222-8222-222222222222",
"pod_completed_at": "2026-07-01T03:04:05.000000Z",
"signer_name": null,
"location": {
"status": "unavailable",
"latitude": null,
"longitude": null,
"accuracy_meters": null
},
"packages": [
{
"tracking_number": "MX123456789",
"sequence_number": 2
}
],
"photos": [],
"signature": null
}
]
}字段和空值规则
| 字段 | 契约 |
|---|---|
pod_id | UUID string;batch 身份。 |
pod_completed_at | nullable date-time;未完成时可为 null。 |
signer_name | nullable string。 |
location.status | captured、failed、unavailable、not_attempted。 |
location.latitude、longitude、accuracy_meters | captured 且三个值都存在、finite 并在既有边界内时返回持久化 Decimal 字符串;缺失、无效、超界和非 captured status 时三个值全部为 null。location evidence、source、metadata 与当前 driver position 不进入 Client projection。 |
packages | array,可为空;使用 related Package 的正整数 sequence_number 映射到你的 package,绝不返回 scan code。 |
photos | array,可为空;每项 photo_id 是 UUID,url 是 manifest-derived URL,content_type 为 image/png 或 image/jpeg。 |
signature | nullable object;url 为 manifest-derived URL,content_type 为 image/svg+xml、image/png 或 image/jpeg。raw signature strokes 永不公开。 |
batch 按完成时间再按创建时间 newest-first 返回。同一 Client 的 shared batch 可以从每个有 package membership 的 shipment 查询到,POD identity 与媒体 bytes 保持共享,而 packages 与媒体 URL prefix 按查询 shipment 过滤。manifest 的 delivery_batches 为空表示没有可用 batch;photo 和 signature 数组为空表示该 batch 没有相应媒体。不要把缺失媒体改写成可用 evidence。
此 shipment-scoped manifest 始终包含 required location object;值不可用时坐标为 null。EVT430 使用独立的可选顶层 location:仅 completed batch 为 captured 且所有值完整有效时才包含,否则整个字段省略。不要把 manifest 的 nullable 规则套用到 EVT430。
可选示例:在浏览器展示媒体
如果应用需要在浏览器展示 POD,可参考以下安全代理示例,把 M Express 凭据留在服务器。这是展示方式示例,不是调用 POD API 必须增加的步骤。
示例中的代理只接受从 manifest 派生的 app-owned mediaKey,不接受任意 URL。它验证 key 仍匹配当前 tracking_number 的 /client/shipments/{tracking_number}/pod/ prefix,然后携带同一个 pod:read token 请求 M Express。
授权必须在你自己的后端执行。导入的 POD region 只展示行为边界:你的应用先认证自己的 route,接受 app-owned mediaKey,把 key 对照当前 manifest 解析,验证所得 URL 仍属于当前 shipment 的 POD prefix,并且永不接受浏览器提供的 upstream URL。
照片只允许 image/png 与 image/jpeg,signature 只允许 image/svg+xml、image/png 与 image/jpeg。manifest 是一个 endpoint,photo 与 signature 是两个媒体 endpoint。helper 还要求 manifest 类型属于这三种 allowlist,并要求上游 200 的 Content-Type 存在、去除参数后属于 allowlist 且与 manifest 类型一致;缺失、HTML、未允许或不匹配的类型会在返回 bytes 前拒绝。helper result 会提供规范化后的 Content-Type,并为外层后端响应提供以下 exact headers:Cache-Control: private, no-store、Referrer-Policy: no-referrer、X-Content-Type-Options: nosniff。
处理访问错误
缺少、格式错误、无效、撤销或 scope 不足的 token 返回 403。未知或隐藏 shipment、跨客户 batch、缺失媒体和不可用证据返回不可区分的 detail-only 404。不要用错误差异推断其他客户的 shipment 或 evidence 是否存在。
验收
- [ ] manifest、photo、signature 使用同一个服务端
pod:readtoken。 - [ ]
pending、partial、complete、空数组和 nullable location 都按契约处理。 - [ ] batch 保持独立且 newest-first;只返回符合条件的已完成 batch,package mapping 使用
sequence_number。 - [ ] 有效 captured location 使用持久化 Decimal 字符串;不可用、缺失、无效、超界和非 captured location 使用全 null 坐标。
- [ ] 若采用浏览器代理示例:代理只接受 manifest-derived media key,并校验当前 shipment POD prefix。
- [ ] 媒体保留与 manifest 一致的允许
Content-Type与三个安全 header。 - [ ] token 和 raw signature strokes 不进入浏览器、URL、HTML、日志、移动端或 CDN。
继续阅读 Webhooks,在 POD 完成事件后触发后端刷新。