Esta guía es para el equipo técnico de una fintech (como Nova) o de una caja (como Caja Horizonte) que opera por API la billetera de sus afiliados, con las mismas reglas que la app: saldo y movimientos, transferencias P2P a otro afiliado, Bre-B a otros bancos, pago de facturas de servicios, recargas de celular, sub-cuentas (bolsillos, ahorros, metas), tarjetas y cambio de divisas. Todo vive bajo la etiqueta wallet del contrato y, en los SDK, bajo client.wallet.

Antes de seguir, ten a mano tu llave de sandbox y repasa Idempotencia y Montos: aquí casi todo mueve dinero.

Antes de empezar #

El titular se nombra por documento #

No hay identificador de afiliado en las rutas: la llave ya identifica a tu entidad y el titular se nombra siempre por document (su número de documento). En los GET va como query (?document=1000000001) y en los POST dentro del cuerpo JSON. Un documento que no existe en tu entidad responde 404.

Scopes #

ScopeQué permite
wallet:readSaldo, movimientos y su detalle, límites Bre-B, facturadores, historial de pagos, operadores de recarga, cuentas, tarjetas y sus movimientos
wallet:transferP2P (resolver y enviar) y Bre-B (resolver y enviar)
wallet:paymentsConsultar y pagar facturas de servicios
wallet:topupRecargar un celular
wallet:accountsCrear bolsillos/ahorros/metas y transferir entre cuentas propias
wallet:cardsEmitir tarjetas y cambiar su estado (congelar, descongelar, bloquear)
wallet:fxCotizar y ejecutar cambio de divisas

Pide solo los que tu integración necesita. wallet:transfer, wallet:payments y wallet:topup mueven dinero y están en la lista de scopes que exigirán firma HMAC de la petición cuando se active esa protección en tu llave (hoy inactiva; se avisará antes). Si tu entidad no tiene contratado el módulo, toda ruta responde 403 con «El módulo de billetera no está contratado para esta entidad.».

Montos e idempotencia #

  • Los montos viajan como texto decimal ("30000", "65000.00"), en pesos colombianos. Los SDK aceptan números o texto y los convierten; ver Montos.
  • P2P, Bre-B, pagar factura y recarga exigen Idempotency-Key: sin ella el gateway responde 400. Los SDK siempre envían una (generan un UUID si no pasas la tuya), pero si tu sistema puede reenviar la operación, pásale una estable por operación de negocio (por ejemplo nova-p2p-2026-11-02-000123). Repetir la misma llave devuelve el resultado original con idempotent_replay: true y no mueve dinero dos veces.
  • Las lecturas por POST (p2p.resolve, breb.resolve, bills.inquire, fx.quote) llevan el dato personal en el cuerpo para que no quede en URLs ni logs; no mueven dinero, no llevan llave y los SDK nunca las reintentan.

client.wallet.accounts es client.accounts

client.wallet.accounts, client.wallet.cards y client.wallet.fx son los mismos objetos que client.accounts, client.cards y client.fx (en .NET client.Wallet.Accounts y client.Accounts; en Go client.Wallet.Accounts y client.Accounts). Usa el camino que prefieras: llaman exactamente a las mismas operaciones.

Mapa de operaciones #

