Delivery API
Errors & limits
Everything returns the same envelope, success or failure. Switch on the error code rather than the message — codes are part of the contract, messages are written for humans and will change.
The envelope
{
"success": true,
"data": { … },
"meta": { "limit": 50, "nextCursor": "eyJ…" } // list endpoints only
}{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": { "fieldErrors": { "customerPhone": ["String must contain at least 7 character(s)"] } },
"requestId": "9f2b1c7a-5d4e-4a3b-8c1d-2e3f4a5b6c7d"
}
}requestId is also returned as the X-Request-Id response header on every request. Log it. Quoting it in a support ticket is the difference between a fix and a conversation about which Tuesday it was.
Error codes
| Code | HTTP | Means, and what to do |
|---|---|---|
| UNAUTHORIZED | 401 | Missing, malformed, revoked or suspended key. Check the header name and that you sent the whole keyId.secret. Do not retry. |
| FORBIDDEN | 403 | Authenticated, but not allowed to do this. Do not retry. |
| NOT_FOUND | 404 | No such delivery. Also what you get for an id that is not yours. |
| CONFLICT | 409 | Usually an idempotency key replayed with a different body. Fix the caller — do not retry with the same key. |
| VALIDATION_ERROR | 422 | The body or query failed validation. details names the fields. Also what a missing Idempotency-Key returns. Do not retry unchanged. |
| (see note) | 429 | Rate limited. Back off and retry — see below. |
| INTERNAL_ERROR | 500 | Our fault. Safe to retry with the same idempotency key. |
Rate limits
| Surface | Limit | Counted per |
|---|---|---|
| /api/v1/external/* | 60 requests / minute | API key |
| Sign-in and signup | 60 requests / minute | IP address |
| Inbound webhook receivers | 300 requests / minute | IP address |
| Public tracking | 60 requests / minute | IP address |
Limits are returned as standard RateLimit headers (the draft-7 form: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset), so you can read your remaining budget rather than guess at it. Because the external limit is counted per key, a runaway batch job on one key cannot rate-limit your checkout on another — another reason to issue one key per system.
If you need to create more than 60 deliveries a minute, use POST /api/v1/external/dispatches/bulk: 500 deliveries, one request, one slot.
Retrying safely
- Timeout, connection reset, 500, 502, 503: retry with the same idempotency key. If the first attempt did create the delivery, you get that original response back rather than a second delivery.
- 429: back off and retry, same key. Exponential with jitter; do not hammer on a fixed interval.
- 401, 403, 404, 409, 422: do not retry. Nothing about sending it again changes the answer — fix the request or the credentials.
// One key per logical order, stored with the order — not generated per attempt.
const idempotencyKey = order.dispatchIdempotencyKey ?? crypto.randomUUID();
await orders.save({ ...order, dispatchIdempotencyKey: idempotencyKey });
for (let attempt = 0; attempt < 5; attempt += 1) {
const res = await createDispatch(order, idempotencyKey);
if (res.ok) return res.body.data;
const retriable = res.status === 429 || res.status >= 500;
if (!retriable) throw new DispatchRejected(res.body.error); // 4xx: fix the request
await sleep(2 ** attempt * 500 + Math.random() * 250); // backoff + jitter
}Timeouts and clocks
- Give our API a 10 second connect budget and 30 seconds for a response.
- Keep your server clock in sync with NTP. Webhook signatures include a timestamp and a drifting clock will make valid requests look like replays.
- All timestamps we send are UTC. Convert at the point of display, never for storage.