Modo Sandbox
El modo Sandbox es un entorno de pruebas por cliente y por región que permite hacer llamadas reales a la API y probar el flujo completo — creación, cambio de estado, movimiento de balance y webhooks — sin mover dinero real.
Qué ocurre en cada request
Sección titulada «Qué ocurre en cada request»Tu request llega a la API │ ▼La API detecta que tu perfil de la región está en SANDBOX │ ▼Se crea la transacción real (PENDING / PROCESSING) y se devuelvela respuesta con el mismo formato que producción { txId, qrCode, qrImage, estimatedFees, ... } │ ▼El desenlace se simula de inmediato: la transacción se cierra comoCOMPLETED, se mueve el ledger y se dispara el webhook con "sandbox": trueLa respuesta del POST de creación sigue devolviendo el estado inicial (PENDING para pay-in, PROCESSING para pay-out Bre-B, COMPLETED para dispersión Nequi). El cierre llega por webhook, o lo ves en el GET de estado.
Flujo completo de integración
Sección titulada «Flujo completo de integración»1. POST /v1/co/breb/payin/qr ← data: { txId: "abc123", qrCode: "00020126...", status: "PENDING" }
2. (muestras el QR — nadie paga en realidad)
3. El gateway simula el desenlace y cierra la transacción │ ▼4. → Tu webhookUrl recibe (evento en el header X-Webhook-Event: payin.completed): { "txId": "abc123", "type": "PAYIN", "status": "COMPLETED", "amount": 50000, "currency": "COP", "feeCharged": 1500, "netAmount": 48500, "sandbox": true }
5. GET /v1/co/breb/payin/qr/abc123 ← data: { status: "COMPLETED", feeClient: 1500, netAmount: 48500, ... }
6. GET /v1/co/balance ← data: el netAmount ya está acreditadoSimular errores (simulate)
Sección titulada «Simular errores (simulate)»Los fallos reales (“el celular no tiene Nequi”, “Bre-B caído”, “saldo insuficiente”) dependen del estado del proveedor y no se pueden provocar a voluntad. Con simulate pides el error que quieres recibir y la API te lo devuelve con el mismo envelope, el mismo status HTTP y el mismo código que devolvería de verdad.
- Es completamente opcional. Si no lo envías, la petición sigue su curso normal (
201/200y el desenlace simulado de siempre). - El mensaje no es configurable. Sale siempre del catálogo de la API, para que lo que ves en sandbox sea palabra por palabra lo que llegaría en producción.
- Aplica a todos los endpoints de pay-in y pay-out, en Nequi y en Bre-B.
- Todo error simulado incluye
"simulated": truedentro dedetails, para distinguirlo de un fallo real en tus logs.
Cómo se envía
Sección titulada «Cómo se envía»En el cuerpo, en cualquiera de estas tres formas (equivalentes):
{ "amount": 10000, "phoneNumber": "3001234567", "simulate": "11-37L" }{ "amount": 10000, "phoneNumber": "3001234567", "simulate": { "error": "502" } }{ "amount": 10000, "phoneNumber": "3001234567", "simulate": { "status": 422, "code": "11-37L" } }En endpoints GET y DELETE, que no llevan cuerpo, se usa la cabecera:
X-Simulate-Error: 502Si envías las dos, manda la cabecera.
Valores aceptados
Sección titulada «Valores aceptados»Códigos de negocio de Nequi — se devuelven como 422 con el mensaje real:
| Valor | HTTP | Mensaje |
|---|---|---|
20-08A | 422 | El número de celular no tiene una cuenta Nequi. |
11-37L | 422 | El número de celular no tiene una cuenta Nequi. |
20-05A | 422 | Datos de la solicitud inválidos. |
11-9L | 422 | No se pudo procesar la operación, intenta de nuevo. |
11-36L | 422 | El monto supera el límite permitido para esta cuenta. |
10-454 | 422 | La transacción ya caducó. |
2-CCSB000005 | 422 | ¡Ups! Esta cuenta Nequi no puede recibir plata en este momento. |
Códigos de negocio de la API — conservan su status real:
| Valor | HTTP |
|---|---|
IDENTIFICATION_MISMATCH | 422 |
QR_REFERENCE_CONFLICT | 409 |
IDEMPOTENCY_KEY_REQUIRED | 400 |
IDEMPOTENCY_KEY_INVALID | 400 |
IDEMPOTENCY_KEY_REUSED | 422 |
IDEMPOTENCY_IN_FLIGHT | 409 |
IDEMPOTENCY_CONFLICT | 409 |
Códigos del envelope: VALIDATION_ERROR, UNAUTHORIZED, INSUFFICIENT_FUNDS, FORBIDDEN, NOT_FOUND, CONFLICT, PROVIDER_REJECTED, RATE_LIMITED, INTERNAL_ERROR, NOT_IMPLEMENTED, PROVIDER_ERROR, SERVICE_UNAVAILABLE. Ver Códigos HTTP.
Status HTTP a secas: 400, 401, 402, 403, 404, 409, 422, 429, 500, 501, 502, 503.
Ejemplos
Sección titulada «Ejemplos»Simular “el celular no tiene Nequi”:
POST /v1/co/payin/push{ "phoneNumber": "3001234567", "amount": 10000, "simulate": "11-37L" }{ "success": false, "error": { "code": "11-37L", "message": "El número de celular no tiene una cuenta Nequi.", "details": { "provider": "NEQUI", "simulated": true } }, "requestId": "abc123-def456"}Status: 422
Simular proveedor caído:
POST /v1/co/breb/payout{ "amount": 50000, "keyType": "ALPHA", "keyValue": "@ejemplo", "identificationNumber": "1020304050", "simulate": "502" }{ "success": false, "error": { "code": "PROVIDER_ERROR", "message": "Servicio de pagos no disponible, intenta de nuevo.", "details": { "simulated": true } }, "requestId": "abc123-def456"}Status: 502
Simular saldo insuficiente — trae los mismos campos que el 402 real:
POST /v1/co/payout/disburse{ "phoneNumber": "3001234567", "amount": 50000, "simulate": "INSUFFICIENT_FUNDS" }{ "success": false, "error": { "code": "INSUFFICIENT_FUNDS", "message": "Saldo insuficiente para la operación.", "details": { "availableBalance": 0, "requiredAmount": 1000, "currency": "COP", "simulated": true } }, "requestId": "abc123-def456"}Status: 402
Simular un error en una consulta de estado (sin cuerpo, con cabecera):
GET /v1/co/payin/qr/{txId}X-Simulate-Error: 429Status: 429, código RATE_LIMITED.
Valor no reconocido
Sección titulada «Valor no reconocido»Un valor mal escrito no se ignora: devuelve 400 con la lista completa de valores válidos. Ignorarlo en silencio daría un 201 y creerías que tu manejo de errores funciona.
{ "success": false, "error": { "code": "VALIDATION_ERROR", "message": "simulate: valor no reconocido \"NO_EXISTE\"", "details": { "supported": ["20-08A", "11-37L", "…", "502", "503"] } }, "requestId": "abc123-def456"}Forzar el desenlace (sandboxSettle)
Sección titulada «Forzar el desenlace (sandboxSettle)»Campo opcional del cuerpo de creación de un cobro. Pides el desenlace que quieres y se aplica en la misma llamada, con el webhook real. Sustituye al comportamiento anterior, donde todo cobro en sandbox terminaba COMPLETED.
Dónde se acepta
Sección titulada «Dónde se acepta»Los 4 POST de creación de pay-in CO:
POST /v1/co/payin/push— Nequi pushPOST /v1/co/payin/qr— Nequi QRPOST /v1/co/breb/payin/qr— Bre-B QRPOST /v1/co/breb/payin/key— Bre-B por llave
Los pay-out no lo aceptan. Tampoco validate-key de Bre-B, las cancelaciones ni los GET.
Valores
Sección titulada «Valores»| Valor | Estado final | Webhook | Ledger |
|---|---|---|---|
ausente / "auto" / "completed" | COMPLETED | payin.completed | acredita amount − feeClient |
"failed" | FAILED | payin.failed | no mueve saldo |
"expired" | EXPIRED en BREB_QR/BREB_KEY, FAILED en Nequi | payin.expired | no mueve saldo |
El valor es case-insensitive y se le hace trim. Cualquier otro valor devuelve 400 y el cobro no se crea:
{ "success": false, "error": { "code": "VALIDATION_ERROR", "message": "sandboxSettle: valor no reconocido \"nunca\" (admitidos: \"auto\", \"completed\", \"failed\", \"expired\")" }}Ejemplo
Sección titulada «Ejemplo»POST /v1/co/breb/payin/qr{ "amount": 50000, "sandboxSettle": "expired" }Cuándo actúa
Sección titulada «Cuándo actúa»Depende del entorno:
- Producción → el campo se ignora por completo, ni siquiera se valida. Puedes dejarlo en código compartido entre entornos: no cambia nada ni provoca errores cuando cambias la base URL.
- Sandbox → se aplica. El cobro se crea normalmente, y el desenlace que pediste se ejecuta de inmediato: se cierra la transacción en el estado correspondiente, se mueve (o no) el ledger y sale el webhook. Sin
sandboxSettle, sandbox sigue haciendo lo de siempre: cerrar enCOMPLETED.
La validación corre después de las validaciones de negocio: si mandas un amount inválido y un sandboxSettle inválido, ves primero el error del amount.
La respuesta de creación sigue diciendo PENDING
Sección titulada «La respuesta de creación sigue diciendo PENDING»{ "txId": "a1b2…", "status": "PENDING", "amount": 50000 }El desenlace se aplica justo después de crear el cobro, así que el 201 no lo refleja. El estado real llega por webhook o con el GET de estado. No es nuevo: sandbox ya respondía PENDING sobre cobros que quedaban COMPLETED.
Payloads de webhook
Sección titulada «Payloads de webhook»payin.completed / payin.failed (evento en el header X-Webhook-Event):
{ "txId": "…", "type": "PAYIN", "status": "FAILED", "amount": 50000, "currency": "COP", "feeCharged": 0, "netAmount": 0, "sandbox": true}En completed, feeCharged y netAmount traen los valores reales.
payin.expired:
{ "txId": "…", "type": "PAYIN", "status": "EXPIRED", "amount": 50000, "currency": "COP", "failureReason": "Vencimiento simulado en sandbox", "expiredAt": "2026-08-31T14:02:11.000Z"}Ojo: este payload no lleva "sandbox": true — es literalmente el mismo que emite el cron de producción. El failureReason es lo único que delata que fue pedido.
Todos van firmados con la Clave HMAC del proveedor si está configurada, igual que en producción.
Relación con simulate
Sección titulada «Relación con simulate»Cosas distintas, se pueden usar por separado:
simulate(o el headerX-Simulate-Error): catálogo de errores de creación —422de Nequi,502del proveedor,409de idempotencia. Se resuelve antes de enrutar y devuelve el error sin crear nada.sandboxSettle: cómo termina un cobro que sí se creó.
Restricciones
Sección titulada «Restricciones»| Situación | Comportamiento |
|---|---|
| Tu cuenta está suspendida | 503 — el servicio está suspendido para tu cuenta |
| Tu cuenta pasa a producción | Las transacciones se procesan con dinero real; el modo de prueba se desactiva automáticamente |