Reproducing errors in sandbox
The error codes page tells you what each
B2B_* code means. This page tells you how to make one happen on purpose,
in sandbox, with the exact request — so you can build and test your error
handling before a real payout depends on it.
Every response on this page was captured against
https://api.gateway.sandbox.retorna.app with a sandbox client whose corridor
is VE / VES over P2P_PHONE_TRANSFER and BANK_TRANSFER. Your own rejections
will differ in ids and amounts, never in shape.
Sandbox does not reject everything production rejects. Three families of
invalid data are accepted here with a 201. A client validated only against
sandbox will pass its own tests and fail in production.
Where sandbox is more permissive than production​
These are not errors you can reproduce. They are the opposite: requests that should fail, and do not.
| What you send | Sandbox | Production |
|---|---|---|
A Venezuelan account number that is not 20 digits (0134123) | 201 Created | 422 B2B_PROVIDER_REJECTED |
An account number whose first four digits do not match bank_name (0102… declared as BANCO_BANESCO_VE) | 201 Created | 422 B2B_PROVIDER_REJECTED |
bank_account against a P2P_PHONE_TRANSFER quotation, or bank_phone_account against a BANK_TRANSFER one | 201 Created | 422 B2B_QUOTATION_MISMATCH |
The account-number rules are described in
what details tells you;
the payout-method cross-check is B2B_QUOTATION_MISMATCH. Sandbox enforces
neither.
There is a fourth gap, and it is the reason the third row exists at all:
A sandbox quotation does not echo back its destination. Both
POST /quotations and GET /quotations/{id} return destination.country
and destination.payout_method.type as null:
"destination": { "country": null, "payout_method": { "type": null } }
So the cross-check the catalog tells you to perform — compare your
payment_instructions against the quotation's destination.payout_method
before sending — has nothing to compare against in sandbox. Track the payout
method you asked for on your side, and do not rely on reading it back.
What to do about all four. Validate the Venezuelan account shape in your own client (20 digits, first four matching the bank), and keep your payout method paired with the quotation id you minted it from. Treat sandbox as a test of your plumbing — auth, signing, idempotency, retry logic — not as a test of your payload.
Setup​
Everything below assumes a shell with your sandbox credentials and the signing helpers loaded. Signing is mandatory on every endpoint except the token call; request signing explains the two modes.
export B2B_CLIENT_ID=... # your sandbox client id
export B2B_CLIENT_SECRET=... # your sandbox client secret
export B2B_PRIVATE_KEY=./key.pem # the PKCS#8 key from your provisioning
export BASE=https://api.gateway.sandbox.retorna.app
b2b_token() {
curl -s -X POST "$BASE/oauth2/token" \
-H "Authorization: Basic $(printf '%s:%s' "$B2B_CLIENT_ID" "$B2B_CLIENT_SECRET" | base64)" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "scope=sandbox/full_access" | jq -r .access_token
}
_b2b_sign() { openssl dgst -sha256 -sign "$B2B_PRIVATE_KEY" | openssl base64 -A; }
# query mode: {path}?{queryString}{nonce}
b2b_get() {
local req="$1" qs="${2:-}" nonce sig
nonce=$(($(date +%s) * 1000))
sig=$(printf '%s?%s%s' "$req" "$qs" "$nonce" | _b2b_sign)
curl -s -i -X GET "$BASE$req${qs:+?$qs}" \
-H "Authorization: Bearer $B2B_TOKEN" \
-H "signature: $sig" -H "nonce: $nonce"
}
# body mode: JSON.stringify(rawBody) + nonce -- the body string is encoded AGAIN.
# $3, when present, is sent as X-Idempotency-Key.
b2b_post() {
local req="$1" body="$2" key="${3:-}" nonce sig
nonce=$(($(date +%s) * 1000))
sig=$(printf '%s%s' "$(printf '%s' "$body" | jq -Rs .)" "$nonce" | _b2b_sign)
if [ -n "$key" ]; then
curl -s -i -X POST "$BASE$req" \
-H "Authorization: Bearer $B2B_TOKEN" -H "Content-Type: application/json" \
-H "signature: $sig" -H "nonce: $nonce" \
-H "X-Idempotency-Key: $key" -d "$body"
else
curl -s -i -X POST "$BASE$req" \
-H "Authorization: Bearer $B2B_TOKEN" -H "Content-Type: application/json" \
-H "signature: $sig" -H "nonce: $nonce" -d "$body"
fi
}
export B2B_TOKEN=$(b2b_token)
path is not a free variable name. In zsh it is tied to PATH, so a
local path=... inside a function empties your PATH for the rest of that call
and every command in it stops resolving. That is why the helpers use req.
zsh does not word-split unquoted expansions. Passing extra curl flags
through a single variable ($extra, ${key:+-H "..."}) works in bash and
silently collapses into one argument in zsh. That is why b2b_post branches on
the key instead of interpolating a flag.
Two fixtures the examples reuse:
QUOTE='{"source":{"currency":"USDR"},"destination":{"country":"VE","currency":"VES",
"payout_method":{"type":"P2P_PHONE_TRANSFER"}},"quote":{"mode":"SEND_EXACT","amount":"10.00"}}'
# mint a fresh quotation and keep its id
Q=$(b2b_post /quotations "$QUOTE" | tail -1 | jq -r .id)
order_body() { # $1 = quotation id
cat <<JSON
{"quotation_id":"$1","purpose":"FAMILY_SUPPORT",
"sender":{"names":"Ana Maria","last_names":"Perez Gomez",
"document":{"type":"CC","id":"1020304050","country":"CO"},"country":"CO"},
"destination":{
"receiver":{"names":"Carlos Andres","last_names":"Rodriguez Lopez",
"document":{"type":"V","id":"12345678","country":"VE"},
"email":"[email protected]","country":"VE"},
"payment_instructions":{"bank_phone_account":{
"bank_name":"BANCO_BANESCO_VE","phone_number":"+584141234567"}}}}
JSON
}
The third argument to b2b_post is the X-Idempotency-Key. Leave it off and
the header is not sent at all — which is itself one of the recipes below.
What you can reproduce​
Eleven of the eighteen catalog codes are reachable in sandbox. Here they are at a glance; each has its own recipe below.
| Code | HTTP | Trigger in one line |
|---|---|---|
B2B_UNAUTHORIZED | 401 | Omit or corrupt the signature, or skew the nonce |
B2B_QUOTATION_NOT_FOUND | 404 | GET /quotations/{random uuid} |
B2B_QUOTATION_EXPIRED | 422 | Mint a quotation, wait past expires_at, order against it |
B2B_QUOTATION_ALREADY_USED | 409 | Two orders against the same quotation |
B2B_INSUFFICIENT_BALANCE | 422 | Order for more than your wallet holds |
B2B_BENEFICIARY_INVALID | 422 | payment_instructions: {} |
B2B_RATE_NOT_FOUND | 422 | Quote a corridor you have no route for |
B2B_ORDER_NOT_FOUND | 404 | GET /orders/{random uuid} |
B2B_IDEMPOTENCY_CONFLICT | 409 | Same X-Idempotency-Key, different body |
B2B_INVALID_PAGINATION_CURSOR | 400 | Garbage cursor on either paginated listing |
B2B_PROVIDER_REJECTED | 422 | Amount outside route limits, a non-VE document.type, or an unknown type filter |
B2B_UNAUTHORIZED — 401​
Four different mistakes collapse into one response, on purpose. Any of them reproduces it:
# no signature at all
curl -s -i "$BASE/me/routes" -H "Authorization: Bearer $B2B_TOKEN"
# a signature that is well-formed base64 but wrong
curl -s -i "$BASE/me/routes" -H "Authorization: Bearer $B2B_TOKEN" \
-H "signature: $(printf 'nope' | base64)" -H "nonce: $(($(date +%s) * 1000))"
# a correct signature with a nonce ten minutes in the past
NONCE=$(( ($(date +%s) - 600) * 1000 ))
curl -s -i "$BASE/me/routes" -H "Authorization: Bearer $B2B_TOKEN" \
-H "signature: $(printf '/me/routes?%s' "$NONCE" | _b2b_sign)" -H "nonce: $NONCE"
{
"code": "B2B_UNAUTHORIZED",
"category": "PERMANENT",
"message": "No tiene autorización para realizar esta operación o sus credenciales no son válidas."
}
The fourth is the one that actually costs people time: signing the body
once instead of twice. JSON.stringify(rawBody) + nonce is the message —
not rawBody + nonce. Both produce a valid-looking signature; only one
verifies.
B2B_QUOTATION_NOT_FOUND — 404​
b2b_get /quotations/11111111-2222-4333-8444-555555555555
{
"code": "B2B_QUOTATION_NOT_FOUND",
"category": "PERMANENT",
"message": "La cotización referenciada no existe o no pertenece a su cliente."
}
A quotation belonging to another client answers identically. You cannot tell "never existed" from "not yours", and that is deliberate.
B2B_QUOTATION_EXPIRED — 422​
A sandbox quotation lives 15 minutes (exchange_rate.expires_at). Mint one,
wait, then order against it:
Q=$(b2b_post /quotations "$QUOTE" | tail -1 | jq -r .id)
sleep 900
b2b_post /orders "$(order_body "$Q")" "$(uuidgen)"
{
"code": "B2B_QUOTATION_EXPIRED",
"category": "PERMANENT",
"message": "La cotización expiró antes de procesar la operación. Genere una cotización nueva e intente otra vez."
}
200GET /quotations/{id} on an expired quotation returns the quotation, not an
error. Expiry is only enforced when you use it. If you need to know whether a
quotation is still good, compare exchange_rate.expires_at against your own
clock — do not infer it from the status code.
B2B_QUOTATION_ALREADY_USED — 409​
One quotation backs exactly one order, forever. Send two, each with its own idempotency key:
Q=$(b2b_post /quotations "$QUOTE" | tail -1 | jq -r .id)
b2b_post /orders "$(order_body "$Q")" "$(uuidgen)" # 201
b2b_post /orders "$(order_body "$Q")" "$(uuidgen)" # 409
{
"code": "B2B_QUOTATION_ALREADY_USED",
"category": "PERMANENT",
"message": "La cotización referenciada ya respalda otra operación: es de uso único. NO reintente con la misma cotización. Verifique el estado de la operación existente antes de crear una nueva; si corresponde, genere una cotización nueva."
}
This is the one quotation error that is a 409 and not a 422, because a
payout already exists on the other side. Reconcile before you re-send anything —
see idempotency.
B2B_INSUFFICIENT_BALANCE — 422​
Quote for more than GET /me/wallets/USDR reports, but stay under your daily
funding limit from GET /me/routes — otherwise the funding gate fires first and
you get a different rejection.
b2b_get /me/wallets/USDR # read "amount", say 4900.00
Q=$(b2b_post /quotations '{"source":{"currency":"USDR"},"destination":{"country":"VE",
"currency":"VES","payout_method":{"type":"P2P_PHONE_TRANSFER"}},
"quote":{"mode":"SEND_EXACT","amount":"4950.00"}}' | tail -1 | jq -r .id)
b2b_post /orders "$(order_body "$Q")" "$(uuidgen)"
{
"code": "B2B_INSUFFICIENT_BALANCE",
"category": "PERMANENT",
"message": "El balance disponible es insuficiente para completar la operación."
}
B2B_BENEFICIARY_INVALID — 422​
An empty payment_instructions object. This one is rejected locally and never
reaches the payment network:
Q=$(b2b_post /quotations "$QUOTE" | tail -1 | jq -r .id)
b2b_post /orders '{"quotation_id":"'$Q'","purpose":"FAMILY_SUPPORT",
"sender":{"names":"Ana","last_names":"Perez","document":{"type":"CC","id":"1020304050",
"country":"CO"},"country":"CO"},
"destination":{"receiver":{"names":"Carlos","last_names":"Rodriguez",
"document":{"type":"V","id":"12345678","country":"VE"},
"email":"[email protected]","country":"VE"},
"payment_instructions":{}}}' "$(uuidgen)"
{
"code": "B2B_BENEFICIARY_INVALID",
"category": "PERMANENT",
"message": "Los datos del beneficiario son inválidos o están incompletos para el canal de pago seleccionado."
}
Sending both bank_account and bank_phone_account does not land here. It
is caught one layer earlier, as a 400 validation error:
{
"message": ["destination.payment_instructions.payment_instructions must carry either bank_account or bank_phone_account, not both"],
"error": "Bad Request",
"statusCode": 400
}
B2B_RATE_NOT_FOUND — 422​
Any corridor you have no active route for. Check GET /me/routes first, then
ask for something outside it — a different country, or a currency that does not
pair with the country you sent:
b2b_post /quotations '{"source":{"currency":"USDR"},"destination":{"country":"PE",
"currency":"PEN","payout_method":{"type":"P2P_PHONE_TRANSFER"}},
"quote":{"mode":"SEND_EXACT","amount":"10.00"}}'
{
"code": "B2B_RATE_NOT_FOUND",
"category": "PERMANENT",
"message": "No hay una tarifa activa para el corredor solicitado (moneda origen/destino, paÃs y método de pago). Verifique la configuración de rutas disponibles."
}
B2B_ORDER_NOT_FOUND — 404​
b2b_get /orders/11111111-2222-4333-8444-555555555555
{
"code": "B2B_ORDER_NOT_FOUND",
"category": "PERMANENT",
"message": "La orden referenciada no existe o no pertenece a su cliente."
}
B2B_IDEMPOTENCY_CONFLICT — 409​
The same key with a different body. Note what it takes to reproduce: two
distinct quotations, because reusing one would trip
B2B_QUOTATION_ALREADY_USED first.
KEY=$(uuidgen)
Q1=$(b2b_post /quotations "$QUOTE" | tail -1 | jq -r .id)
Q2=$(b2b_post /quotations "$QUOTE" | tail -1 | jq -r .id)
b2b_post /orders "$(order_body "$Q1")" "$KEY" # 201
b2b_post /orders "$(order_body "$Q2")" "$KEY" # 409
{
"code": "B2B_IDEMPOTENCY_CONFLICT",
"category": "PERMANENT",
"message": "La clave de idempotencia ya fue utilizada para crear una orden con datos distintos. Use una clave nueva por cada orden."
}
The same key with an identical body is not an error at all: it returns
201 with the original order, same id. That is the replay you want on a
timeout — see idempotency.
B2B_INVALID_PAGINATION_CURSOR — 400​
b2b_get /me/wallets/USDR/transactions/top_up "cursor=not-a-cursor"
{
"code": "B2B_INVALID_PAGINATION_CURSOR",
"category": "PERMANENT",
"message": "El cursor de paginación no es válido. Use el `next_cursor` devuelto por la consulta anterior, o repita la consulta sin cursor para empezar de nuevo."
}
GET /orders?cursor=not-a-cursor answers identically. Both paginated
listings agree on this code, so you can branch on it once.
B2B_PROVIDER_REJECTED — 422​
The catch-all for a rejection the catalog has no specific code for. It is the
only code that carries details, and details is the only part that tells you
what to fix. Four reliable ways to trigger it:
An amount below the route minimum:
b2b_post /quotations '{"source":{"currency":"USDR"},"destination":{"country":"VE",
"currency":"VES","payout_method":{"type":"P2P_PHONE_TRANSFER"}},
"quote":{"mode":"SEND_EXACT","amount":"0.01"}}'
{
"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": ["Amount 0.01 is below the route's per-transaction minimum 1.00"]
}
An amount above the route maximum — same shape, details: ["Amount 99999999.00 exceeds the route's per-transaction maximum 1000000.00"]. Both
bounds come from limits.per_transaction in GET /me/routes.
A beneficiary document type outside the Venezuelan set. Send
document.type: "PASSPORT" on the receiver:
{
"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"]
}
A Venezuelan beneficiary's document.type must be V, E, J or G — not
CI, DNI or PASSPORT. This is the single most common rejection in
integration, and the one case where sandbox does catch the payload error.
A type filter value outside the allowed set on the order listing:
b2b_get /orders "type=NOT_A_TYPE"
{
"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": ["type must be one of the following values: WALLET_TO_POG, PIG_TO_POG, PIG_TO_WALLET, SETTLEMENT_REVERSAL, SETTLEMENT_RECOVERY"]
}
Note the status: a rejected filter is a 422, not the 400 you might
expect from a query-parameter problem. The filter is validated downstream, not
at our edge, so it arrives through the provider-rejection path like any other
uncatalogued rejection — and details names the whole allowed set, which is
the fastest way to fix the call.
A value that is allowed but matches nothing answers 200 with an empty
data array, never an error. type=PIG_TO_POG on a client that only sends
wallet payouts is a legitimate, empty result.
What you cannot reproduce in sandbox​
Seven catalog codes have no path from a sandbox request. Write handlers for them anyway — they are all reachable in production — but do not spend an afternoon hunting for the request that triggers one.
| Code | Why it is unreachable here |
|---|---|
B2B_QUOTATION_MISMATCH | Sandbox does not cross-check the payout method against the quotation, and the amount and currency are taken from the quotation itself, so they cannot disagree |
B2B_COMPANY_NOT_FOUND | Your sandbox credentials always resolve to a company |
B2B_WALLET_NOT_CONFIGURED | Sandbox clients are provisioned with a wallet |
B2B_WALLET_NOT_FOUND | GET /me/wallets/{currency} resolves your wallet regardless of the currency you pass — see the note below |
B2B_RATE_LIMITED | No request-rate limit is applied in sandbox (30 parallel calls returned 30 × 200) |
B2B_PROVIDER_UNAVAILABLE | Requires the payment network to be down; there is no way to ask for it |
B2B_UNKNOWN | By definition an unclassified failure — there is no request that produces one on demand |
GET /me/wallets/{currency} does not validate the currencyEUR, VES and XXX all return your USDR wallet, with "currency": "USDR"
in the body and a 200 status. Only the format is checked — a lowercase or
overlong value gives 400 currency must be 3 to 5 uppercase letters, e.g. USDR.
Read the currency field in the response rather than assuming it echoes your
request.
The four response shapes, one curl each​
Handling errors explains why there are four. These are the requests that produce each one, so you can point your parser at all of them.
1. Domain error — code and category. Every recipe above.
2. Validation error — 400, message as an array. A missing object, an
unknown field, a malformed value:
b2b_post /quotations '{"destination":{"country":"VE","currency":"VES",
"payout_method":{"type":"P2P_PHONE_TRANSFER"}},"quote":{"mode":"SEND_EXACT","amount":"10.00"}}'
# {"message":["source should not be null or undefined"],"error":"Bad Request","statusCode":400}
b2b_post /quotations "$(echo "$QUOTE" | jq '. + {not_a_real_field:"x"}' -c)"
# {"message":["property not_a_real_field should not exist"],"error":"Bad Request","statusCode":400}
b2b_post /quotations "$(echo "$QUOTE" | jq '.quote.amount="0"' -c)"
# {"message":["quote.amount must be a positive decimal string (e.g. 1000.00)"],...}
message is not always an array inside this shape. A bad enum in a path
segment gives a plain string:
b2b_get /me/wallets/USDR/transactions/not_a_type
# {"message":"Validation failed (enum string is expected)","error":"Bad Request","statusCode":400}
3. Transport and auth error — message as a string, error, statusCode:
curl -s -i "$BASE/me/routes" # no token
# {"message":"Bearer token required","error":"Unauthorized","statusCode":401}
curl -s -i "$BASE/me/routes" -H "Authorization: Bearer not.a.jwt"
# {"message":"Invalid api token","error":"Unauthorized","statusCode":401}
/oauth2/token is the exception to the "code means signature" ruleA wrong client secret on the token endpoint answers with the domain shape —
code: "B2B_UNAUTHORIZED" — not this one. So the shortcut "a 401 with code
is my signature, a 401 with error is my token" holds on every endpoint
except the one that issues the token. On /oauth2/token, a 401 is always your
credential pair.
4. Gateway error — message and requestId, no code, no statusCode.
Omit the X-Idempotency-Key header on POST /orders:
Q=$(b2b_post /quotations "$QUOTE" | tail -1 | jq -r .id)
b2b_post /orders "$(order_body "$Q")" # note: no idempotency header
{"message": " "Missing required request parameters: [X-Idempotency-Key]"", "requestId": "3b306ce4-63ce-4d8a-ad1a-eb22014925f8"}
Look at the quoting: " "Missing …]"". The inner quotes are not escaped, so
JSON.parse on this response throws — it does not give you an object with a
message key. A client that parses every error body unconditionally crashes
here rather than handling a fourth shape.
Guard the parse. On POST /orders, a 400 whose body fails to parse means a
requirement was rejected at the edge, and the X-Idempotency-Key header is the
first thing to check.
Where to go next​
- Error codes — the full catalog, with categories and retry policy
- Handling errors — the four shapes and what to retry
- Idempotency — why a retry needs the same key