Errors ⚠️
The Cardda API uses standard HTTP status codes. Most error responses ship a JSON body in one of three shapes:
{ "error": "<machine_code>", "message": "<human readable description>" }{ "errors": { "field_name": ["error 1", "error 2"], ... } }{ "message": "<human readable description>" }The first shape is used for transport-level errors (auth, rate limit, …); message is optional. The second shape is used for validation errors on POST / PATCH / PUT requests. The third — message only, with no error field — is used by a handful of action-level guards (the {"message":"Unauthorized (401)"} responses described below) and by the 416 page-size-cap response. Don't write a parser that requires an error field: branch on the HTTP status first, then read message (and error when present).
A subset of401and403responses ship with an empty body. Specifically, the guards inApplicationControllerfor missing/invalid authentication, missingcompany-id, and Pundit-denied actions renderstatus: :unauthorizedwith no body, and a handful of action-level guards renderstatus: :forbiddenwith no body. A separate set of action-level guards (admin-only mutations, company-scope mismatches acrossbank_payrolls,bank_recipients,bank_transactions,payables,purchase_orders, etc.) instead renderstatus: :unauthorizedwith a JSON body of the shape{"message": "Unauthorized (401)"}— this is the shape published in the per-endpoint OpenAPI pages. Branch your client logic onresponse.statusfirst; parse the body defensively — read it as text and skip JSON parsing if the string is empty, or wrapJSON.parsein a try/catch (don't rely onContent-Length, since proxies or chunked transfer can omit it). We're consolidating these shapes in a follow-up — until then, treat the body as opaque: a401/403may arrive empty or as JSON depending on which guard tripped.
Status code reference
| HTTP | error code | When you see this | Notes |
|---|---|---|---|
400 | bad_request | The request body or query string is malformed (invalid JSON, wrong types, missing required field). | Inspect the message field. Fix and retry. |
401 | (empty body) | Missing or invalid Authorization header, missing company-id when required, or a require_* guard in ApplicationController denied access. | Branch on the status, not the body. Add the header or refresh the key. See The company-id header. |
401 | {"message":"Unauthorized (401)"} | An action-level guard rejected the request (admin-only mutation, company-scope mismatch on bank_payrolls/bank_recipients/bank_transactions/payables/purchase_orders/etc.). This is the shape documented in the per-endpoint OpenAPI pages. | Branch on the status; parse the body defensively (it may also arrive empty for the ApplicationController guards above). |
401 | email_not_verified | The user owning the API key has not verified their email (richer JSON body). | Ask the user to confirm their email. |
403 | (empty body) | An action-level guard denied this request without a body (e.g. card creation gated by issuer state, Sofia API key without the right scope). | Branch on the status, not the body. The empty body is intentional. |
403 | richer JSON (error_code / message) | An action-level guard denied this request and shipped a JSON explanation. Examples: EMAIL_NOT_VERIFIED (Firebase user without a verified email), bank_keys/kyc_incomplete (company KYC pending), or attempting to assign the owner role without being one. | Inspect error_code (when present) and message. Field names vary by endpoint, so don't rely on a single shape. |
404 | not_found | Resource not found, or company-id references a company the user is not a member of. | We do not leak existence — 404 is returned in both cases by design. |
409 | conflict | The action conflicts with current state (e.g. trying to authorize a transaction that is already authorized, enrolling a recipient that already exists). | Re-fetch the resource and reconcile. |
416 | (page size cap) | The pagination window (_end - _start) exceeded the server-side maximum of 2 000 rows. Body shape: {"message": "Requested page size <N> exceeds maximum of 2000"}. | Reduce the window. The recommended page size is 100; see Pagination. |
422 | unprocessable_entity | Validation failed on POST / PATCH / PUT. | Body uses the { errors: { field: [...] } } shape. |
429 | rate_limit_exceeded | Too many requests. | See Rate limits and inspect the Retry-After header. |
500 | internal_error | Unexpected error on Cardda's side. | Retry with exponential backoff; if it persists, contact us with the X-Request-Id response header. |
502 / 503 / 504 | upstream/transient | Temporary upstream issue (a banking partner, a card issuer). | Retry with backoff. |
Examples
Missing company-id
company-id$ curl -i https://api.cardda.com/v1/banking/bank_transactions \
-H "Authorization: Bearer YOUR_API_KEY"
HTTP/1.1 401 Unauthorized
Content-Length: 0The body is empty by design. Do not try to JSON.parse it — branch on the 401 status alone and prompt the user to set the company-id header.
Validation error on create
$ curl -i -X POST https://api.cardda.com/v1/banking/bank_recipients \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "company-id: 550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-d '{"name": "Acme Vendor"}'
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"errors": {
"rut": ["can't be blank", "is invalid"],
"account_number": ["can't be blank"],
"bank_id": ["must reference an existing bank"]
}
}Rate limited
HTTP/1.1 429 Too Many Requests
Retry-After: 1
Content-Type: application/json
{
"error": "rate_limit_exceeded",
"message": "Too many requests. Please retry after 1 second.",
"retry_after": 1
}Page size too large
$ curl -i 'https://api.cardda.com/v1/banking/bank_transactions?_start=0&_end=5000' \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "company-id: 550e8400-e29b-41d4-a716-446655440000"
HTTP/1.1 416 Range Not Satisfiable
Content-Type: application/json
{ "message": "Requested page size 5000 exceeds maximum of 2000" }The cap is enforced server-side to prevent a sync script from mistaking a silently-truncated page for the end of the result set. Reduce _end - _start to ≤ 2 000 (the recommended page size is 100) and walk the collection page by page — see Pagination.
Resource not found / not your company
HTTP/1.1 404 Not Found
Content-Type: application/json
{"error": "not_found"}Recommended client-side handling
async function carddaRequest(path, init) {
const res = await fetch(`https://api.cardda.com${path}`, init);
if (res.ok) return res.json();
let body = {};
try { body = await res.json(); } catch {}
switch (res.status) {
case 401:
// Ask the user to re-authenticate or refresh the company list
throw new AuthError(body);
case 403:
throw new PermissionError(body);
case 404:
throw new NotFoundError(body);
case 422:
throw new ValidationError(body.errors);
case 416:
// Caller asked for more than 2 000 rows in a single page. Body is JSON
// with { message }. Don't retry without shrinking the window.
throw new PageSizeTooLargeError(body);
case 429: {
const retryAfter = Number(res.headers.get("Retry-After") ?? "1");
throw new RateLimitError({ retryAfter });
}
case 500:
case 502:
case 503:
case 504:
throw new TransientError(body, { requestId: res.headers.get("X-Request-Id") });
default:
throw new HttpError(res.status, body);
}
}Tracing an error
Every Cardda response includes an X-Request-Id header. When you contact support about a failing request, paste that ID in the message — we can pull the full trace from our observability tooling instantly.
$ curl -i https://api.cardda.com/v1/banking/bank_transactions ...
HTTP/1.1 500 Internal Server Error
X-Request-Id: 8f3c2a7e-1b4d-4f9a-b2c5-9d7e8f1a2b3cTip. Log the
X-Request-Idfor every non-2xx response in your client. Future-you will thank past-you.
Updated 12 days ago
