Saltar al contenido principal

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.

Lee la sección siguiente antes de confiar en un test suite en verde

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íasSandboxProducción
Una cuenta venezolana que no tiene 20 dígitos (0134123)201 Created422 B2B_PROVIDER_REJECTED
Una cuenta cuyos primeros cuatro dígitos no coinciden con bank_name (0102… declarada como BANCO_BANESCO_VE)201 Created422 B2B_PROVIDER_REJECTED
bank_account contra una cotización P2P_PHONE_TRANSFER, o bank_phone_account contra una BANK_TRANSFER201 Created422 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)
Dos trampas de zsh que esos helpers ya esquivan

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ódigoHTTPCómo dispararlo
B2B_UNAUTHORIZED401Omití o corrompé la signature, o desfasá el nonce
B2B_QUOTATION_NOT_FOUND404GET /quotations/{uuid al azar}
B2B_QUOTATION_EXPIRED422Generá una cotización, esperá pasado expires_at, ordená contra ella
B2B_QUOTATION_ALREADY_USED409Dos órdenes contra la misma cotización
B2B_INSUFFICIENT_BALANCE422Orden por más de lo que tiene tu billetera
B2B_BENEFICIARY_INVALID422payment_instructions: {}
B2B_RATE_NOT_FOUND422Cotizá un corredor para el que no tenés ruta
B2B_ORDER_NOT_FOUND404GET /orders/{uuid al azar}
B2B_IDEMPOTENCY_CONFLICT409Mismo X-Idempotency-Key, body distinto
B2B_INVALID_PAGINATION_CURSOR400cursor basura en cualquiera de los dos listados paginados
B2B_PROVIDER_REJECTED422Monto 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."
}
Una cotización vencida se sigue leyendo con 200

GET /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ódigoPor qué es inalcanzable acá
B2B_QUOTATION_MISMATCHSandbox 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_FOUNDTus credenciales de sandbox siempre resuelven a una compañía
B2B_WALLET_NOT_CONFIGUREDLos clientes de sandbox se aprovisionan con billetera
B2B_WALLET_NOT_FOUNDGET /me/wallets/{currency} resuelve tu billetera sin importar la moneda que pases — ver la nota de abajo
B2B_RATE_LIMITEDNo hay límite de tasa de requests en sandbox (30 llamadas en paralelo devolvieron 30 × 200)
B2B_PROVIDER_UNAVAILABLERequiere que la red de pagos esté caída; no hay forma de pedirlo
B2B_UNKNOWNPor definición es una falla sin clasificar — no hay request que produzca una a pedido
GET /me/wallets/{currency} no valida la moneda

EUR, 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"}
Este body no es JSON válido

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​