Reproducir errores en sandbox
La página de códigos de error te dice qué
significa cada B2B_*. Esta te dice cómo provocarlo a propósito, en
sandbox, con el request exacto — para que construyas y pruebes tu manejo de
errores antes de que un pago real dependa de él.
Todas las respuestas de esta página fueron capturadas contra
https://api.gateway.sandbox.retorna.app con un cliente de sandbox cuyo
corredor es VE / VES sobre P2P_PHONE_TRANSFER y BANK_TRANSFER. Tus
rechazos van a diferir en ids y montos, nunca en shape.
Sandbox no rechaza todo lo que rechaza producción. Tres familias de datos
inválidos se aceptan acá con un 201. Un cliente validado solo contra sandbox
pasa sus propios tests y falla en producción.
Dónde sandbox es más permisivo que producción
Estos no son errores que puedas reproducir. Son lo contrario: requests que deberían fallar, y no fallan.
| Lo que envías | Sandbox | Producción |
|---|---|---|
Una cuenta venezolana que no tiene 20 dígitos (0134123) | 201 Created | 422 B2B_PROVIDER_REJECTED |
Una cuenta cuyos primeros cuatro dígitos no coinciden con bank_name (0102… declarada como BANCO_BANESCO_VE) | 201 Created | 422 B2B_PROVIDER_REJECTED |
bank_account contra una cotización P2P_PHONE_TRANSFER, o bank_phone_account contra una BANK_TRANSFER | 201 Created | 422 B2B_QUOTATION_MISMATCH |
Las reglas de número de cuenta están descritas en
qué te dice details;
el cruce de método de pago es B2B_QUOTATION_MISMATCH. Sandbox no aplica
ninguna de las dos.
Hay una cuarta brecha, y es la razón por la que existe la tercera fila:
Una cotización de sandbox no devuelve su destino. Tanto POST /quotations
como GET /quotations/{id} devuelven destination.country y
destination.payout_method.type en null:
"destination": { "country": null, "payout_method": { "type": null } }
Así que el cruce que el catálogo te pide hacer — comparar tus
payment_instructions contra el destination.payout_method de la cotización
antes de enviar — no tiene contra qué comparar en sandbox. Guardá de tu lado el
método de pago que pediste, y no dependas de leerlo de vuelta.
Qué hacer con las cuatro. Validá la forma de la cuenta venezolana en tu propio cliente (20 dígitos, los primeros cuatro coincidiendo con el banco), y mantené el método de pago apareado con el id de cotización con el que lo generaste. Tratá a sandbox como una prueba de tu plomería — auth, firma, idempotencia, lógica de reintentos — no como una prueba de tu payload.
Preparación
Todo lo que sigue asume una shell con tus credenciales de sandbox y los helpers de firma cargados. La firma es obligatoria en todos los endpoints excepto el de token; firma de requests explica los dos modos.
export B2B_CLIENT_ID=... # tu client id de sandbox
export B2B_CLIENT_SECRET=... # tu client secret de sandbox
export B2B_PRIVATE_KEY=./key.pem # la llave PKCS#8 de tu aprovisionamiento
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; }
# modo query: {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"
}
# modo body: JSON.stringify(rawBody) + nonce -- el body string se codifica OTRA VEZ.
# $3, cuando está presente, se envía como 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 no es un nombre de variable libre. En zsh está atada a PATH, así
que un local path=... dentro de una función vacía tu PATH por el resto de
esa llamada y todos los comandos dejan de resolverse. Por eso los helpers usan
req.
zsh no hace word splitting en expansiones sin comillas. Pasar flags extra de
curl por una sola variable ($extra, ${key:+-H "..."}) funciona en bash y en
zsh colapsa silenciosamente a un único argumento. Por eso b2b_post ramifica
según la clave en vez de interpolar un flag.
Dos fixtures que reusan los ejemplos:
QUOTE='{"source":{"currency":"USDR"},"destination":{"country":"VE","currency":"VES",
"payout_method":{"type":"P2P_PHONE_TRANSFER"}},"quote":{"mode":"SEND_EXACT","amount":"10.00"}}'
# genera una cotización fresca y guarda su id
Q=$(b2b_post /quotations "$QUOTE" | tail -1 | jq -r .id)
order_body() { # $1 = id de la cotización
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
}
El tercer argumento de b2b_post es el X-Idempotency-Key. Si lo omites, la
cabecera no se envía — y eso en sí mismo es una de las recetas de más abajo.
Lo que sí podés reproducir
Once de los dieciocho códigos del catálogo son alcanzables en sandbox. Acá están de un vistazo; cada uno tiene su receta más abajo.
| Código | HTTP | Cómo dispararlo |
|---|---|---|
B2B_UNAUTHORIZED | 401 | Omití o corrompé la signature, o desfasá el nonce |
B2B_QUOTATION_NOT_FOUND | 404 | GET /quotations/{uuid al azar} |
B2B_QUOTATION_EXPIRED | 422 | Generá una cotización, esperá pasado expires_at, ordená contra ella |
B2B_QUOTATION_ALREADY_USED | 409 | Dos órdenes contra la misma cotización |
B2B_INSUFFICIENT_BALANCE | 422 | Orden por más de lo que tiene tu billetera |
B2B_BENEFICIARY_INVALID | 422 | payment_instructions: {} |
B2B_RATE_NOT_FOUND | 422 | Cotizá un corredor para el que no tenés ruta |
B2B_ORDER_NOT_FOUND | 404 | GET /orders/{uuid al azar} |
B2B_IDEMPOTENCY_CONFLICT | 409 | Mismo X-Idempotency-Key, body distinto |
B2B_INVALID_PAGINATION_CURSOR | 400 | cursor basura en cualquiera de los dos listados paginados |
B2B_PROVIDER_REJECTED | 422 | Monto fuera de los límites de la ruta, un document.type no venezolano, o un filtro type desconocido |
B2B_UNAUTHORIZED — 401
Cuatro errores distintos colapsan en una sola respuesta, a propósito. Cualquiera de ellos lo reproduce:
# sin firma
curl -s -i "$BASE/me/routes" -H "Authorization: Bearer $B2B_TOKEN"
# una firma bien formada en base64 pero incorrecta
curl -s -i "$BASE/me/routes" -H "Authorization: Bearer $B2B_TOKEN" \
-H "signature: $(printf 'nope' | base64)" -H "nonce: $(($(date +%s) * 1000))"
# una firma correcta con un nonce de hace diez minutos
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."
}
El cuarto es el que realmente cuesta tiempo: firmar el body una vez en lugar
de dos. El mensaje es JSON.stringify(rawBody) + nonce, no rawBody + nonce.
Los dos producen una firma con buena pinta; solo una verifica.
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."
}
Una cotización de otro cliente responde idéntico. No podés distinguir "nunca existió" de "no es tuya", y eso es deliberado.
B2B_QUOTATION_EXPIRED — 422
Una cotización de sandbox vive 15 minutos (exchange_rate.expires_at).
Generá una, esperá, y después ordená contra ella:
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} sobre una cotización vencida devuelve la cotización, no
un error. El vencimiento solo se aplica cuando la usás. Si necesitás saber si
una cotización sigue vigente, compará exchange_rate.expires_at contra tu
propio reloj — no lo infieras del status code.
B2B_QUOTATION_ALREADY_USED — 409
Una cotización respalda exactamente una orden, para siempre. Mandá dos, cada una con su propia clave de idempotencia:
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."
}
Este es el único error de cotización que es 409 y no 422, porque ya existe un
pago del otro lado. Reconciliá antes de reenviar nada — ver
idempotencia.
B2B_INSUFFICIENT_BALANCE — 422
Cotizá por más de lo que reporta GET /me/wallets/USDR, pero quedate por debajo
de tu límite diario de fondeo de GET /me/routes — si no, el gate de fondeo
dispara primero y obtenés otro rechazo.
b2b_get /me/wallets/USDR # leé "amount", digamos 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
Un objeto payment_instructions vacío. Este se rechaza localmente y nunca llega
a la red de pagos:
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."
}
Mandar bank_account y bank_phone_account juntos no cae acá. Se atrapa una
capa antes, como error de validación 400:
{
"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
Cualquier corredor para el que no tengas ruta activa. Revisá GET /me/routes
primero, y después pedí algo por fuera — otro país, o una moneda que no se
empareja con el país que mandaste:
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
La misma clave con un body distinto. Fijate qué hace falta para
reproducirlo: dos cotizaciones distintas, porque reusar una dispararía
B2B_QUOTATION_ALREADY_USED primero.
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."
}
La misma clave con un body idéntico no es un error: devuelve 201 con la
orden original, mismo id. Ese es el replay que querés en un timeout — ver
idempotencia.
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 responde idéntico. Los dos listados paginados
coinciden en este código, así que podés ramificar una sola vez.
B2B_PROVIDER_REJECTED — 422
El comodín para un rechazo que el catálogo no tiene tipificado. Es el único
código que trae details, y details es la única parte que te dice qué
arreglar. Cuatro formas confiables de dispararlo:
Un monto por debajo del mínimo de la ruta:
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"]
}
Un monto por encima del máximo de la ruta — mismo shape, details: ["Amount 99999999.00 exceeds the route's per-transaction maximum 1000000.00"]. Los dos
límites salen de limits.per_transaction en GET /me/routes.
Un tipo de documento de beneficiario fuera del set venezolano. Mandá
document.type: "PASSPORT" en el receptor:
{
"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"]
}
El document.type de un beneficiario venezolano tiene que ser V, E, J o
G — no CI, DNI ni PASSPORT. Es el rechazo más común en integración, y el
único caso donde sandbox sí atrapa el error de payload.
Un valor de filtro type fuera del conjunto permitido en el listado de
órdenes:
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"]
}
Mirá el status: un filtro rechazado da 422, no el 400 que esperarías de
un problema de query param. El filtro se valida río abajo, no en nuestro borde,
así que llega por el camino de rechazo del proveedor como cualquier otro rechazo
no catalogado — y details nombra el conjunto permitido completo, que es la
forma más rápida de corregir la llamada.
Un valor que sí está permitido pero no matchea nada responde 200 con un
data vacío, nunca un error. type=PIG_TO_POG en un cliente que solo hace
payouts desde billetera es un resultado legítimo y vacío.
Lo que no podés reproducir en sandbox
Siete códigos del catálogo no tienen camino desde un request de sandbox. Escribí handlers para ellos igual — son todos alcanzables en producción — pero no gastes una tarde buscando el request que dispara uno.
| Código | Por qué es inalcanzable acá |
|---|---|
B2B_QUOTATION_MISMATCH | Sandbox no cruza el método de pago contra la cotización, y el monto y la moneda se toman de la cotización misma, así que no pueden discrepar |
B2B_COMPANY_NOT_FOUND | Tus credenciales de sandbox siempre resuelven a una compañía |
B2B_WALLET_NOT_CONFIGURED | Los clientes de sandbox se aprovisionan con billetera |
B2B_WALLET_NOT_FOUND | GET /me/wallets/{currency} resuelve tu billetera sin importar la moneda que pases — ver la nota de abajo |
B2B_RATE_LIMITED | No hay límite de tasa de requests en sandbox (30 llamadas en paralelo devolvieron 30 × 200) |
B2B_PROVIDER_UNAVAILABLE | Requiere que la red de pagos esté caída; no hay forma de pedirlo |
B2B_UNKNOWN | Por definición es una falla sin clasificar — no hay request que produzca una a pedido |
GET /me/wallets/{currency} no valida la monedaEUR, VES y XXX devuelven todos tu billetera USDR, con "currency": "USDR"
en el body y status 200. Solo se valida el formato — un valor en minúsculas o
demasiado largo da 400 currency must be 3 to 5 uppercase letters, e.g. USDR.
Leé el campo currency de la respuesta en lugar de asumir que refleja tu
request.
Las cuatro shapes de respuesta, un curl cada una
Manejo de errores explica por qué son cuatro. Estos son los requests que producen cada una, para que apuntes tu parser a todas.
1. Error de dominio — code y category. Todas las recetas de arriba.
2. Error de validación — 400, message como array. Un objeto faltante, un
campo desconocido, un valor mal formado:
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)"],...}
Dentro de esta shape message no siempre es un array. Un enum inválido en un
segmento de path da un string plano:
b2b_get /me/wallets/USDR/transactions/not_a_type
# {"message":"Validation failed (enum string is expected)","error":"Bad Request","statusCode":400}
3. Error de transporte y auth — message como string, error, statusCode:
curl -s -i "$BASE/me/routes" # sin 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 es la excepción a la regla de "code significa firma"Un client secret incorrecto en el endpoint de token responde con la shape de
dominio — code: "B2B_UNAUTHORIZED" — no con esta. Así que el atajo "un
401 con code es mi firma, un 401 con error es mi token" vale en todos los
endpoints menos en el que emite el token. En /oauth2/token, un 401 es siempre
tu par de credenciales.
4. Error de gateway — message y requestId, sin code, sin statusCode.
Omití la cabecera X-Idempotency-Key en POST /orders:
Q=$(b2b_post /quotations "$QUOTE" | tail -1 | jq -r .id)
b2b_post /orders "$(order_body "$Q")" # ojo: sin clave de idempotencia
{"message": " "Missing required request parameters: [X-Idempotency-Key]"", "requestId": "3b306ce4-63ce-4d8a-ad1a-eb22014925f8"}
Mirá el entrecomillado: " "Missing …]"". Las comillas internas no están
escapadas, así que JSON.parse sobre esta respuesta tira excepción — no te
da un objeto con una clave message. Un cliente que parsea todo body de error
sin condiciones se rompe acá en lugar de manejar una cuarta shape.
Protegé el parseo. En POST /orders, un 400 cuyo body falla al parsear
significa que un requisito fue rechazado en el borde, y la cabecera
X-Idempotency-Key es lo primero que hay que revisar.
Para seguir
- Códigos de error — el catálogo completo, con categorías y política de reintento
- Manejo de errores — las cuatro shapes y qué reintentar
- Idempotencia — por qué un reintento necesita la misma clave