Get API key
Release notes

Changelog

Every developer-facing change lands here. Subscribe via RSS or the email digest for breaking-change advance notice.

v1.28
Current

Cash on delivery is paused

No carrier currently collects cash on delivery for these shipments, so a COD order would ship without the recipient ever being asked to pay. COD is now rejected at order creation until collection is available.

  • changedPOST /api/v1/shipments (and the MCP create_shipment tool, which calls it): payment_type: "cod" now returns 400 COD_NOT_AVAILABLE (1500034) at order creation, with no quote, hold or shipment created. payment_type: "prepaid" (the default) is unaffected. If you retry with "prepaid", use a NEW Idempotency-Key — reusing the old key with the corrected payload returns 409 IDEMPOTENCY_KEY_REUSED (1500003).
v1.27

Insurance rejected instead of silently dropped when unsupported

Selecting insurance on a service that cannot carry it used to be accepted and silently priced without insurance, so the order shipped uninsured. It is now rejected.

  • changedPOST /v1/shipments: when selected_surcharges includes "INSURANCE" but the final selected option does not carry insurance — its opt_in_availability marks INSURANCE as not selectable or omits it, or no insurance fee is priced for it (for example, the destination's rate card has no insurance line for that service, or the carrier product behind it cannot carry insurance) — the request now returns 400 INSURANCE_UNSUPPORTED (1500031) instead of being accepted with insurance silently dropped and no fee charged for it. Requests that also send insured_amount are unaffected: an option that does not offer a custom insured amount still returns 400 INVALID_FIELD (insured_amount).
  • changedCheck opt_in_availability on POST /v1/rates before sending "INSURANCE"; only options that list it as selectable accept it.
  • changedStandard insurance ("INSURANCE") is now offered on more options wherever the rate card lists it: options fulfilled by Globavend or iMile (including the CNBIML postal service codes) and YunExpress postal-line options. Custom insured amounts remain limited to the options that already offered them.
v1.26

Chile shipments require the recipient's RUT

Chilean customs clears on the recipient's tax ID (RUT), and the carrier returns parcels whose RUT is missing or invalid. The API now requires it at order creation instead of letting the shipment be returned later.

  • changedPOST /api/v1/shipments (and the MCP create_shipment tool, which calls it): for a shipment bound for Chile (recipient.country_code: "CL"), tax_number (the recipient's RUT) is now required. Omitting it returns 400 CL_TAX_NUMBER_REQUIRED (1500033) at order creation, with no quote, hold or shipment created; an empty or whitespace-only tax_number is rejected earlier by request validation, as for every destination (400 Bad Request, with a data entry whose path is ["tax_number"]). Only presence is checked — the RUT's format and check digit are not validated. Every other destination is unaffected. If you retry with the RUT added, use a NEW Idempotency-Key — reusing the old key with the corrected payload returns 409 IDEMPOTENCY_KEY_REUSED (1500003), and the original 400 itself keeps replaying for 24h under the key that produced it.
v1.25

Cancel is refused while an interception request is in progress

Customers can now request an interception on a `label_created` shipment from the Merdi portal (the carrier is asked to hold the parcel when it reaches the warehouse). Cancelling such a shipment would delete the carrier order under the hold, so the cancel endpoint now refuses it until the request is withdrawn or closed.

  • changedPOST /v1/shipments/{shipment_id}/cancel (and the MCP cancel_shipment tool, which calls it): a shipment with an interception request in progress now returns 409 SHIPMENT_CANCEL_INTERCEPT_IN_PROGRESS (1500032) and nothing is changed — no status change, no carrier cancel, no wallet movement. Withdraw the request on the shipment page in the Merdi portal (Package disposition) and retry; if it can no longer be withdrawn, contact support. Shipments without such a request are unaffected.
v1.24

Custom insured amount follows the declared currency

The custom insured amount from v1.23 is now entered in the shipment's declared currency and capped at USD 2,500. The feature is still switched off for all accounts, so no existing integration is affected.

  • changedPOST /v1/shipments: insured_amount_hkd is replaced by insured_amount, in the shipment's declared currency — the same currency as unit_price (see declared_currency). Must be > 0 with at most 2 decimal places and at most USD 2,500; an HKD amount is converted at the frozen declared_fx rate before the cap is checked. On an HKD shipment for which no USD/HKD rate can be resolved, supplying it returns 503 FX_RATE_UNAVAILABLE. The INSURANCE fee is 0.7% × the amount converted to HKD at the frozen rate, min HKD 9.
  • changedPOST /v1/rates: insured_amount_hkd stays in HKD (the same currency as declared_value_hkd on this endpoint) and is now capped at USD 2,500 at the day's rate. The custom_insured_amount object on the INSURANCE entry of opt_in_availability is now { max_usd } (was { max_hkd }).
  • addedPOST /v1/shipments (pricing_snapshot) and GET /v1/shipments/{shipment_id} (top level): new optional custom_insured_amount_usd — the frozen insured amount in USD. custom_insured_amount_hkd is now its HKD equivalent at the frozen rate, the basis the fee was priced on.
  • addedNot yet available via MCP: create_shipment / get_rates do not accept a custom insured amount yet — use the REST endpoints directly for now.
v1.23

Custom insured amount for enrolled accounts

Insurance has always been priced off freight + declared value. Accounts enrolled for custom insured amount can now choose the amount to insure instead (today: the cosmetics lane only). Enrollment is per account — contact your account manager.

  • addedPOST /v1/rates and POST /v1/shipments: new optional field insured_amount_hkd — a custom declared-value insurance amount in HKD, replacing the standard insurance pricing basis (0.7% × (freight + declared value), min HKD 9) with 0.7% × this amount (same minimum). Available only to accounts enrolled for custom insured amount, and only when the selected option's opt_in_availability INSURANCE entry carries custom_insured_amount (see below) — today that means the cosmetics lane. Must be > 0, <= 20,000, with at most 2 decimal places — any other value returns 400 INVALID_FIELD on both endpoints. POST /v1/shipments also returns 400 INVALID_FIELD when "INSURANCE" is not in selected_surcharges or the selected option doesn't support it; POST /v1/rates instead prices such an option at standard insurance and, when INSURANCE is selected, notes it in that option's surcharge_conflicts.
  • addedPOST /v1/rates: each option's opt_in_availability entry for INSURANCE now carries custom_insured_amount: { max_hkd } when your account is enrolled and this option supports it — absent otherwise, meaning insured_amount_hkd is not accepted for that option.
  • addedPOST /v1/shipments: response pricing_snapshot gains optional custom_insured_amount_hkd; GET /v1/shipments/{shipment_id} returns the same value as an optional top-level custom_insured_amount_hkd. Either is the frozen amount, present only when the shipment used it.
  • addedNot yet available via MCP: create_shipment / get_rates do not accept insured_amount_hkd yet — use the REST endpoints directly for now.
v1.22

Shipment detail returns the fee actually charged

Until now the API returned no money figures for a shipment, so the only amount an integration could show was the quote from booking. Once the carrier has measured the parcel, the shipment detail now also returns the fee actually charged and the weight it was charged on.

  • addedGET /api/v1/shipments/{shipment_id}: new field billing (HKD) — status (settled | pending), pre_charge_hkd (the quote frozen at booking; also the amount held from your balance, unless your account is exempt from booking holds), charged_hkd and chargeable_weight_kg (the final fee and the carrier-measured weight it was computed from; null until settled), declared_weight_kg, settled_at, and adjustments_hkd (later refunds, manual adjustments and service fees on this shipment; charged_hkd + adjustments_hkd is what it has cost you so far). billing is null only for old shipments with no stored price. Some carrier routes never report a final fee; those shipments stay pending. The list endpoint and the public tracking endpoint are unchanged.
v1.20

unit_price without declared_currency is now read as USD

Until now, a request that omitted declared_currency was read as HKD. If a caller's unit_price actually held a USD amount without declaring the currency, it was read as HKD instead, so the declared value ended up at roughly 1/7.8 of what was intended — typically without the request being rejected, though a low enough HKD-read unit_price (for example 0.01) could round to USD 0.00 and return 400. Callers whose unit_price was genuinely an HKD amount were unaffected. The web and bulk-upload order flows have accepted USD only since 2026-09-07, and USD is the declaration currency we recommend for the API. The API's reading of unit_price now matches.

  • changedPOST /api/v1/shipments: when declared_currency is omitted and any item carries unit_price, the shipment is now read as USD — unit_price is a USD amount (previously HKD). Items with no unit price are still accepted and declared at 0. As with an explicit declared_currency: "USD", an unresolvable exchange rate now returns 503 FX_RATE_UNAVAILABLE (1500018) for these requests; it is safe to retry with the same Idempotency-Key. Because the declared value is read differently, value-based surcharges and pricing_snapshot.total_hkd can differ, a destination's declared-value cap can apply, and a create bound with expected_total_hkd can return 409 PRICING_QUOTE_CHANGED if that total was quoted for a different declared value. If your integration sends HKD amounts in unit_price without declared_currency, add declared_currency: "HKD" or switch to unit_price_hkd — otherwise those amounts will be read as USD, roughly 7.8× higher.
  • changedPOST /api/v1/shipments: when declared_currency is omitted, mixing unit_price and unit_price_hkd across the items of one shipment now returns 400 INVALID_FIELD; the message names the items involved. A shipment has one declared currency: use the same price field on every item, or send declared_currency explicitly (with "HKD", both fields are HKD amounts, as before).
  • changedNot affected: unit_price_hkd is always HKD, so requests whose items use only unit_price_hkd (or carry no unit price) with declared_currency omitted are handled as before; requests that send declared_currency explicitly ("USD" or "HKD") are handled as before, including the rule that an explicit "USD" requires unit_price on every item. We recommend always sending declared_currency.
  • changedMCP create_shipment forwards to this endpoint and follows the same rules; its field descriptions now say so. MCP clients that cached the previous tool descriptions should reconnect to pick them up.
v1.19

items is now required on shipment create

A shipment created with no declared items was previously accepted and returned 201. In live mode, we observed such orders being rejected by the carrier at dispatch for missing declaration details — declared contents are required for customs. The API now rejects the request up front instead of letting it fail silently downstream.

  • changedPOST /api/v1/shipments: items is now required — at least one item (description, quantity). This applies identically to live and test (mk_test_*) keys. A missing or empty items array now returns 400 Bad Request with an entry in data whose path is ["items"] (not guaranteed to be data[0] if the request has other validation errors). If you retry with a corrected body, use a NEW Idempotency-Key — reusing the old key with a different payload returns 409 IDEMPOTENCY_KEY_REUSED (1500003), and the original 400 itself continues to replay for 24h under the key that produced it. unit_price / unit_price_hkd remain optional and unchanged.
v1.18

Brazil shipments require an 8-digit HS code

Brazil customs (via the carrier) rejects declarations whose HS code isn't exactly 8 digits (Brazil NCM). The API now blocks this at order creation instead of the shipment failing silently later at carrier forecast.

  • changedPOST /api/v1/shipments: for a shipment bound for Brazil (recipient.country_code: "BR"), every item's hs_code must be exactly 8 digits after symbols are removed. A missing, empty, or non-8-digit hs_code, or a Brazil shipment with no items, now returns 400 HS_CODE_BR_8_DIGITS_REQUIRED (1500030) at order creation — previously the shipment was created successfully and only rejected later when the carrier processed the customs forecast. Every other destination is unaffected. If you retry with corrected item data, use a NEW Idempotency-Key — reusing the old key replays the original 400 (existing idempotency rule).
v1.17

Optional recipient email on shipment create

Some destinations (for example Colombia) require a recipient email at the carrier. The API now accepts one.

  • addedPOST /api/v1/shipments: optional recipient.email. An empty string is treated as not provided.
  • changedWhen recipient.email is not provided, your account email is passed to the carrier for carriers that need a recipient email.
v1.16

Daily quota ×10, Hong Kong-time reset, self-explaining 429s, terminal-shipment caching

Rate-limit defence pass after a customer integration spent 10 hours in a 429 loop overnight. Quotas are now a ceiling, not a cliff, and every 429 tells you why.

  • changedDefault daily quota raised from 10,000 to 100,000 requests per key. The per-second cap (100) is unchanged.
  • changedDaily quota now resets at 00:00 Hong Kong time (UTC+8) instead of 00:00 UTC. X-RateLimit-Reset-Day reflects the new boundary.
  • added429 responses carry data.reason (per_second | per_day), data.retry_after_seconds, data.day_reset_at, and an X-RateLimit-Reason header. Rejected requests no longer consume daily quota.
  • addedGET /api/v1/tracking/{tn} for delivered / cancelled / returned shipments is served from a 6-hour server cache (X-Merdi-Cache header).
  • addedPOST /api/v1/shipments: re-sending a canonical-JSON-identical payload that the carrier's data rule rejected (422 9900027) within 1 hour replays the rejection (data.negative_cache: true) instead of hitting the carrier again. Fixing the data must use a NEW Idempotency-Key — reusing the old key returns 409 1500003; the existing 422 under the same key is itself replayed for 24h by the idempotency layer, and this 1-hour negative cache only applies when a new key resends the identical payload.
v1.15

Norway VOEC prepay service

Shipments to Norway can now skip the VOEC tax registration number entirely by having the carrier prepay the VOEC VAT on your behalf.

  • addedPOST /api/v1/shipments accepts an optional tax_number (1–100 characters after trimming) — the destination tax registration number, e.g. Norway's VOEC number. Required for shipments to Norway (recipient.country_code: "NO") unless voec_prepay is true.
  • addedPOST /api/v1/shipments accepts an optional voec_prepay boolean — set it true to have the carrier prepay Norway's VOEC VAT on your behalf instead of supplying tax_number. Available for Norway destinations on the standard-goods lane (service_tier_id HKASTDGGSTCM) and the cosmetics lane (HKASTDCSSTCM); equivalent to including "NO_VOEC_PREPAY" in selected_surcharges (case-insensitive) — either form is honoured. Charged at declared value × 27% (25% VAT + 2% handling) on the quote; the standard-goods lane additionally adds HKD 3/shipment and silently switches the carrier product to HK-ASS-PF, while the cosmetics lane keeps its own carrier product and has no per-shipment fee.
  • changedSending voec_prepay together with tax_number, ioss_number, or vat_prepay now returns 400 VOEC_PREPAY_CONFLICT (1500027) — choose one. voec_prepay for a destination other than Norway, or on a lane that does not support it (e.g. the postal-standard lane), returns 400 VOEC_PREPAY_UNSUPPORTED (1500028). While the feature is being rolled out, any use of voec_prepay returns 400 VOEC_PREPAY_DISABLED (1500026).
  • changedA Norway-bound shipment with neither tax_number nor voec_prepay now returns 400 TAX_NUMBER_REQUIRED (1500029) at order creation, instead of being accepted and rejected later at carrier dispatch (which previously auto-cancelled the shipment and refunded the wallet hold). This is a breaking behavior change for the request shape (a request that previously reached carrier dispatch with no tax_number field now 400s immediately) — but the public API has never produced a single successful Norway shipment in production, so no existing integration relies on the old (always-failing-downstream) behavior.
v1.14

HKD-declared shipments are now recorded in USD

A shipment declared in HKD (via unit_price_hkd, or an omitted/"HKD" declared_currency) is now converted to USD at the exchange rate frozen when the order is placed and stored as a USD shipment. Billing is unaffected — it still uses the HKD figure you originally sent.

  • changedA shipment created with unit_price_hkd (or declared_currency omitted / "HKD") now has its item prices converted to USD at the live rate frozen at order creation and is stored with declared_currency: "USD" and a frozen declared_fx — this is what POST /api/v1/shipments, GET /api/v1/shipments, and GET /api/v1/shipments/{id} return for it going forward. unit_price_hkd remains fully accepted and every input field is unchanged; billing continues to use the HKD number you supplied, so charged amounts do not change. If the USD/HKD rate is temporarily unavailable, the shipment is created as before — recorded in HKD, with no declared_fx — rather than being rejected.
  • changedAn item whose HKD price rounds to USD 0.00 after conversion (a positive unit_price_hkd below roughly HK$0.04, depending on the current rate) is now rejected with 400 INVALID_FIELD. An item priced at exactly 0 is unaffected.
v1.13

Sales platform name required for Japan shipments

YunExpress rejects every Japan-bound shipment that omits a sales platform name — the field is now validated at order creation instead of failing later at carrier dispatch.

  • addedPOST /api/v1/shipments accepts an optional sales_platform_name (free text, 1–50 characters) — the marketplace or storefront name the shipment was sold through (e.g. "Amazon", "Shopee", "Own Store"). Required when recipient.country_code is JP; optional for other destinations, but when supplied it is still forwarded to the carrier.
  • changedA Japan-bound shipment without sales_platform_name now returns 400 1500025 (SALES_PLATFORM_NAME_REQUIRED) at order creation, instead of the order being accepted and then rejected later at carrier dispatch (which previously auto-cancelled the shipment and refunded the wallet hold). This is not a breaking change for any working integration — the Japan lane has never produced a single successful shipment in production, so no existing integration relies on the old (always-failing) behavior.
v1.12

Authenticated label download for partner-carrier-channel shipments

A new endpoint downloads a shipment's label PDF for every carrier — including shipments where label_pdf_url stays permanently null.

  • addedGET /api/v1/shipments/{shipment_id}/label — authenticated (shipments:read) label PDF download, for EVERY carrier and channel, including shipments booked through a partner carrier channel whose label_pdf_url never becomes non-null. Same 404 collapsing (unknown / cross-account / cross-mode) as the doorstep-POD endpoint; returns 502 if the label exists but could not be downloaded, or 503 if a partner-channel label has not finished generating yet (retry shortly — not a permanent failure).
  • addedlabel_available: boolean on both GET /v1/shipments (list rows) and GET /v1/shipments/{id} (detail) — true when a label SOURCE exists, either directly at label_pdf_url or via the new label endpoint. This does not guarantee the label bytes are downloadable at this exact instant: for a partner-carrier-channel shipment the label endpoint may briefly return 503 (with a Retry-After header) right after dispatch while the carrier finishes generating it — poll again after that many seconds. Poll this instead of label_pdf_url alone if your integration needs to work uniformly across every carrier and channel.
  • changedlabel_pdf_url now stays permanently null (not a pending/polling state) for a shipment booked through a partner carrier channel — use the new GET /v1/shipments/{shipment_id}/label endpoint for those instead.
v1.11

Account-state rejections are no longer cached by Idempotency-Key

Settle an overdue invoice or top up, then retry with the SAME Idempotency-Key — the request is re-evaluated instead of replaying the old rejection.

  • fixedRejections that depend on account state rather than the payload are no longer cached against the Idempotency-Key: 1500009 (insufficient available balance), 1500012 (overdue-invoice freeze), 1500013, 1500014, 1500016 (credit floor / limit). Previously these were cached for 24h like any other 4xx, so a customer who paid mid-batch kept receiving the stale rejection on every same-key retry until the window expired — the only workarounds were minting fresh keys or waiting out the 24 hours. These responses now behave like 5xx: the same key re-runs the request and the wallet, credit and freeze gates are evaluated again. No shipment is ever created by a rejected attempt, so retrying cannot duplicate an order.
  • changedPayload-level 4xx (400 / 403 / 404, and 409 PRICING_QUOTE_CHANGED) are unchanged — still cached for 24h, since the same body produces the same outcome. 2xx replay, IDEMPOTENCY_KEY_REUSED conflict detection, the in-flight 409 and the 24h window are all unchanged.
v1.10

Multiple IOSS numbers + carrier VAT prepay

Accounts can now register several IOSS numbers with their own labels, and choose per shipment between an IOSS number and carrier VAT prepay.

  • addedPOST /api/v1/shipments accepts an optional vat_prepay boolean — set it true to have the carrier prepay destination VAT on your behalf (EU destinations only) instead of supplying ioss_number. Charged at the same rate as today's no-IOSS default (declared value × destination VAT rate + 2% handling) — no new fee, no price change.
  • changedSending both ioss_number and vat_prepay on the same request now returns 400 IOSS_AND_PREPAY_CONFLICT — choose one.
  • changedvat_prepay is only honoured on carriers that support it (currently YunExpress only); a shipment routed to a carrier without support returns 400 VAT_PREPAY_UNSUPPORTED — use ioss_number instead. While the feature is being rolled out, any use of vat_prepay returns 400 VAT_PREPAY_DISABLED.
v1.9

Last-mile doorstep POD photo download

Customers can now download the last-mile courier's doorstep delivery photo through the authenticated Public API.

  • addedGET /api/v1/shipments/{shipment_id}/pod returns the shipment's last-mile doorstep POD photo as a JPEG attachment. It requires shipments:read, enforces account + live/sandbox isolation, and returns 404 when no current photo is available.
  • securityThe upstream storage URL is never exposed. iMile proxies the image with an allowlisted host, manual redirect handling, a 10-second timeout, and a 25 MB response cap.
  • changedThis API deliberately excludes the YunExpress-issued POD certificate PDF. Webhook documentation now states that shipment.delivered does not embed a doorstep photo; integrations should call the download endpoint when needed.
v1.8

IOSS ownership check on shipment create

An ioss_number override on POST /v1/shipments must now belong to your account. Numbers saved in settings are auto-filed with the carrier.

  • changedPOST /api/v1/shipments ioss_number override is now ownership-checked: it must be your account's saved IOSS (settings 報關資料) or a marketplace platform IOSS registered to your account — anything else returns 400 IOSS_NOT_OWNED. Previously only the format was validated. Orders without an override (account default) are unaffected.
  • addedSaving an IOSS in settings now auto-files (备案) it with the carrier — a carrier-side prerequisite before EU orders can carry the number. Filing normally takes effect immediately. To register a marketplace platform IOSS (e.g. Amazon's) on your account, contact support.
v1.7

Service-pricing model v2 — Product Codes, Service Codes, live rate quotes

The service catalog moved to a three-layer model: Product Code (entitlement container, e.g. HKASTD) → Service Code (the orderable unit you put in service_tier_id, e.g. HKASTDGGSTCM) → versioned rate tables. Rates are live, service_tiers is entitlement-scoped, and five legacy tier codes are retired.

  • changedLegacy tier codes RETIRED (breaking): US-GEN-PUB-001, US-GEN-PUB-002, HK-ST-GG-01, HK-US-SE-GG-01 and HKUSSTCS01 were deleted on 2026-06-10 — shipments referencing them now fail. Migrate to HKASTDGGSTCM (standard line) or the matching line from GET /api/v1/service_tiers. The only legacy code still live is HKUSSEGG01. See the new Service Codes guide for the mapping.
  • addedPOST /api/v1/rates is LIVE (the docs previously listed it as not-implemented/501). Entitlement-scoped quotes wrapping the internal billing engine: AU destinations price by postal-code zone (destination_postal_code → zone; without it zoned options return available:false + reason), per-option opt_in_availability gates the SIGNATURE/INSURANCE checkboxes, surcharge_conflicts lists opt-ins that couldn't apply, and EU lanes carry an ioss_notice block. Engine rejections return 400 with the engine's real reason and data.field classification (rate_card / declared_value_hkd / parcels).
  • changedGET /api/v1/service_tiers is now entitlement-scoped: it returns only the Service Codes your account can book (Product Code entitlements + assigned rate cards), not the whole platform catalog — matching what the docs always promised. Tiers now expose product_code, service_options[] (cargo/air/last-mile dimensions) and is_standard_service. Note product_code (iMile container, e.g. HKASTD) ≠ carrier.product_code (the upstream carrier's own reference). No entitlements → 200 + empty list.
  • addedPOST /api/v1/shipments request fields documented: selected_surcharges (e.g. ["SIGNATURE","INSURANCE"] — eligibility is per service code + destination; on the US priority line signature and insurance are mutually exclusive) and ioss_number (IM + 10 digits; overrides the account-level IOSS default; EU destinations only — shipped 2026-06-10 with the EU IOSS work, documented now).
  • addedNew Service Codes guide: the three-layer model, code anatomy (HKASTD + cargo GG/CS + air ST/PR + last-mile CM/PS), standard vs value-added lines, ETA conventions (standard ~8-12 business days, priority ~6-10), fetching your codes, and the two-product_codes disambiguation.
v1.6

Last-mile tracking number on shipment detail

The final-mile carrier number (e.g. USPS) is now on the shipment-detail API — the number you actually push to marketplaces.

  • addedGET /api/v1/shipments/{id} now returns last_mile_tracking_number — the final-mile carrier's own number (e.g. USPS 9214…), populated once the upstream carrier hands the parcel to the last-mile carrier. Null until that handoff. This is typically the number to push to eBay/Amazon and show the buyer, since the delivering carrier's tracker recognises it.
  • changedThe "which number do I use?" guide now lists three numbers — tracking_number (iMile canonical), waybill_number (first-leg carrier, e.g. Yun YT…), and last_mile_tracking_number (final-mile, e.g. USPS). Rule of thumb: prefer the last-mile number for marketplaces once it's non-null, else fall back to the waybill.
v1.5

Wallet balance, webhook redeliver, integration-feedback hardening

Driven by a real third-party integration's feedback — proactive balance monitoring, self-serve redelivery, and docs that match the wire.

  • addedGET /api/v1/wallet/balance — read your prepaid wallet { balance, available, held, currency }. Poll available to top up before a batch hits insufficient funds, instead of discovering it mid-run. (available = balance − open holds.)
  • addedPOST /api/v1/webhooks/{id}/deliveries/{delivery_id}/redeliver — re-send a previously-failed delivery (re-fires the original event_id so your receiver can dedupe). Also surfaced as a 「重發」 button on the dashboard delivery viewer.
  • addedWebhook event payload schema is now published in the docs + OpenAPI (WebhookEventEnvelope): the delivered body is { event_id, event_type, livemode, created_at, data } — you no longer have to reverse-engineer it from a sample. Full event-type catalog included.
  • changedIdempotency-Key is now OPTIONAL on naturally-idempotent operations — DELETE /webhooks/{id} and POST /shipments/{id}/cancel no longer 400 when it's absent (still honoured for replay if you send it). It remains REQUIRED on create endpoints. The idempotency guide now has a per-endpoint requirement table.
  • fixedDocs ↔ wire consistency: corrected every remaining prose reference to the webhook signature header — it is X-Merdi-Signature (some guide paragraphs dropped the X- prefix). A CI lint now blocks this drift from recurring.
  • addedDocs clarity: a "which tracking number do I push to marketplaces?" section (carrier-native waybill_number for eBay/Amazon vs iMile-canonical tracking_number), the buyer-facing tracking page URL, the dispatch-is-synchronous timing contract, and the base URL https://merdiexpress.com/api/v1/ made prominent in Quickstart.
v1.4

Offline webhook verification, X-Request-Id, idempotency normalisation

Webhook integration hardening — verify your signature handling end-to-end before production, even from private staging or CI.

  • addedGET /api/v1/webhooks/{id}/sample — returns a genuinely-signed sample event WITHOUT delivering it anywhere. Feed the returned raw_body + X-Merdi-Signature straight into your receiver's verification code and confirm — before production — that your HMAC check accepts a real iMile signature. Works behind NAT / on localhost / in CI, where POST .../test (which actually delivers) can't reach you.
  • addedX-Request-Id response header on every API response (including 401s and 5xx). Echo your own X-Request-Id request header and we preserve it. Quote it when reporting an issue and we can pull the exact request from our logs.
  • changedIdempotency-Key payload matching now normalises JSON key order. Retrying the same logical request with fields in a different order (e.g. {a,b} vs {b,a}) is correctly treated as the SAME request — no more spurious 409 Conflict. Array order is still significant (a parcel list ≠ its reverse).
  • addedWebhooks guide expanded: a prominent ⚠ warning to verify against the RAW request body (the #1 integration bug), an at-least-once deduplication section (dedup on X-Merdi-Event-Id, stable across retries), and an offline-verification walkthrough using the new sample endpoint.
  • addedEvery public response shape is now in the OpenAPI spec (/openapi.json → 30 schemas). Shipment detail/summary, rate quotes, service tiers, and all webhook shapes are now fully documented + Postman-importable, not just the request bodies.
v1.3

Webhook rotation, sandbox carrier, Postman collection, live try-it console

Big polish pass — most asked-for endpoints + tooling so first-touch integration takes 5 minutes, not 5 hours.

  • addedPOST /api/v1/webhooks/{id}/rotate — rotate a webhook signing secret in place. Endpoint id, url, events, history all preserved. New plaintext secret returned EXACTLY ONCE. Recommended pattern: accept both old + new for an overlap window during rollover.
  • addedSandbox carrier (carrier_code = "sandbox"). Generates a real-looking waybill_number + a placeholder label_pdf_url so you can exercise the full label-fetch path in integration tests without paying for a real shipment. Works in both live and test modes.
  • addedPostman collection auto-published at https://merdiexpress.com/postman-collection.json. One click → import all endpoints with {{base_url}} + {{api_key}} variables ready to fill. Linked from the docs footer.
  • added/developers/api try-it console now hits the real API live (was a mock with a Math.random success rate). Use a mk_test_* key from your dashboard to exercise endpoints without leaving the docs site.
  • addedSelf-serve webhook delivery viewer at /dashboard/developers/webhooks/{id}/deliveries. State / event type / HTTP status / latency / response body excerpt per attempt. Cursor-paginated 20 at a time. Lets you self-debug receiver issues without filing a support ticket.
  • changedRemoved stale @merdiexpress/node SDK references from the quickstart + landing samples. That package was vapourware — the docs now show raw fetch / requests / curl. A real SDK is in the roadmap.
  • fixedRate-limit guide listed generic header names (X-RateLimit-Limit); corrected to our actual X-RateLimit-Limit-Second, X-RateLimit-Limit-Day, X-RateLimit-Reset-Day etc. Existing header behaviour unchanged.
  • security5xx outage alerter — internal change. Server errors now post to an ops Slack channel via ERROR_ALERT_WEBHOOK_URL (throttled per-route, per-5-min). Future prod outages should get noticed in minutes, not after a customer complains.
v1.2

Labels, base-URL hardening, secret-at-rest encryption

Polish pass driven by the first external integration feedback. Spec is easier to parse, labels are easier to retrieve, and customer signing secrets are encrypted at rest.

  • addedlabel_pdf_url and waybill_number now returned on GET /api/v1/shipments/{id} (and surfaced in the OpenAPI schema). Recommended polling pattern: 1s × up to 30s after the synchronous POST until label_pdf_url !== null or dispatch_status === "failed". URL is presigned OSS — fetch directly, no Authorization header.
  • addedNew /developers/guides/labels guide covering when the PDF is ready, the polling pattern, fetching + caching the PDF, and test-mode behaviour (dispatch skipped, dispatch=null).
  • addedBase URL is now embedded inside every paths key in the OpenAPI spec (e.g. /api/v1/shipments), with servers: [{url: "https://merdiexpress.com"}] at root. SDK generators that ignore the servers field now resolve correctly.
  • addedCompat redirect: any request to https://merdiexpress.com/v1/* returns HTTP 308 Permanent Redirect → /api/v1/* (preserves method + body — works for POST with Idempotency-Key). If your SDK config has the wrong base URL it will still work, but please update — 308 is cacheable.
  • securityWebhook signing secrets are now encrypted at rest in our database (AES-256-GCM envelopes, per-row IVs). A DB backup leak no longer exposes signing material. The secret you receive on POST /api/v1/webhooks is unchanged; this is a server-side hardening, no client action needed.
  • securityDependency security sweep: cleared 45 of 46 open vulnerability advisories on our build (the remaining one is bundled inside the Next.js framework itself and waits on the upstream release). Includes a major nodemailer bump and removal of unused auth chains.
  • fixedDeploy crashloop guard. If a build half-completes, the next deploy now refuses to start a broken process; the previously-good build keeps serving until a clean build replaces it. Closes a ~15 min / returning 502 incident on 2026-05-28.
  • changedSpec version bumped 1.0.0 → 1.1.0. info.description now leads with an explicit "BASE URL: every endpoint is served from https://merdiexpress.com/api/v1/" note.
v1.1

Shipment creation goes live

Order placement + cancel on the API surface. Wallet-debited live mode, free-and-safe test mode.

  • addedPOST /v1/shipments — create a shipment server-side: quote engine re-priced bit-for-bit, pricing snapshot frozen into the record, label-created tracking event emitted. Requires Idempotency-Key.
  • addedPOST /v1/shipments/{id}/cancel — state-machine cancellation for pre-pickup shipments (label_created only). Live mode refunds the wallet by the original total; test mode never deducts so refund is always 0. Cancelling twice returns the same snapshot.
  • addedPOST /v1/rates — real billing-engine quote (was a 501 placeholder in v1.0). Accepts weight_kg, destination_country, goods_category + optional surcharges; returns freight + reg fee + per-line surcharges + total HKD.
  • addedCORS preflight on /v1/* so browser clients (try-it consoles, single-page integrations) work without a proxy.
  • addedWebhook delivery worker with exponential backoff retries (5 attempts over ~30 min) + auto-disable after 10 consecutive failures.
  • addedSelf-serve key + webhook management in the dashboard at /dashboard/developers/{keys,webhooks} — no more admin-CLI dependency for onboarding.
  • fixedGET /v1/shipments/{id} and GET /v1/shipments (list) now correctly resolve account → client ID; previously returned 404 for own-shipment lookups.
v1.0

Phase A/B foundation launch

First public release. Tracking, shipments (read-only), service tiers, webhooks. Rates endpoint reserved.

  • addedBearer auth with mk_live_* and mk_test_* key prefixes; per-key rate limit (100/sec, 10k/day).
  • addedIdempotency-Key header support on all POST endpoints; safe to retry for 24h.
  • addedWebhooks: HMAC-SHA256 signatures on X-Merdi-Signature header (Stripe-style t=,v1= format); signing_secret returned once on create.
  • addedEndpoints: shipments list+detail, tracking by number, service tiers, countries, webhooks CRUD + test-fire.
  • changedWire format adopts snake_case across all request and response bodies.
v0.9.3

Pre-launch beta — partner program

Beta cohort: 8 HK 3PLs. Feedback loop into v1.0 surface.

  • addedTest-mode keys (mk_test_* prefix) — same endpoints and same merdiexpress.com/api host as live, but no carrier dispatch and no wallet deduct. Mint from /dashboard/developers/keys.
  • changedRenamed pickup_eta → eta on tracking responses.
  • fixedCheckpoint ordering now stable when two scans share a timestamp.
v0.9.0

Internal dogfood

API surface frozen pending external review.

  • addedOpenAPI 3.1 spec published internally.
  • securityKey prefixes (mk_test_, mk_live_) introduced to prevent accidental cross-env writes.
You've reached the bottom of history.