OperaciónRutaScopeIdempotency-Key
SaldoGET/v1/gateway/wallet/balance/wallet:read—
MovimientosGET/v1/gateway/wallet/transactions/wallet:read—
Detalle de movimientoGET/v1/gateway/wallet/transactions/{tx_id}/wallet:read—
Resolver destinatario P2PPOST/v1/gateway/wallet/p2p/resolve/wallet:transfer—
Enviar P2PPOST/v1/gateway/wallet/p2p/transfers/wallet:transferobligatoria
Resolver llave Bre-BPOST/v1/gateway/wallet/breb/resolve/wallet:transfer—
Enviar Bre-BPOST/v1/gateway/wallet/breb/transfers/wallet:transferobligatoria
Límites Bre-BGET/v1/gateway/wallet/breb/limits/wallet:read—
FacturadoresGET/v1/gateway/wallet/billers/wallet:read—
Consultar facturaPOST/v1/gateway/wallet/bills/inquiry/wallet:payments—
Pagar facturaPOST/v1/gateway/wallet/bills/pay/wallet:paymentsobligatoria
Historial de pagosGET/v1/gateway/wallet/bills/payments/wallet:read—
Detalle de pagoGET/v1/gateway/wallet/bills/payments/{payment_id}/wallet:read—
Operadores de recargaGET/v1/gateway/wallet/topups/operators/wallet:read—
RecargarPOST/v1/gateway/wallet/topups/wallet:topupobligatoria
CuentasGET/v1/gateway/wallet/accounts/wallet:read—
Crear bolsilloPOST/v1/gateway/wallet/accounts/wallet:accountsrecomendada
Transferir entre cuentasPOST/v1/gateway/wallet/accounts/transfer/wallet:accountsrecomendada
TarjetasGET/v1/gateway/wallet/cards/wallet:read—
Emitir tarjetaPOST/v1/gateway/wallet/cards/wallet:cardsrecomendada
Cambiar estado de tarjetaPOST/v1/gateway/wallet/cards/{card_id}/status/wallet:cardsrecomendada
Movimientos de tarjetaGET/v1/gateway/wallet/cards/{card_id}/transactions/wallet:read—
Cotizar divisasPOST/v1/gateway/wallet/fx/quote/wallet:fx—
Convertir divisasPOST/v1/gateway/wallet/fx/convert/wallet:fxrecomendada

Saldo #

wallet.balance devuelve un Balance tipado. available es lo que el titular puede gastar (el available_cash del motor, neto de retenciones); total incluye lo retenido y lo pendiente.

import os
from compense_gateway import CompenseClient

client = CompenseClient(api_key=os.environ["COMPENSE_API_KEY"], environment="sandbox")
DOC = "1000000001"   # titular de prueba: perfil elegible, saldo 500.000

saldo = client.wallet.balance(DOC)
print(saldo.available.amount, saldo.total.amount, saldo.currency)
if saldo.pending is not None:
    print("En tránsito:", saldo.pending.amount)
JSON
{"total": 500000.0, "available_cash": 500000.0, "restricted_amount": 0.0,
 "pending_amount": 0.0, "currency": "COP", "environment": "sandbox"}
Campo del contratoEn el SDKQué es
totaltotalSaldo total del titular
available_cashavailableDisponible para gastar (neto de retenciones)
restricted_amountrestrictedRetenido (opcional; null si el motor no lo envía)
pending_amountpendingEn tránsito (opcional)
currencycurrencyMoneda de la entidad (COP en Colombia)

Los demás ejemplos de esta página reutilizan client y el documento 1000000001; en Go asumen ctx, client y doc del bloque anterior.

Movimientos #

GET/v1/gateway/wallet/transactions/ pagina con limit/offset y devuelve un OffsetPage: results, limit, offset, has_more y next_offset (detalles en Paginación). Filtros: start_date y end_date (AAAA-MM-DD o ISO 8601; una fecha sola en end_date incluye todo ese día), category (TRANSFER, PAYMENT…), type (tipo del motor: TRANSFER_OUT, TOPUP…) y limit de 1 a 100 (50 por defecto). Los movimientos llegan del más reciente al más antiguo.

pagina = client.wallet.transactions.list(DOC, start_date="2026-10-01",
                                         end_date="2026-10-31", limit=20)
while True:
    for mov in pagina:
        print(mov["date"], mov["transaction_type"], mov["amount"], mov["status"])
    if not pagina.has_more:
        break
    pagina = client.wallet.transactions.list(DOC, start_date="2026-10-01",
                                             end_date="2026-10-31", limit=20,
                                             offset=pagina.next_offset)

detalle = client.wallet.transactions.get(mov["id"], document=DOC)
JSON
{"results": [{"id": "wtx-E-…", "type": "DEBIT", "category": "PAYMENT", "amount": 10000.0,
              "currency": "COP", "date": "2026-10-10T09:15:00+00:00",
              "description": "Recarga CLARO - ******4567", "status": "COMPLETED",
              "transaction_type": "TOPUP"}],
 "limit": 20, "offset": 0, "has_more": false, "next_offset": null}

El detalle solo muestra movimientos del titular indicado: el id de un movimiento de otro afiliado responde 404, aunque exista.

Transferencia P2P a otro afiliado #

