Rate limits
Each key is capped at 100 requests/second and 100,000 requests/day. The daily quota resets at 00:00 Hong Kong time (UTC+8). Above this we return 429 with a reason.
Caps
| Scope | Limit | Window |
|---|---|---|
| Per key | 100 | 1 second (fixed window) |
| Per key | 100,000 | Calendar day, resets 00:00 HKT |
| Per account | — | No additional account-wide cap today; limits apply per key. |
Response headers
Every authenticated response includes:
HTTP/1.1 200 OK X-RateLimit-Limit-Second: 100 X-RateLimit-Remaining-Second: 87 X-RateLimit-Limit-Day: 100000 X-RateLimit-Remaining-Day: 94120 X-RateLimit-Reset-Day: 1789142400 # unix seconds — next 00:00 HKT (16:00 UTC) # On 429: # Retry-After: 1 # seconds until the relevant bucket replenishes # X-RateLimit-Reason: per_second | per_day
429 response body:
{ "status": 429, "sysCode": "1500004", "message": "Rate limit exceeded (per_day); retry after 9000 seconds", "data": { "reason": "per_day", "retry_after_seconds": 9000, "day_reset_at": "2026-09-11T16:00:00.000Z" } }
Backoff strategy
On 429, read the Retry-After header (seconds). If absent, use exponential backoff with jitter: min(2^n * 100ms, 30s) + rand(0, 250ms). Cap at 5 retries. reason=per_second → wait 1 second; reason=per_day → wait until X-RateLimit-Reset-Day, don't retry at a fixed interval. A rejected 429 does not consume your daily quota.
async function callWithBackoff(req, { max = 5 } = {}) { for (let n = 0; n <= max; n++) { const r = await fetch(req); if (r.status !== 429 && r.status < 500) return r; const retryAfter = Number(r.headers.get('retry-after')); const delay = retryAfter ? retryAfter * 1000 : Math.min(2 ** n * 100, 30_000) + Math.random() * 250; await new Promise(res => setTimeout(res, delay)); } throw new Error('exhausted retries'); }
Server-side caching for terminal shipments
For terminal shipments (delivered / cancelled / returned), GET /api/v1/tracking/{tracking_number} responses are served from a server-side cache for up to 6 hours (X-Merdi-Cache: hit | miss | bypass). Responses always carry Cache-Control: no-store — cache status is only reflected in X-Merdi-Cache. Prefer GET /api/v1/shipments?updated_after= for incremental polling, and stop polling a shipment once it reaches a terminal status.
Replay of repeated rejected requests
If the same key re-sends a payload with identical canonical-JSON content (object key order and whitespace don't matter; array order is preserved) within 1 hour of it being rejected by a carrier data rule (422 YUN_DISPATCH_REJECTED, sys_code 9900027), POST /api/v1/shipments replays the same 422 (data.negative_cache: true, X-Merdi-Negative-Cache: hit) instead of hitting the carrier again. Resending fixed data must use a NEW Idempotency-Key — reusing the old key returns 409 (sys_code 1500003); the existing 422 under the same key is itself replayed by the idempotency layer for 24 hours, and this 1-hour negative cache only applies when a new key resends the identical payload. This cache's key is independent of Idempotency-Key; account-state 402 rejections are not cached this way.
X-Merdi-Cache and X-Merdi-Negative-Cache only describe the cache layer THIS request actually went through. When a response is instead replayed from the 24-hour Idempotency-Key store, it returns the original stored body without either header.