En esta receta, la app de Nova permite enviar dinero a cualquier persona con su celular, correo, documento o alias. Si el destinatario también es usuario de Nova, el envío es una transferencia P2P interna e inmediata; si su llave está en otro banco, va por Bre-B. El backend de Nova decide el riel, muestra a quién se le va a pagar, envía dentro de la vida del token y concilia el resultado final por webhook. Las operaciones están descritas en Billetera.

El flujo #

flowchart TD
    A[Usuario escribe la llave y el monto] --> B[POST wallet/p2p/resolve/]
    B -->|found y is_local| C[Riel P2P]
    B -->|no encontrada o is_local = false| D[POST wallet/breb/resolve/]
    D -->|found| E[Riel Bre-B]
    D -->|found = false| F[No existe esa llave]
    C --> G[Mostrar nombre enmascarado y pedir confirmación]
    E --> H[Revisar límites Bre-B] --> G
    G -->|antes de 300 s| I[POST transfers/ + Idempotency-Key]
    G -->|token vencido| B
    I --> J{data.status}
    J -->|COMPLETED| K[Listo · wallets.transfer.created]
    J -->|PENDING| L[Esperar wallets.transfer.settled o .failed]

Requisitos: una llave con wallet:transfer (resolver y enviar) y wallet:read (límites), y un endpoint de webhooks suscrito a wallets.transfer.created, wallets.transfer.settled y wallets.transfer.failed. Los ejemplos usan el titular de prueba 1000000001; en Go asumen ctx y client creados como en el quickstart.

1. Resuelve la llave y elige el riel #

Resolver es una lectura por POST: lleva el dato personal en el cuerpo (no en la URL), no mueve dinero y no necesita Idempotency-Key. Primero intenta P2P, que es interno; si nadie de tu entidad tiene esa llave, o la llave vive en otra entidad (is_local: false), resuelve por Bre-B.

import os
import time
from compense_gateway import CompenseClient

client = CompenseClient(api_key=os.environ["COMPENSE_API_KEY"], environment="sandbox")
DOC = "1000000001"

def resolver(llave: str, tipo: str):
    """Devuelve (riel, resolución, vence_en) o (None, None, None)."""
    r = client.wallet.p2p.resolve(document=DOC, alias=llave, key_type=tipo)
    if r.found and r.is_local is not False:
        return "p2p", r, time.monotonic() + r.expires_in
    r = client.wallet.breb.resolve(document=DOC, key_type=tipo, value=llave)
    if r.found:
        return "breb", r, time.monotonic() + r.expires_in
    return None, None, None

riel, r, vence_en = resolver("3109876543", "PHONE")
if riel:
    destino = r.display_name if riel == "p2p" else f"{r.display_name} · {r.institution_name} · {r.key_display}"
    print(f"¿Enviar a {destino}?")

2. Muestra el nombre enmascarado y confirma #

La resolución devuelve el destinatario enmascarado (Sa*** Re***; en Bre-B también el banco y la llave parcial ******6543). Muéstralo tal cual y pide una confirmación explícita: es la defensa del usuario contra un dígito mal escrito. Nunca muestres ni guardes datos completos que no te devolvió la resolución.

El resolution_token vive 300 segundos (expires_in) y está atado a tu entidad, a la billetera que paga y a la que recibe. Guarda la hora de vencimiento: si el usuario confirma después, resuelve de nuevo y vuelve a pedir la confirmación (el nombre pudo cambiar) en vez de enviar y esperar el 422 RESOLUTION_EXPIRED. El token de P2P solo sirve en P2P y el de Bre-B solo en Bre-B: cruzarlos responde 400 RESOLUTION_INVALID.

3. Revisa los límites (Bre-B) #

Los envíos Bre-B tienen topes por transacción, por día y por mes según el nivel del titular. Valida antes de confirmar para no hacer esperar al usuario con un rechazo.

monto = 150000
if riel == "breb":
    lim = client.wallet.breb.limits(DOC)
    if monto > lim["max_per_transaction"] or monto > (lim["available_today"] or 0):
        raise SystemExit(f"Supera tu límite: hoy puedes enviar hasta {lim['available_today']}")

4. Transfiere antes de que venza el token #

P2P y Bre-B reciben el mismo cuerpo (document, resolution_token, amount, description) y exigen Idempotency-Key. Derívala del id del envío en tu base (nova-envio-<id>) y reutilízala en cada reintento por timeout o 5xx: el gateway devuelve el resultado original y nunca envía dos veces. Si el envío fue rechazado con 422 (sin saldo, token vencido, rechazo del banco), no se movió dinero: tras resolver y confirmar de nuevo, usa una llave nueva.

from compense_gateway import ValidationError

if time.monotonic() >= vence_en - 15:                 # margen para la latencia
    riel, r, vence_en = resolver("3109876543", "PHONE")  # y vuelve a confirmar con el usuario

recurso = client.wallet.p2p if riel == "p2p" else client.wallet.breb
try:
    tx = recurso.transfer(
        document=DOC, resolution_token=r.resolution_token, amount=str(monto),
        description="Pago del almuerzo", idempotency_key="nova-envio-ENV-2026-004512",
    )
except ValidationError as e:                           # 422: no se movió dinero
    raise SystemExit(f"No se pudo enviar: {e.detail}")

print(tx.data["status"], tx.data["transaction_id"], tx.data.get("end_to_end_id"))

Respuesta de un Bre-B en el sandbox:

