In this recipe, the Nova app lets users send money to anyone using their mobile number, email, ID number, or alias. If the recipient is also a Nova user, the transfer is an internal, instant P2P transfer; if their key is at another bank, it goes through Bre-B (Colombia’s instant payments). Nova’s backend picks the rail, shows who is going to be paid, sends within the token’s lifetime, and reconciles the final result by webhook. The operations are described in Wallet.

The flow #

flowchart TD
    A[User enters the key and the amount] --> B[POST wallet/p2p/resolve/]
    B -->|found and is_local| C[P2P rail]
    B -->|not found or is_local = false| D[POST wallet/breb/resolve/]
    D -->|found| E[Bre-B rail]
    D -->|found = false| F[That key doesn't exist]
    C --> G[Show masked name and ask for confirmation]
    E --> H[Check Bre-B limits] --> G
    G -->|before 300 s| I[POST transfers/ + Idempotency-Key]
    G -->|token expired| B
    I --> J{data.status}
    J -->|COMPLETED| K[Done · wallets.transfer.created]
    J -->|PENDING| L[Wait for wallets.transfer.settled or .failed]

Requirements: an API key with wallet:transfer (resolve and send) and wallet:read (limits), and a webhook endpoint subscribed to wallets.transfer.created, wallets.transfer.settled, and wallets.transfer.failed. The examples use the test account holder 1000000001; in Go they assume ctx and client created as in the quickstart.

1. Resolve the key and choose the rail #

Resolving is a read done with POST: it carries the personal data in the body (not in the URL), it doesn’t move money, and it doesn’t need an Idempotency-Key. Try P2P first, which is internal; if no one in your entity (organization) has that key, or the key lives at another entity (is_local: false), resolve through 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):
    """Returns (rail, resolution, expires_at) or (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"Send to {destino}?")

2. Show the masked name and confirm #

The resolution returns the recipient masked (Sa*** Re***; for Bre-B also the bank and the partial key ******6543). Show it exactly as returned and ask for explicit confirmation: it’s the user’s defense against a mistyped digit. Never show or store full data that the resolution didn’t return to you.

The resolution_token lives for 300 seconds (expires_in) and is bound to your entity, the paying wallet, and the receiving wallet. Store the expiration time: if the user confirms later, resolve again and ask for confirmation again (the name may have changed) instead of sending and waiting for 422 RESOLUTION_EXPIRED. A P2P token only works for P2P and a Bre-B token only for Bre-B: mixing them up returns 400 RESOLUTION_INVALID.

3. Check the limits (Bre-B) #

Bre-B transfers have per-transaction, daily, and monthly caps based on the account holder’s tier. Validate before confirming so the user doesn’t sit waiting for a rejection.

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"Over your limit: today you can send up to {lim['available_today']}")

4. Transfer before the token expires #

P2P and Bre-B take the same body (document, resolution_token, amount, description) and require an Idempotency-Key. Derive it from the transfer’s id in your database (nova-envio-<id>) and reuse it on every retry after a timeout or 5xx: the gateway returns the original result and never sends twice. If the transfer was rejected with 422 (insufficient funds, expired token, bank rejection), no money moved: after resolving and confirming again, use a new key.

from compense_gateway import ValidationError

if time.monotonic() >= vence_en - 15:                 # margin for latency
    riel, r, vence_en = resolver("3109876543", "PHONE")  # and confirm with the user again

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="Lunch payment", idempotency_key="nova-envio-ENV-2026-004512",
    )
except ValidationError as e:                           # 422: no money moved
    raise SystemExit(f"Could not send: {e.detail}")

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

Response for a Bre-B transfer in the 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)."}}

Errors you need to handle in this step:

HTTPEngine codeWhat to do
422INSUFFICIENT_FUNDSShow the available balance; no money moved
422RESOLUTION_EXPIREDResolve and confirm again; new key
422TRANSFER_REJECTEDThe receiving bank rejected it; show the reason from detail
422SELF_TRANSFERThe recipient is the same account holder
422—Limit exceeded or Bre-B not enabled for your entity
400RESOLUTION_INVALIDToken from another rail or another account holder: resolve again
409 (…/idempotency-conflict), 502, 504, timeout—Retry with the same key

5. Reconcile with webhooks #

P2P settles immediately: data.status comes back as COMPLETED. Bre-B may come back as COMPLETED or as PENDING if the receiving bank hasn’t confirmed yet; in that case the outcome arrives as an event. Show «In progress» and don’t ask the user to retry.

stateDiagram-v2
    [*] --> PENDING
    PENDING --> PROCESSING
    PROCESSING --> SETTLED: wallets.transfer.settled
    PROCESSING --> FAILED: wallets.transfer.failed
    SETTLED --> RETURNED: later return
    SETTLED --> [*]
    FAILED --> [*]
    RETURNED --> [*]
EventWhat it meansWhat to do in Nova
wallets.transfer.createdTransfer posted (P2P) or accepted by the rail (Bre-B, with rail: "breb")Mark the transfer as sent
wallets.transfer.settledAn in-flight transfer has settledMark it «Delivered» and notify
wallets.transfer.failedThe rail rejected it; no money moved (reason)Mark it «Failed» and show the reason

Reconcile on resource.transaction_id, which is the same data.transaction_id from the transfer response. Deduplicate on the event id: delivery is at least once.

Reconcile 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

If a webhook doesn’t arrive (your endpoint was down), look up the transaction with wallet.transactions.get(transaction_id, document=…) or redeliver it from Webhook retries.

6. Test every outcome in the sandbox #

The sandbox is deterministic. With account holder 1000000001 (balance 500,000):

To testDo this
Nonexistent keyAlias or key 9999999999 → found: false in both P2P and Bre-B
Sending to yourselfP2P with alias equal to the payer’s document → 422 SELF_TRANSFER
Mixed-up tokenSend a Bre-B token through P2P (or the other way around) → 400 RESOLUTION_INVALID
Rejection by the receiving bankBre-B for 11111 → 422 TRANSFER_REJECTED
Insufficient fundsMore than 500,000, or account holder 1000000002 (balance 0) / 1000000004 (balance 100) → 422 INSUFFICIENT_FUNDS
Safe retryRepeat the transfer with the same key → same transaction_id, idempotent_replay: true

Sandbox transfers respond COMPLETED instantly. To exercise your reconciliation of in-flight transfers, use the simulator: it advances one state per call and the magic amount decides the ending: 22.22 ends in FAILED, 33.33 settles and is then returned (RETURNED), and any other amount (for example 11.11) ends in 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)

The simulator doesn’t move balances or emit webhooks; combine it with the test events from step 5 to walk through every branch of your receiver.

Checklist #

  • You resolve through P2P first and fall back to Bre-B when the key isn’t local.
  • You show the masked name (and, for Bre-B, the bank) and ask for explicit confirmation.
  • You track the token’s expiration (300 s) and resolve again before sending if it expired.
  • The Idempotency-Key comes from your transfer’s id; you retry timeouts and 5xx with the same key.
  • You treat PENDING as «In progress» and reconcile with wallets.transfer.settled / .failed.
  • You tested SELF_TRANSFER, TRANSFER_REJECTED, INSUFFICIENT_FUNDS, and the magic amounts 22.22 and 33.33 in the sandbox.