Ir al contenido

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.

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 devuelve
la respuesta con el mismo formato que producción
{ txId, qrCode, qrImage, estimatedFees, ... }
El desenlace se simula de inmediato: la transacción se cierra como
COMPLETED, se mueve el ledger y se dispara el webhook con "sandbox": true

La 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.

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á acreditado

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/200 y 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": true dentro de details, para distinguirlo de un fallo real en tus logs.

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: 502

Si envías las dos, manda la cabecera.

Códigos de negocio de Nequi — se devuelven como 422 con el mensaje real:

ValorHTTPMensaje
20-08A422El número de celular no tiene una cuenta Nequi.
11-37L422El número de celular no tiene una cuenta Nequi.
20-05A422Datos de la solicitud inválidos.
11-9L422No se pudo procesar la operación, intenta de nuevo.
11-36L422El monto supera el límite permitido para esta cuenta.
10-454422La transacción ya caducó.
2-CCSB000005422¡Ups! Esta cuenta Nequi no puede recibir plata en este momento.

Códigos de negocio de la API — conservan su status real:

ValorHTTP
IDENTIFICATION_MISMATCH422
QR_REFERENCE_CONFLICT409
IDEMPOTENCY_KEY_REQUIRED400
IDEMPOTENCY_KEY_INVALID400
IDEMPOTENCY_KEY_REUSED422
IDEMPOTENCY_IN_FLIGHT409
IDEMPOTENCY_CONFLICT409

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.

Simular “el celular no tiene Nequi”:

Ventana de terminal
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:

Ventana de terminal
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:

Ventana de terminal
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):

Ventana de terminal
GET /v1/co/payin/qr/{txId}
X-Simulate-Error: 429

Status: 429, código RATE_LIMITED.

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"
}

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.

Los 4 POST de creación de pay-in CO:

  • POST /v1/co/payin/push — Nequi push
  • POST /v1/co/payin/qr — Nequi QR
  • POST /v1/co/breb/payin/qr — Bre-B QR
  • POST /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.

ValorEstado finalWebhookLedger
ausente / "auto" / "completed"COMPLETEDpayin.completedacredita amount − feeClient
"failed"FAILEDpayin.failedno mueve saldo
"expired"EXPIRED en BREB_QR/BREB_KEY, FAILED en Nequipayin.expiredno 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\")"
}
}
Ventana de terminal
POST /v1/co/breb/payin/qr
{ "amount": 50000, "sandboxSettle": "expired" }

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 en COMPLETED.

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.

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.

Cosas distintas, se pueden usar por separado:

  • simulate (o el header X-Simulate-Error): catálogo de errores de creación — 422 de Nequi, 502 del proveedor, 409 de idempotencia. Se resuelve antes de enrutar y devuelve el error sin crear nada.
  • sandboxSettle: cómo termina un cobro que sí se creó.
SituaciónComportamiento
Tu cuenta está suspendida503 — el servicio está suspendido para tu cuenta
Tu cuenta pasa a producciónLas transacciones se procesan con dinero real; el modo de prueba se desactiva automáticamente