Skip to content

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 获取:

http
Authorization: Bearer <signing_key_id>.<signing_secret>

该 credential 由当前 endpoint 的 signing_key_id 与 signing_secret 派生。 rotation、endpoint deactivation 或 deletion 后旧 credential 会失效。只使用冻结 事件中的 link,绝不把 credential 交给浏览器,也不接受任意 URL。

获取 manifest ​

js
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",
    },
  };
}
py
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:

json
{
  "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_idUUID string;batch 身份。
pod_completed_atnullable date-time;未完成时可为 null。
signer_namenullable string。
location.statuscaptured、failed、unavailable、not_attempted。
location.latitude、longitude、accuracy_meterscaptured 且三个值都存在、finite 并在既有边界内时返回持久化 Decimal 字符串;缺失、无效、超界和非 captured status 时三个值全部为 null。location evidence、source、metadata 与当前 driver position 不进入 Client projection。
packagesarray,可为空;使用 related Package 的正整数 sequence_number 映射到你的 package,绝不返回 scan code。
photosarray,可为空;每项 photo_id 是 UUID,url 是 manifest-derived URL,content_type 为 image/png 或 image/jpeg。
signaturenullable 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:read token。
  • [ ] 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 完成事件后触发后端刷新。

M Express 服务端集成指南