Errors & Retries
Audience: 🔌 Integration Client
Getting error handling right is most of what separates a fragile integration from a reliable one. This chapter gives you a model you can code against directly.
The error envelope
Almost every error is:
{ "error": { "code": "validation_failed", "message": "…", "details": { "issues": [ … ] } } }
codeis a stable, snake_case string. Branch oncode, never onmessage(messages are human text and can change; they are never localised).detailsis present only when there's structured extra info (e.g. validationissues).
⚠️ The envelope is not universal. A few responses are intentionally flat
{ "error": "<string>" }:
413body-too-large- the idempotency terminal-error body
- the CSRF
403(browser only — you won't hit it as an API client)Everywhere else,
erroris always an object. If you're distinguishing the two shapes in code, checktypeof body.error === "string"for the flat variant. The API Reference'sErrorResponseschema now matches this object envelope (it previously described the legacy flat shape — fixed).
The 400 vs 422 boundary
This distinction is deliberate and useful:
400 validation_failed— the request is malformed: not JSON, not an object, a structural problem. Your code built the request wrong.422 validation_failed— the request is well-formed but the payment fails a schema or business rule. The specific problems are inerror.details.issues. The data is wrong, not the request.
So: 400 → fix your request construction; 422 → surface the issues to whoever supplied the payment data. Neither is retryable as-is.
What's retryable
| Status | code | Retry? | How |
|---|---|---|---|
429 | rate_limited | ✅ yes | wait for Retry-After / retryAfterSeconds, then retry |
503 | overloaded | ✅ yes | transient (converter pool busy); honour Retry-After (~2 s) |
503 | service_unavailable | ✅ yes | a dependency is down; back off |
409 | conflict (in-progress) | ✅ yes | same idempotency key, retry shortly |
409 | conflict (different-body) | ❌ no | you reused a key with a changed body — fix it |
500 | internal_error | ⚠️ maybe | retry once with backoff; if it persists, capture X-Request-ID and escalate |
502 | sftp_probe_failed etc. | ⚠️ maybe | upstream hiccup; limited retry |
400 | any | ❌ no | fix the request |
401 | unauthorized | ❌ no* | re-authenticate (check the key); not a blind retry |
403 | forbidden / admin_required | ❌ no | scope/role problem; will never succeed as-is |
404 | *_not_found | ❌ no | wrong id / resource |
422 | validation_failed etc. | ❌ no | fix the data |
Rule of thumb: retry 429, 503, in-progress 409, and (cautiously) 5xx. Never blind-retry a 4xx other than in-progress 409.
The two faces of 409
Because it's the one that bites people: on POST /journal/payments, 409 means either
- in-progress — "…already being processed. Retry shortly." → retry (same key), or
- different-body — "…already used with a different request payload." → do not retry; it's a client bug.
Tell them apart by the message text, then branch. (Details in Idempotency.)
Rate limiting
- The limit is 300 requests per 60 seconds per caller, on POST routes only (payments, convert, inbound, webhooks, approvals, etc.). GETs are not limited — poll freely.
- ⚠️ **For API keys the bucket is per platform. All of your integration's traffic — every company, every worker — shares one** 300/min bucket. If you run parallel workers, coordinate so you don't starve yourself.
- There are **no
X-RateLimit-*headers.** You find out by getting a429, which carriesRetry-Afteranddetails.retryAfterSeconds. Implement a token-bucket or concurrency limit on your side rather than probing for the ceiling.
A retry helper
async function withRetry<T>(fn: () => Promise<Response>, parse: (r: Response) => Promise<T>) {
const MAX = 5;
for (let attempt = 1; attempt <= MAX; attempt++) {
const res = await fn();
const requestId = res.headers.get("x-request-id");
if (res.ok) return parse(res);
const retryable =
res.status === 429 ||
res.status === 503 ||
(res.status === 409 && /being processed/i.test(await res.clone().text())) ||
res.status >= 500;
if (!retryable || attempt === MAX) {
throw new Error(`${res.status} not retryable (request ${requestId})`);
}
const retryAfter = Number(res.headers.get("retry-after")) || 2 ** attempt;
await new Promise(r => setTimeout(r, retryAfter * 1000));
}
throw new Error("unreachable");
}
Always capture X-Request-ID
Every response carries an X-Request-ID. Log it with every call. When you open a support ticket, quoting it lets the operator look up the exact request in the exception log. It's the single most useful thing you can record.