Una transferencia P2P mueve dinero entre dos afiliados de tu misma entidad. Son dos pasos, como en la app: resolver (saber a quién le vas a pagar) y enviar.

La entidad nunca envía un id de destinatario suelto. resolve busca el alias dentro del motor y devuelve el nombre enmascarado y un resolution_token firmado, de vida corta (300 s), atado a tu entidad, a la billetera que paga y a la que recibe. El envío solo acepta ese token: no se puede confirmar a una persona y pagarle a otra, ni reutilizar el token con otro titular.

sequenceDiagram
    autonumber
    participant U as Usuario (app de Nova)
    participant N as Backend de Nova
    participant G as Gateway Compense
    U->>N: Enviar $30.000 al 300 123 4567
    N->>G: POST wallet/p2p/resolve/ {document, alias}
    G-->>N: found, display_name "Sa*** Re***", resolution_token (300 s)
    N->>U: ¿Confirmas el envío a Sa*** Re***?
    U->>N: Sí
    N->>G: POST wallet/p2p/transfers/ + Idempotency-Key
    G-->>N: 201 {transaction_id, data.status: COMPLETED}
    G-)N: webhook wallets.transfer.created
r = client.wallet.p2p.resolve(document=DOC, alias="3001234567", key_type="PHONE")
if not r.found:
    raise SystemExit("Nadie tiene ese alias en la entidad")
if r.is_local is False:
    raise SystemExit("La llave es de otra entidad: usa Bre-B")

# Muestra r.display_name al usuario y pide confirmación antes de r.expires_in segundos
tx = client.wallet.p2p.transfer(
    document=DOC,
    resolution_token=r.resolution_token,
    amount="30000",
    description="Arriendo",
    idempotency_key="nova-p2p-2026-11-02-000123",
)
print(tx.transaction_id, tx.data["transaction_id"], tx.data["status"], tx.idempotent_replay)

Respuesta de resolve y de transfer:

JSON
{"found": true, "is_local": true, "display_name": "Sa*** Re***",
 "resolution_token": "p2p-E-…", "expires_in": 300}
JSON
{"status": "success", "transaction_id": "gw-…", "module": "wallet",
 "data": {"transaction_id": "wtx-E-…", "status": "COMPLETED", "amount": 30000.0,
          "currency": "COP", "display_name": "Sa*** Re***"}}
  • alias acepta celular, correo, documento o @alias; key_type (PHONE, EMAIL, DOCUMENT, ALIAS) es opcional y desambigua una cadena de dígitos.
  • found: false: nadie tiene ese alias. is_local: false: la llave es de otra entidad, usa Bre-B.
  • tx.transaction_id es el id del gateway (para Transacciones y soporte); tx.data["transaction_id"] es el movimiento en la billetera (sirve en wallet.transactions.get).
  • Si el motor de riesgo retiene la transferencia, la respuesta trae review_status: EN_REVISION.

Bre-B a otro banco #

Bre-B son pagos inmediatos 24/7 a cuentas de otros bancos usando una llave (celular, documento, correo o alias). El flujo es el mismo de P2P: resolver la llave y enviar con el token. Tu entidad debe tener Bre-B habilitado; si no, el envío responde 422 con «Esta entidad aún no tiene habilitadas las transferencias Bre-B.».

limites = client.wallet.breb.limits(DOC)
print(limites["max_per_transaction"], limites["available_today"])

r = client.wallet.breb.resolve(document=DOC, key_type="PHONE", value="3109876543")
if r.found:
    # Muestra al usuario r.display_name, r.institution_name y r.key_display
    tx = client.wallet.breb.transfer(
        document=DOC, resolution_token=r.resolution_token, amount="25000",
        description="Pago", idempotency_key="nova-breb-2026-11-02-000045",
    )
    if tx.data["status"] == "PENDING":
        print("El banco destino aún no confirma: espera wallets.transfer.settled")
JSON
{"found": true, "resolution_token": "brb-E-…", "expires_in": 300, "key_type": "PHONE",
 "key_display": "******6543", "display_name": "Ca*** Ro***",
 "institution_name": "Banco Sandbox", "participant_code": "SBXBANK", "is_local": false}
  • key_type: PHONE, DOCUMENT, EMAIL o ALIAS.
  • Si la llave resulta ser de un afiliado de tu misma entidad (is_local: true), el envío se hace como transferencia interna con el mismo token.
  • data.status del envío: COMPLETED (liquidado) o PENDING (el banco destino aún no confirma; llega por evento o consulta). Un rechazo del banco destino es 422 con el motivo y no descuenta dinero.

