Errors and retries
Every error code mapped to plain meaning, who caused it, whether to retry, and with what backoff.
Errors and retries
When something fails in the BlendFi API, the response is always JSON and always has the same shape. This guide shows you how to read it, what each code means, and which errors are safe to retry.
The error envelope
{
"code": "invalid_transaction_state",
"message": "Transaction cannot transition from 'buying_crypto' to 'completed'.",
"request_id": "01KPR9F6MM8G147177J7ZQPJHG",
"details": { /* optional, present on validation errors */ }
}Branch on `code`
`code` is stable, machine-readable, and never changes meaning. All your error handling should key off this field.
Show `message` to humans
`message` is the human-readable description. We may rewrite the wording over time, so don't pattern-match it in code.
Send `request_id` when asking for help
Every response carries a `request_id`. Include it in support emails — we find your exact request in seconds.
How to handle an error
1. Parse the code first
Don't read the HTTP status alone. The same 400 can be validation_error (you'll fix it), invalid_json (also you), or idempotency_key_required (you forgot the header). Branch on code, not status.
const res = await fetch(url, options);
if (!res.ok) {
const error = await res.json();
switch (error.code) {
case "validation_error":
// Show field errors from error.details.issues
return showFieldErrors(error.details?.issues);
case "idempotency_key_required":
throw new Error("missing idempotency key — bug in our retry layer");
case "rate_limit_exceeded":
return retryAfterBackoff(error);
case "internal_error":
return retryWithBackoff(error);
default:
return reportUnexpected(error);
}
}2. Decide whether to retry
Use the retry policy below. The short version: 5xx and 429 are always safe to retry with the same idempotency key. 4xx are not — fix the request and use a fresh key.
3. Log request_id for everything
Whether the call succeeded or failed, store request_id alongside the operation in your audit log. When a partner reports "transaction X is stuck", that ID lets us trace the exact request through our logs in seconds — without it, we're searching by timestamp and account ID, which is slow and ambiguous.
log.info({
request_id: res.headers.get("x-request-id"),
transaction_id: body.id,
status: res.status,
}, "blendfi.transaction.created");Retry policy
One rule
Retry 5xx and 429. Don't retry 4xx. Use the same idempotency key when you retry; mint a new one only when you've changed the request to fix a 4xx.
| Status range | Cause | Safe to retry? | How |
|---|---|---|---|
2xx | Success | n/a | n/a |
400, 401, 403, 404, 409, 422 | Your request | No | Fix the request, use a new idempotency key |
429 rate_limit_exceeded | You're sending too fast | Yes | Honor the Retry-After header; exponential backoff if absent |
500 internal_error | BlendFi-side bug | Yes | Same key; exponential backoff (250ms, 500ms, 1s, 2s, 4s, give up) |
502, 503, 504 | BlendFi or upstream provider unavailable | Yes | Same key; exponential backoff |
A sane retry loop
async function withRetry(fn) {
const MAX_ATTEMPTS = 5;
let lastError;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
try {
const res = await fn();
if (res.status < 500 && res.status !== 429) return res;
lastError = await res.json();
} catch (networkError) {
lastError = networkError;
}
const backoff = Math.min(2 ** attempt * 250, 4000);
const jitter = Math.random() * 100;
await sleep(backoff + jitter);
}
throw new Error("blendfi: exhausted retries", { cause: lastError });
}Wrap fn() so the same idempotency key is reused across attempts — see the Idempotency guide for why this matters.
Full error catalogue
4xx — your request
| Code | HTTP | Meaning | Fix |
|---|---|---|---|
validation_error | 400 | Schema validation failed (request body, query, or params) | Read details.issues for per-field messages; correct and resend with a new idempotency key |
invalid_json | 400 | Body could not be parsed as JSON | Check for trailing commas, unescaped quotes, encoding |
invalid_cursor | 400 | Pagination cursor is malformed or expired | Restart the listing from the beginning |
idempotency_key_required | 400 | Idempotency-Key header missing on a POST or PATCH | Add the header; see the Idempotency guide |
authentication_required | 401 | Authorization header missing or malformed | Add Authorization: Bearer sk_test_… |
authentication_failed | 401 | Key is unknown, revoked, or has the wrong environment prefix | Re-copy the key; verify sandbox vs production |
unauthorized | 401 | Authenticated but missing the credentials for this resource | Check the resource is in your tenant |
missing_capability | 403 | Key is valid but lacks the capability for this endpoint | Email us to widen the key's capabilities |
user_not_found | 404 | The user_id does not exist or is in another tenant | Verify the ID; check it belongs to your org |
quote_not_found | 404 | The quote_id does not exist or is in another tenant | Same |
transaction_not_found | 404 | The transaction_id does not exist or is in another tenant | Same |
idempotency_key_reused | 409 | This Idempotency-Key was used with a different body | Bug in your code — generate a fresh key per logical operation |
idempotency_key_in_progress | 409 | A request with the same key is still running (under 60 s old) | Wait briefly and retry with the same key |
invalid_transaction_state | 409 | Tried to advance a transaction through a disallowed state transition | Re-fetch the transaction; respect the lifecycle |
quote_already_consumed | 409 | Quote was already used to create a transaction | Create a new quote |
quote_expired | 409 | Quote is older than its TTL (typically 30 s) | Create a new quote |
duplicate_cpf | 409 | Another user in your tenant already has this CPF | Look up the existing user with GET /v1/users?cpf=… |
duplicate_external_id | 409 | Another user in your tenant already has this external_id | Same |
429 — rate limit
| Code | HTTP | Meaning | Fix |
|---|---|---|---|
rate_limit_exceeded | 429 | You exceeded the per-key rate limit for this endpoint | Honor Retry-After; back off; consider client-side throttling |
5xx — our problem
| Code | HTTP | Meaning | Fix |
|---|---|---|---|
internal_error | 500 | Unexpected server-side failure | Retry with the same idempotency key; if it persists, email us with the request_id |
upstream_unavailable | 502 | An upstream provider (Pix bank, exchange, blockchain) is unreachable | Retry with backoff; we surface the upstream identifier in details.upstream |
Validation errors in detail
When the response is 400 validation_error, details.issues lists every failed field:
{
"code": "validation_error",
"message": "Request validation failed.",
"request_id": "01KPR9F6MM8G147177J7ZQPJHG",
"details": {
"issues": [
{ "path": "cpf", "message": "must be 11 digits" },
{ "path": "external_id", "message": "is required" }
]
}
}path is the dotted JSON path of the offending field. Show these inline next to your form fields — your end users will appreciate it.
What to read next
Idempotency
The retry contract — same key, same response. Required for every retry strategy in this guide.
Transaction lifecycle
Where the `invalid_transaction_state` errors come from — the legal state transitions, in one diagram.
Environments and limits
Current rate limits per endpoint, sandbox vs production differences, how to request raises.
FAQ
Should I retry on a 400 validation_error?
No. The exact same request will fail the same way every time. Fix the body, mint a new idempotency key, send again.
The code field has a value I've never seen. What do I do?
Treat it as internal_error for retry purposes (don't retry destructive operations; do retry idempotent reads with backoff). Email us with the request_id and we'll either document the code or fix the bug.
My retry budget is exhausted but I haven't seen a final response. What's the state?
For POST and PATCH, query the resource directly with GET using the parameters you sent. If you used the recommended external_id/Idempotency-Key patterns, your data is findable — you just don't know if the original create succeeded. The lookup tells you.
429 doesn't include a Retry-After. How long should I back off?
Use exponential backoff starting at 1 second, capped at 30 seconds. We aim to always include Retry-After, but degraded responses may strip it.
What's the difference between authentication_failed and unauthorized?
authentication_failed means we can't identify you (bad key). unauthorized means we know who you are but you're trying to access a resource that isn't yours (e.g. a transaction in another organization).