JSON
{"status": "success", "transaction_id": "gw-…", "module": "wallet",
 "data": {"transaction_id": "wtx-E-…", "status": "COMPLETED", "amount": 150000.0, "currency": "COP",
          "end_to_end_id": "CMP…", "display_name": "Ca*** Ro***", "institution_name": "Banco Sandbox",
          "message": "Transferencia enviada a Ca*** Ro*** (Banco Sandbox)."}}

Errores que debes manejar en este paso:

HTTPCódigo del motorQué hacer
422INSUFFICIENT_FUNDSMuestra el saldo disponible; no se movió dinero
422RESOLUTION_EXPIREDResuelve y confirma de nuevo; nueva llave
422TRANSFER_REJECTEDEl banco destino rechazó; informa el motivo del detail
422SELF_TRANSFEREl destino es el mismo titular
422—Límite superado o Bre-B no habilitado en tu entidad
400RESOLUTION_INVALIDToken de otro riel o de otro titular: resuelve de nuevo
409 (…/idempotency-conflict), 502, 504, timeout—Reintenta con la misma llave

5. Concilia por webhooks #

P2P se liquida en el acto: data.status llega en COMPLETED. Bre-B puede llegar en COMPLETED o en PENDING si el banco destino aún no confirma; en ese caso el desenlace llega por evento. Muestra «En proceso» y no le pidas al usuario que reintente.

stateDiagram-v2
    [*] --> PENDING
    PENDING --> PROCESSING
    PROCESSING --> SETTLED: wallets.transfer.settled
    PROCESSING --> FAILED: wallets.transfer.failed
    SETTLED --> RETURNED: devolución posterior
    SETTLED --> [*]
    FAILED --> [*]
    RETURNED --> [*]
EventoQué significaQué hacer en Nova
wallets.transfer.createdEnvío asentado (P2P) o aceptado por el riel (Bre-B, con rail: "breb")Marca el envío como enviado
wallets.transfer.settledUna transferencia en curso quedó liquidadaMarca «Entregado» y notifica
wallets.transfer.failedEl riel rechazó; no se movió dinero (reason)Marca «Fallido» y muestra el motivo

Concilia por resource.transaction_id, que es el mismo data.transaction_id de la respuesta del envío. Deduplica por el id del evento: la entrega es al menos una vez.

Conciliar wallets.transfer.*
from compense_gateway.webhooks import verify_webhook, WebhookVerificationError

ESTADO = {"wallets.transfer.created": "ENVIADO", "wallets.transfer.settled": "ENTREGADO",
          "wallets.transfer.failed": "FALLIDO"}

def recibir(raw_body: bytes, headers) -> int:
    try:
        ev = verify_webhook(raw_body, headers, secret=os.environ["COMPENSE_WEBHOOK_SECRET"])
    except WebhookVerificationError:
        return 401
    if not ev.is_test and ev.event in ESTADO and not ya_procesado(ev.event_id):
        actualizar_envio(ev.resource["transaction_id"], ESTADO[ev.event], ev.resource.get("reason"))
        registrar_procesado(ev.event_id)
    return 200

Si un webhook no llega (tu endpoint estuvo caído), consulta el movimiento con wallet.transactions.get(transaction_id, document=…) o reenvía la entrega desde Reintentos de webhooks.

6. Prueba cada desenlace en el sandbox #

El sandbox es determinista. Con el titular 1000000001 (saldo 500.000):

Para probarHaz esto
Llave inexistenteAlias o llave 9999999999 → found: false en P2P y en Bre-B
Envío a sí mismoP2P con alias igual al documento del pagador → 422 SELF_TRANSFER
Token cruzadoEnvía por P2P un token de Bre-B (o al revés) → 400 RESOLUTION_INVALID
Rechazo del banco destinoBre-B por 11111 → 422 TRANSFER_REJECTED
Sin saldoMás de 500.000, o el titular 1000000002 (saldo 0) / 1000000004 (saldo 100) → 422 INSUFFICIENT_FUNDS
Reintento seguroRepite el envío con la misma llave → mismo transaction_id, idempotent_replay: true

Los envíos del sandbox responden COMPLETED al instante. Para ejercitar tu conciliación de transferencias en curso, usa el simulador: avanza un estado por llamada y el monto mágico decide el final: 22.22 termina en FAILED, 33.33 se liquida y luego se devuelve (RETURNED), y cualquier otro monto (por ejemplo 11.11) termina en SETTLED.

estado = "PENDING"
while True:
    paso = client.sandbox.simulate.wallet_transfer(status=estado, amount="33.33",
                                                   transaction_id="wtx-E-3f1c9a0b2d4e")
    print(paso["previous_status"], "→", paso["status"], paso.get("reason_code"))
    if paso["terminal"]:
        break
    estado = paso["status"]
# PENDING → PROCESSING → SETTLED → RETURNED (TRANSFER_RETURNED)

El simulador no mueve saldo ni emite webhooks; combínalo con los eventos de prueba del paso 5 para recorrer cada rama de tu receptor.

Lista de verificación #

  • Resuelves primero por P2P y caes a Bre-B cuando la llave no es local.
  • Muestras el nombre enmascarado (y en Bre-B el banco) y pides confirmación explícita.
  • Controlas el vencimiento del token (300 s) y vuelves a resolver antes de enviar si venció.
  • La Idempotency-Key sale del id de tu envío; reintentas timeouts y 5xx con la misma llave.
  • Tratas PENDING como «En proceso» y concilias por wallets.transfer.settled / .failed.
  • Probaste en sandbox SELF_TRANSFER, TRANSFER_REJECTED, INSUFFICIENT_FUNDS y los montos mágicos 22.22 y 33.33.