Los límites dependen del nivel del titular:

CampoQué es
tierNivel del titular (TIER_1…)
max_per_transactionTope por envío
max_daily_amount / max_monthly_amountTopes por día y por mes
used_today / used_monthLo ya usado
available_todayLo que aún puede enviar hoy

Consulta los límites antes de ofrecer el envío: un monto por encima responde 422.

Facturas de servicios #

El pago de facturas tiene tres momentos: elegir el facturador (convenio), consultar la factura para conocer el valor exacto y pagarla. Para un flujo completo, con confirmación del usuario, reintentos y comprobante, sigue la receta Cobrar una factura desde la billetera.

convenios = client.wallet.bills.billers(category="POWER", q="epm")   # no necesita document

factura = client.wallet.bills.inquire(document=DOC, biller_code="EPM", reference="123456789")
print(factura["holder_name"], factura["amount"], factura["due_date"], factura["status"])

tx = client.wallet.bills.pay(
    document=DOC, biller_code="EPM", reference="123456789",
    amount=factura["amount"],                       # el valor de la consulta
    idempotency_key="nova-bill-EPM-123456789-2026-10",
)
print(tx.data["status"], tx.data.get("approval_number"))

historial = client.wallet.bills.list_payments(DOC, kind="BILL", limit=20)
pago = client.wallet.bills.get_payment(historial.results[0]["id"], document=DOC)

Consulta de la factura (en el sandbox, EPM / 123456789 vale 90.000):

JSON
{"biller": {"code": "EPM", "name": "EPM Energía", "category": "POWER"}, "reference": "123456789",
 "holder_name": "Sa*** Ti***", "amount": "90000.00", "currency": "COP", "due_date": "2026-11-15",
 "status": "PENDING", "allows_partial": false, "min_amount": "90000.00", "max_amount": "90000.00",
 "period": "2026-10"}
  • Categorías de facturador: UTILITIES, TELCO, TV, WATER, POWER, GAS, TAX, INTERNET, EDUCATION, OTHER.
  • En lugar de biller_code + reference puedes consultar con barcode (código GS1 de la factura).
  • El monto a pagar debe ser el de la consulta, salvo convenios con pago parcial (allows_partial: true), que aceptan cualquier valor entre min_amount y max_amount.
  • data.status del pago: APPROVED (HTTP 201, con approval_number) o PENDING (HTTP 202: el recaudador aún no confirma). Un pago PENDING se concilia con el recaudador cada vez que consultas su detalle con bills.get_payment.
  • kind del historial: BILL (facturas) o TOPUP (recargas).

Recargas de celular #

GET/v1/gateway/wallet/topups/operators/ devuelve exactamente lo que la recarga acepta: operadores, rango de montos, montos sugeridos y paquetes. source: catalog indica que tu entidad tiene catálogo del agregador; default, los operadores del país con el rango por defecto.

catalogo = client.wallet.topups.operators()
claro = next(o for o in catalogo["operators"] if o["code"] == "CLARO")
print(claro["min_amount"], claro["max_amount"], claro["suggested_amounts"])

tx = client.wallet.topups.create(
    document=DOC, phone_number="3001234567", carrier="CLARO", amount="20000",
    idempotency_key="nova-topup-2026-11-02-000123",
)
print(tx.data["status"], tx.data["phone_number_masked"])

Validaciones: celular colombiano de 10 dígitos que empieza por 3 (INVALID_PHONE), operador habilitado (OPERATOR_NOT_FOUND), monto dentro del rango del operador (INVALID_AMOUNT), saldo y límites del titular.

Cuentas y bolsillos #

Cada titular tiene su billetera principal y puede tener sub-cuentas: bolsillos (pocket), ahorros (savings) o metas (goal). Crear sub-cuentas y mover dinero entre ellas pide el scope wallet:accounts; listarlas, wallet:read.

cuentas = client.wallet.accounts.list(DOC)          # == client.accounts.list(DOC)
principal = next(c for c in cuentas if c.is_primary or c.kind.lower() == "wallet")

