Skip to content
DocsProductSign inCreate an account

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
{
  "success": true,
  "data": { … },
  "meta": { "limit": 50, "nextCursor": "eyJ…" }   // list endpoints only
}
Failure
{
  "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

CodeHTTPMeans, and what to do
UNAUTHORIZED401Missing, malformed, revoked or suspended key. Check the header name and that you sent the whole keyId.secret. Do not retry.
FORBIDDEN403Authenticated, but not allowed to do this. Do not retry.
NOT_FOUND404No such delivery. Also what you get for an id that is not yours.
CONFLICT409Usually an idempotency key replayed with a different body. Fix the caller — do not retry with the same key.
VALIDATION_ERROR422The body or query failed validation. details names the fields. Also what a missing Idempotency-Key returns. Do not retry unchanged.
(see note)429Rate limited. Back off and retry — see below.
INTERNAL_ERROR500Our fault. Safe to retry with the same idempotency key.

Rate limits

SurfaceLimitCounted per
/api/v1/external/*60 requests / minuteAPI key
Sign-in and signup60 requests / minuteIP address
Inbound webhook receivers300 requests / minuteIP address
Public tracking60 requests / minuteIP 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.
Retry shape that will not duplicate
// 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.