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:
| Category | Meaning | What to do |
|---|---|---|
TRANSIENT | Something upstream was unavailable or rate-limited | Retry with backoff |
PERMANENT | The request is wrong, or the state does not allow it | Do not retry — fix the request |
UNKNOWN | We could not classify it | Retry 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.