ahorro = client.wallet.accounts.create_pocket(
    document=DOC, name="Vacaciones", kind="savings",
    target_amount="500000", target_date="2026-12-31",
    idempotency_key="nova-pocket-1000000001-vacaciones",
)

envelope = client.wallet.accounts.transfer(
    document=DOC, from_account=principal.id, to_account=ahorro.id, amount="20000",
    idempotency_key="nova-acc-tx-2026-11-02-0001",
)
print(envelope["transaction_id"], envelope["data"])
  • kind va en minúsculas (pocket, savings, goal); los SDK lo normalizan.
  • target_amount y target_date (meta de ahorro) los envían los SDK cuando los pasas; el contrato los admite como campos adicionales.
  • accounts.transfer devuelve el sobre del gateway tal cual (un diccionario/objeto), no un Transaction tipado.

Tarjetas #

Las tarjetas son débito, virtuales o físicas. El gateway solo expone metadatos no sensibles (last_four, marca, estado): el PAN y el CVV nunca salen del procesador.

stateDiagram-v2
    [*] --> ACTIVE: issue
    ACTIVE --> FROZEN: set_status(freeze)
    FROZEN --> ACTIVE: set_status(unfreeze)
    ACTIVE --> BLOCKED: set_status(block)
    FROZEN --> BLOCKED: set_status(block)
    BLOCKED --> [*]

block es terminal: una tarjeta bloqueada no se reactiva; emite una nueva.

tarjeta = client.wallet.cards.issue(document=DOC, form_factor="virtual",
                                    idempotency_key="nova-card-1000000001-v1")
print(tarjeta.id, tarjeta.last_four, tarjeta.status)

client.wallet.cards.set_status(tarjeta.id, document=DOC, status="freeze")
client.wallet.cards.set_status(tarjeta.id, document=DOC, status="unfreeze")

tarjetas = client.wallet.cards.list(DOC)
movs = client.wallet.cards.transactions(tarjeta.id, document=DOC)

form_factor es virtual (por defecto) o physical; status es freeze, unfreeze o block. Los SDK los envían en minúsculas aunque los escribas en mayúsculas. Al emitirse una tarjeta se emite el evento wallets.card.issued.

Cambio de divisas (FX) #

Primero cotizas (lectura pura: no mueve dinero, no se factura) y luego ejecutas la conversión pasando el quote_id de la cotización. La tasa es indicativa: el motor la vuelve a resolver al ejecutar.

cotizacion = client.wallet.fx.quote(document=DOC, sell_currency="COP",
                                    buy_currency="USD", amount="1000000")
print(cotizacion["rate"], cotizacion["converted_amount"])

envelope = client.wallet.fx.convert(
    document=DOC, sell_currency="COP", buy_currency="USD", amount="1000000",
    quote_id=cotizacion.get("quote_id"),
    idempotency_key="nova-fx-2026-11-02-0007",
)

Los nombres del contrato son sell_currency y buy_currency (los SDK los envían en mayúsculas). fx.convert devuelve el sobre del gateway tal cual. Un par de monedas sin tasa publicada responde 422.

Errores de negocio #

Los errores llegan en application/problem+json (ver Errores). En un rechazo del motor, detail trae el motivo que dio el motor —su mensaje en español, por ejemplo «Esta factura ya está pagada.»— y, si el motor no envía mensaje, el código: The request was rejected by the processing engine (code: BILL_ALREADY_PAID). La columna «Código» es el código del motor que origina cada caso: úsalo para reconocerlo en logs y al hablar con soporte (cita siempre el trace_id).

