BlendFi

Errors and retries

Every error code mapped to plain meaning, who caused it, whether to retry, and with what backoff.

Reliability

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 rangeCauseSafe to retry?How
2xxSuccessn/an/a
400, 401, 403, 404, 409, 422Your requestNoFix the request, use a new idempotency key
429 rate_limit_exceededYou're sending too fastYesHonor the Retry-After header; exponential backoff if absent
500 internal_errorBlendFi-side bugYesSame key; exponential backoff (250ms, 500ms, 1s, 2s, 4s, give up)
502, 503, 504BlendFi or upstream provider unavailableYesSame 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

CodeHTTPMeaningFix
validation_error400Schema validation failed (request body, query, or params)Read details.issues for per-field messages; correct and resend with a new idempotency key
invalid_json400Body could not be parsed as JSONCheck for trailing commas, unescaped quotes, encoding
invalid_cursor400Pagination cursor is malformed or expiredRestart the listing from the beginning
idempotency_key_required400Idempotency-Key header missing on a POST or PATCHAdd the header; see the Idempotency guide
authentication_required401Authorization header missing or malformedAdd Authorization: Bearer sk_test_…
authentication_failed401Key is unknown, revoked, or has the wrong environment prefixRe-copy the key; verify sandbox vs production
unauthorized401Authenticated but missing the credentials for this resourceCheck the resource is in your tenant
missing_capability403Key is valid but lacks the capability for this endpointEmail us to widen the key's capabilities
user_not_found404The user_id does not exist or is in another tenantVerify the ID; check it belongs to your org
quote_not_found404The quote_id does not exist or is in another tenantSame
transaction_not_found404The transaction_id does not exist or is in another tenantSame
idempotency_key_reused409This Idempotency-Key was used with a different bodyBug in your code — generate a fresh key per logical operation
idempotency_key_in_progress409A request with the same key is still running (under 60 s old)Wait briefly and retry with the same key
invalid_transaction_state409Tried to advance a transaction through a disallowed state transitionRe-fetch the transaction; respect the lifecycle
quote_already_consumed409Quote was already used to create a transactionCreate a new quote
quote_expired409Quote is older than its TTL (typically 30 s)Create a new quote
duplicate_cpf409Another user in your tenant already has this CPFLook up the existing user with GET /v1/users?cpf=…
duplicate_external_id409Another user in your tenant already has this external_idSame

429 — rate limit

CodeHTTPMeaningFix
rate_limit_exceeded429You exceeded the per-key rate limit for this endpointHonor Retry-After; back off; consider client-side throttling

5xx — our problem

CodeHTTPMeaningFix
internal_error500Unexpected server-side failureRetry with the same idempotency key; if it persists, email us with the request_id
upstream_unavailable502An upstream provider (Pix bank, exchange, blockchain) is unreachableRetry 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.

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).

On this page