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 readable prose and can change; they are never localised).detailsis present only when there's structured extra info (e.g. validationissues).
✅ The envelope is universal. Every error response, including the ones that used to be flat, now uses the
{ "error": { "code", "message" } }object shape:413body-too-large (payload_too_large), the idempotency terminal-error (internal_error), and the browser-only CSRF403(csrf_failed). You can rely onbody.error.codeon every non-2xx response. The API Reference'sErrorResponseschema matches this object envelope.
The 400 vs 422 boundary
This distinction is deliberate and useful:
400 validation_failedmeans the request is malformed: not JSON, not an object, a structural problem. Your code built the request wrong.422 validation_failedmeans 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
There are two limits, and they stack. Both are on by default on any deployed server, sandbox included.
1. The per-caller POST limit (300 / 60 s)
The default limit is 300 requests per 60 seconds per caller (tunable by the operator via RATE_LIMIT_MAX / RATE_LIMIT_WINDOW_MS). It applies to a specific allowlist of expensive POST routes:
POST /journal/payments,POST /journal/payments/preview,POST /journal/export,POST /journal/ingestPOST /convert/,POST /convert-async/,POST /validate/,POST /inbound/POST /webhooksand/webhooks/*POST /connectionsand/connections/*POST /approvals//approve·/reject,POST /banks//test-paymentPOST /users(invite) ·POST /users/*/reinvite·POST /admin/test-emailPOST /company/demo-seed·POST /company/demo-reset
⚠️ /journal/payments/preview is on the list, even though it's the endpoint this guide recommends for cheap iteration. It runs the same convert/validate pipeline as a real payment, so it costs the same budget. Don't preview in a tight loop.
Other POSTs, and GETs, are not counted against this bucket, but see the per-IP cap below.
⚠️ 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.
2. The pre-auth per-IP cap (600 / 60 s) — GETs included
Before your credential is even resolved, every request to a non-public route is counted against a coarse per-source-IP cap: 600 requests per 60 seconds (tunable via PREAUTH_IP_RATE_LIMIT_MAX / PREAUTH_IP_RATE_LIMIT_WINDOW_MS).
This one ignores the method — GETs count too. So "polling is free" is not quite true:
- A poller behind a NAT / shared egress IP (a typical on-prem ERP, or several workers behind one gateway) shares that 600/min with everything else leaving that IP, and can
429on plainGETs. - Both limits return the same
429 rate_limitedwithRetry-After.
Keep your polling interval sane (seconds, not milliseconds), back off on 429 even for reads, and prefer webhooks over tight polling.
Headers
There are **no X-RateLimit-* headers.** You find out by getting a 429, which carries Retry-After and details.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.