HTTPCódigo del motorCuándoQué hacer
400—Falta Idempotency-Key en P2P, Bre-B, factura o recargaEnvía la cabecera
400RESOLUTION_REQUIRED / RESOLUTION_INVALIDEnvío sin token, o con un token de otro flujo (Bre-B en P2P o al revés)Resuelve de nuevo con la operación correcta
400INVALID_PHONE / INVALID_AMOUNTCelular o monto de recarga fuera de reglaCorrige el dato
404MEMBER_NOT_FOUNDEl document no es un afiliado activo de tu entidadSincroniza el afiliado
404BILLER_NOT_FOUND / OPERATOR_NOT_FOUNDConvenio u operador no habilitadoLista de nuevo el catálogo
409BILL_ALREADY_PAIDLa factura (o ese periodo) ya está pagadaMuestra el pago existente; no reintentes
409—La misma Idempotency-Key sigue en cursoEspera y reintenta con la misma llave
422INSUFFICIENT_FUNDSSaldo disponible insuficientePide recargar o un monto menor
422RESOLUTION_EXPIREDPasaron más de 300 s desde resolveResuelve otra vez y vuelve a confirmar
422SELF_TRANSFEREl destinatario es el mismo titular—
422AMOUNT_MISMATCHPagas un valor distinto al de la facturaUsa el amount de la consulta
422TRANSFER_REJECTED / PAYMENT_REJECTEDEl banco destino o el recaudador rechazó; no se descontó dineroInforma al usuario
422—Límite Bre-B superado, Bre-B no habilitadoConsulta breb.limits
502 / 504—El motor falló o no respondióReintenta con la misma Idempotency-Key

Un 409 no siempre es un conflicto de idempotencia. Los SDK levantan la misma excepción para ambos; distínguelos por el type del problema: …/idempotency-conflict cuando es tu llave en curso y …/upstream-error cuando el motor rechazó por una regla de negocio.

from compense_gateway import CompenseAPIError, IdempotencyConflictError, NotFoundError, ValidationError

try:
    tx = client.wallet.p2p.transfer(document=DOC, resolution_token=r.resolution_token,
                                    amount="30000", idempotency_key="nova-p2p-2026-11-02-000123")
except ValidationError as e:                 # 422: saldo, límites, token vencido…
    print("Rechazada:", e.detail, e.trace_id)
except IdempotencyConflictError as e:        # 409
    if e.problem.type.endswith("/idempotency-conflict"):
        print("La misma llave sigue en curso: reintenta luego con la MISMA llave")
    else:
        print("Conflicto de negocio:", e.detail)
except NotFoundError as e:                   # 404: titular inexistente
    print("No existe:", e.trace_id)
except CompenseAPIError as e:                # 400 y demás
    print(e.status, e.detail, e.trace_id)

Eventos #

Los eventos de billetera se emiten en la misma transacción que el movimiento de dinero: si el dinero no se movió, no hay evento de éxito, y un reintento con la misma Idempotency-Key no lo duplica. Suscríbelos como se explica en Suscripción y verifica la firma según Verificación.

Billetera #

EventoDesdeCuándo se emite
wallets.cashin.initiated2026-07-21Se registró una intención de recarga; aún no se mueve dinero.
wallets.cashin.confirmed2026-07-21El origen confirmó la recarga y se emitió dinero electrónico a la billetera.
wallets.transfer.created2026-07-21Se creó una transferencia (P2P, Bre-B o entre bolsillos) y quedó asentada en el libro mayor.
wallets.transfer.settled2026-07-21La transferencia quedó liquidada.
wallets.transfer.failed2026-07-21El riel rechazó la transferencia; no se movió dinero.
wallets.disbursement.credited2026-07-21Se abonó un subsidio o crédito directamente a la billetera.
wallets.withdrawal.authorized2026-07-21Se autorizó un retiro en cajero (token de un solo uso y fondos retenidos).
wallets.bill_payment.completed2026-07-21El titular pagó una factura de servicios y el facturador la aceptó.
wallets.qr_payment.completed2026-07-21El titular pagó un QR interoperable (EMVCo).
wallets.transaction.reversed2026-07-21Se reversó una transacción (reembolso).
wallets.dispute.opened2026-07-21El titular abrió una disputa sobre una transacción.
wallets.dispute.resolved2026-07-21Un operador resolvió la disputa (a favor, con reverso, o rechazada).
wallets.card.issued2026-07-21Se emitió una tarjeta (solo viajan los últimos 4 dígitos).
wallets.topup.completed2026-10-27Se asentó una recarga de celular (carrier, teléfono enmascarado, amount).

Campos de resource de los eventos que dispara esta guía:

