Skip to main content

Handling errors

The error codes page is the complete B2B_* catalog — every code, its HTTP status, its category, and whether a retry can help. This page is about the part that surprises people: not every failure comes back in that shape. Four layers can reject a request, and each answers in its own format.

Four shapes, not one​

Domain errors — code / category​

The shape you will handle most. Anything the platform itself rejects for a business reason:

{
"code": "B2B_INSUFFICIENT_BALANCE",
"category": "PERMANENT",
"message": "El balance disponible es insuficiente para completar la operación.",
"correlationId": "b6e1b6f0-0000-4000-8000-000000000000"
}

message is written in Spanish, whichever language you are reading these docs in. Branch on code, never on message: messages are for humans, they can change, and they are not localized to your locale. Log the correlationId: it is the fastest way for us to find your request.

One code adds a fifth field. B2B_PROVIDER_REJECTED — the payment network refused the request for a reason we do not catalog yet — carries details, the network's own field-level message, verbatim:

{
"code": "B2B_PROVIDER_REJECTED",
"category": "PERMANENT",
"message": "El proveedor rechazó la operación con un error no catalogado. Verifique los datos enviados; la operación no se reintentará automáticamente.",
"details": ["members.1.receiver.beneficiary documentType must be one of V, E, J, G"]
}

Treat details like a validation message: it tells a human what to fix, in English, and its wording is not ours to promise. Log it, show it to your operators, and never branch on it. The error codes page explains when it appears.

Validation errors — 400 with a list​

A malformed request never reaches the domain layer, so it never gets a code. Framework validation answers with message as an array:

{
"statusCode": 400,
"message": ["quotation_id must be a UUID"],
"error": "Bad Request"
}

A missing required object lands here as well, before anything inside it is looked at — a POST /orders without sender, or a POST /quotations without source:

{
"statusCode": 400,
"message": ["sender should not be null or undefined"],
"error": "Bad Request"
}

Unknown fields are rejected here too. Sending a field that is not in the contract — a typo, a leftover from another integration, or a read-only field like bank_display_name — is a 400, not a silent ignore. Send exactly what the schema declares.

A missing required header lands here as well, with message as a plain string rather than an array:

{ "statusCode": 400, "message": "X-Idempotency-Key header is required", "error": "Bad Request" }

So even inside this one shape, message can be either type. Handle both.

Transport and auth errors — message as a string​

Rejections that happen before any application code runs. Same three keys as above, but message is a plain string:

{ "message": "Bearer token required", "error": "Unauthorized", "statusCode": 401 }

There are three of these on the auth path, and the wording tells you which one you hit: Bearer token required (no token), Invalid api token (a token we rejected), and Client identity not established — a token that verified but carries no resolvable client. The last one is not something you can fix by retrying or re-issuing: your credentials need to be looked at on our side.

These come from the token check in authentication.

A signature failure does not look like this. It goes through the domain layer, so it arrives in the first shape with a code you can branch on:

{
"code": "B2B_UNAUTHORIZED",
"category": "PERMANENT",
"message": "No tiene autorización para realizar esta operación o sus credenciales no son válidas."
}

That difference is a free diagnostic: a 401 with code is your signature or nonce, a 401 with error is your token.

If you parse errors, handle both message shapes. A client that assumes message is always a string breaks on the first validation error, and one that assumes it is always an array breaks on the first expired token.

Gateway errors — message and requestId​

One more shape, and the only one that never reaches the service. Requirements declared at the edge — like the X-Idempotency-Key header on POST /orders — are enforced there:

{
"message": "\"Missing required request parameters: [X-Idempotency-Key]\"",
"requestId": "92885917-5fb9-41d1-8272-70cfdefe6c23"
}

No code, no statusCode, no error — a requestId instead. If you are parsing error bodies, this is the fourth case, and the one most likely to slip through a test suite that only exercises the service directly.

What to retry​

category is the whole answer, and it is only present on domain errors:

CategoryMeaningWhat to do
TRANSIENTSomething upstream was unavailable or rate-limitedRetry with backoff
PERMANENTThe request is wrong, or the state does not allow itDo not retry — fix the request
UNKNOWNWe could not classify itRetry once, then escalate with the correlationId

Retrying a PERMANENT error costs you time and gets the identical response. B2B_QUOTATION_ALREADY_USED is the clearest example: a quotation backs exactly one order, forever, so the same call fails the same way for good — see idempotency.

400 and 401 are never worth retrying unchanged either: nothing about the next identical attempt will differ.

A retry that is not a retry​

If a POST /orders call times out or fails in a way you cannot classify, do not build a fresh order. Send the same request with the same X-Idempotency-Key — if the first attempt did land, you get the original order back instead of a duplicate payment. That is the whole point of idempotency.