EventoCampos de resource
wallets.transfer.createdtransaction_id, source_wallet_id, recipient_wallet_id (null si es a otro banco), amount, currency, rail_reference, is_internal, status; Bre-B añade rail: "breb"
wallets.transfer.failedtransaction_id, source_wallet_id, amount, currency, rail_reference, reason, status
wallets.transfer.settledLa transferencia en curso quedó liquidada (ver el catálogo)
wallets.bill_payment.completedtransaction_id, wallet_id, service_id, amount, currency, provider, provider_reference, status
wallets.topup.completedtransaction_id, wallet_id, carrier, phone_number_masked, amount, currency, status
JSON
{"event": "wallets.topup.completed", "version": "1", "tenant_id": "nova",
 "occurred_at": "2026-11-02T15:00:00Z",
 "resource": {"transaction_id": "wtx-E-…", "wallet_id": "…", "carrier": "CLARO",
              "phone_number_masked": "******4567", "amount": "20000.00", "currency": "COP",
              "status": "completed"}}

Concilia por resource.transaction_id, que es el mismo tx.data["transaction_id"] que te devolvió la escritura.

Sandbox #

Con una llave de sandbox nada llega al motor real: el gateway responde datos deterministas (misma petición, misma respuesta) con "environment": "sandbox". La tabla completa está en Datos de prueba.

Documento del titularPerfilComportamiento en billetera
1000000001ElegibleSaldo 500.000; todo funciona
1000000002InelegibleSaldo 0 → INSUFFICIENT_FUNDS en cualquier envío; no puede emitir tarjeta
1000000004Con deudasSaldo 100 → INSUFFICIENT_FUNDS
9999999999No existe404 MEMBER_NOT_FOUND
cualquier otroElegibleComo 1000000001

Casos de prueba:

  • Alias o llave 9999999999 → {"found": false}.
  • P2P con alias igual al document del pagador → 422 SELF_TRANSFER.
  • Envío con un token de otro flujo (Bre-B en P2P o al revés) → 400 RESOLUTION_INVALID.
  • Bre-B por 11111 → 422 TRANSFER_REJECTED (rechazo del banco destino).
  • Factura con referencia terminada en 0 → 409 BILL_ALREADY_PAID; convenio desconocido → 404; pagar un valor distinto al de la consulta → 422 AMOUNT_MISMATCH. El valor de cada factura es fijo por convenio y referencia (entre 50.000 y 95.000): consúltalo primero.
  • Convenios simulados: EPM, ACUEDUCTO, VANTI, ETB. Operadores: CLARO, MOVISTAR, TIGO, WOM (1.000–200.000).
  • Movimientos: tres fijos por titular (1, 5 y 10 de octubre de 2026), paginables y filtrables por fecha; su id sirve en el detalle.

Montos mágicos: liquidar, fallar o devolver #

Una transferencia real puede quedar PENDING y terminar días después en liquidada, fallida o devuelta. Para probar esos desenlaces sin esperar, el simulador de transferencias avanza un estado por llamada (PENDING → PROCESSING → …) y el monto decide el final:

MontoDesenlace
22.22FAILED (no se liquida; reason_code: TRANSFER_FAILED)
33.33RETURNED (se liquida y luego se devuelve; reason_code: TRANSFER_RETURNED)
11.11 o cualquier otroSETTLED
estado = {"status": "PENDING", "amount": "22.22", "transaction_id": "wtx-E-3f1c9a0b2d4e"}
while True:
    paso = client.sandbox.simulate.wallet_transfer(**estado)
    print(paso["previous_status"], "→", paso["status"])
    if paso["terminal"]:
        break
    estado["status"] = paso["status"]
# PENDING → PROCESSING, PROCESSING → FAILED

El simulador es una transformación pura: no mueve saldo ni emite webhooks. Úsalo para recorrer en tus pruebas cada rama de tu conciliación (wallets.transfer.settled y wallets.transfer.failed).

Cuentas, tarjetas y FX en el sandbox

Las respuestas simuladas de cuentas/bolsillos, tarjetas y FX vienen de la superficie anterior de billetera: los montos se simulan en DOP y su forma todavía no coincide del todo con el contrato (el listado de cuentas y de tarjetas llega en results, el cambio de estado de tarjeta espera ACTIVE/FROZEN/BLOCKED y la cotización ignora sell_currency/buy_currency). Úsalas para probar el flujo y el manejo de errores, no para validar valores; saldo, movimientos, P2P, Bre-B, facturas y recargas sí siguen el contrato.

Lo que sigue #