Receta
Enviar dinero: P2P y Bre-B
Construye la pantalla «Enviar dinero» de Nova: resolver la llave, mostrar el nombre enmascarado, transferir antes de que venza el token, elegir entre P2P y Bre-B, respetar límites, conciliar por wallets.transfer.* y probar cada desenlace con montos mágicos.
En esta página
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}?")import { CompenseClient, type Resolution } from "@compense/sdk";
const client = new CompenseClient({ apiKey: process.env.COMPENSE_API_KEY!, environment: "sandbox" });
const DOC = "1000000001";
async function resolver(llave: string, tipo: string): Promise<{ riel: "p2p" | "breb"; r: Resolution; venceEn: number } | null> {
let r = await client.wallet.p2p.resolve({ document: DOC, alias: llave, keyType: tipo });
if (r.found && r.isLocal !== false) return { riel: "p2p", r, venceEn: Date.now() + r.expiresIn * 1000 };
r = await client.wallet.breb.resolve({ document: DOC, keyType: tipo, value: llave });
if (r.found) return { riel: "breb", r, venceEn: Date.now() + r.expiresIn * 1000 };
return null;
}
const destino = await resolver("3109876543", "PHONE");
if (destino) {
const { r } = destino;
console.log(`¿Enviar a ${r.displayName}${destino.riel === "breb" ? ` · ${r.institutionName} · ${r.keyDisplay}` : ""}?`);
}<?php
require __DIR__ . '/vendor/autoload.php';
use Compense\Sdk\CompenseClient;
$client = new CompenseClient(apiKey: getenv('COMPENSE_API_KEY'), environment: 'sandbox');
$doc = '1000000001';
/** @return array{0: ?string, 1: ?\Compense\Sdk\Model\Resolution, 2: ?float} */
function resolver(CompenseClient $client, string $doc, string $llave, string $tipo): array
{
$r = $client->wallet->p2p->resolve(document: $doc, alias: $llave, keyType: $tipo);
if ($r->found && $r->isLocal !== false) {
return ['p2p', $r, microtime(true) + $r->expiresIn];
}
$r = $client->wallet->breb->resolve(document: $doc, keyType: $tipo, value: $llave);
if ($r->found) {
return ['breb', $r, microtime(true) + $r->expiresIn];
}
return [null, null, null];
}
[$riel, $r, $venceEn] = resolver($client, $doc, '3109876543', 'PHONE');
if ($riel !== null) {
echo '¿Enviar a ', $r->displayName, $riel === 'breb' ? " · {$r->institutionName} · {$r->keyDisplay}" : '', '?', PHP_EOL;
}using Compense.Sdk;
using Compense.Sdk.Models;
using var client = new CompenseClient(
Environment.GetEnvironmentVariable("COMPENSE_API_KEY")!, CompenseEnvironment.Sandbox);
const string Doc = "1000000001";
async Task<(string? Riel, Resolution? R, DateTimeOffset VenceEn)> Resolver(string llave, string tipo)
{
Resolution r = await client.Wallet.P2P.ResolveAsync(Doc, llave, tipo);
if (r.Found && r.IsLocal != false) return ("p2p", r, DateTimeOffset.UtcNow.AddSeconds(r.ExpiresIn));
r = await client.Wallet.BreB.ResolveAsync(Doc, tipo, llave);
if (r.Found) return ("breb", r, DateTimeOffset.UtcNow.AddSeconds(r.ExpiresIn));
return (null, null, DateTimeOffset.MinValue);
}
var (riel, res, venceEn) = await Resolver("3109876543", "PHONE");
if (riel is not null)
Console.WriteLine($"¿Enviar a {res!.DisplayName}{(riel == "breb" ? $" · {res.InstitutionName} · {res.KeyDisplay}" : "")}?");const doc = "1000000001"
resolver := func(llave, tipo string) (string, *compense.Resolution, time.Time, error) {
r, err := client.Wallet.P2P.Resolve(ctx, &compense.P2PResolveParams{Document: doc, Alias: llave, KeyType: tipo})
if err != nil {
return "", nil, time.Time{}, err
}
if r.Found && (r.IsLocal == nil || *r.IsLocal) {
return "p2p", r, time.Now().Add(time.Duration(r.ExpiresIn) * time.Second), nil
}
r, err = client.Wallet.BreB.Resolve(ctx, &compense.BreBResolveParams{Document: doc, KeyType: tipo, Value: llave})
if err != nil || !r.Found {
return "", nil, time.Time{}, err
}
return "breb", r, time.Now().Add(time.Duration(r.ExpiresIn) * time.Second), nil
}
riel, r, venceEn, err := resolver("3109876543", "PHONE")
if err != nil {
log.Fatal(err)
}
if riel != "" {
fmt.Printf("¿Enviar a %s (%s %s)?\n", r.DisplayName, r.InstitutionName, r.KeyDisplay)
}curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/wallet/p2p/resolve/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"document": "1000000001", "alias": "3109876543", "key_type": "PHONE"}'
# Si found es false o is_local es false:
curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/wallet/breb/resolve/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"document": "1000000001", "key_type": "PHONE", "value": "3109876543"}'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']}")const monto = 150000;
if (destino?.riel === "breb") {
const lim = await client.wallet.breb.limits(DOC);
if (monto > Number(lim.max_per_transaction) || monto > Number(lim.available_today ?? 0)) {
throw new Error(`Supera tu límite: hoy puedes enviar hasta ${lim.available_today}`);
}
}$monto = 150000;
if ($riel === 'breb') {
$lim = $client->wallet->breb->limits($doc);
if ($monto > $lim['max_per_transaction'] || $monto > ($lim['available_today'] ?? 0)) {
exit("Supera tu límite: hoy puedes enviar hasta {$lim['available_today']}\n");
}
}const decimal Monto = 150000m;
if (riel == "breb")
{
var lim = await client.Wallet.BreB.LimitsAsync(Doc);
decimal disponibleHoy = lim["available_today"]?.GetValue<decimal>() ?? 0m;
if (Monto > lim["max_per_transaction"]!.GetValue<decimal>() || Monto > disponibleHoy)
throw new InvalidOperationException($"Supera tu límite: hoy puedes enviar hasta {disponibleHoy}");
}const monto = 150000.0
if riel == "breb" {
lim, err := client.Wallet.BreB.Limits(ctx, doc)
if err != nil {
log.Fatal(err)
}
num := func(v any) float64 { n, _ := v.(json.Number); f, _ := n.Float64(); return f } // el SDK decodifica con UseNumber
porEnvio, hoy := num(lim["max_per_transaction"]), num(lim["available_today"])
if monto > porEnvio || monto > hoy {
log.Fatalf("supera tu límite: hoy puedes enviar hasta %.0f", hoy)
}
}curl "https://sandbox.dev.compense.co/gw/v1/gateway/wallet/breb/limits/?document=1000000001" \
-H "X-API-Key: $COMPENSE_API_KEY"
# {"tier": "TIER_1", "max_per_transaction": 2000000.0, "max_daily_amount": 3000000.0,
# "max_monthly_amount": 10000000.0, "used_today": 0.0, "used_month": 20000.0, "available_today": 3000000.0}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"))import { ValidationError } from "@compense/sdk";
let actual = destino!;
if (Date.now() >= actual.venceEn - 15_000) actual = (await resolver("3109876543", "PHONE"))!; // y reconfirma
const recurso = actual.riel === "p2p" ? client.wallet.p2p : client.wallet.breb;
let tx;
try {
tx = await recurso.transfer({
document: DOC,
resolutionToken: actual.r.resolutionToken,
amount: String(monto),
description: "Pago del almuerzo",
idempotencyKey: "nova-envio-ENV-2026-004512",
});
} catch (err) {
if (err instanceof ValidationError) throw new Error(`No se pudo enviar: ${err.detail}`); // 422: no se movió dinero
throw err;
}
console.log(tx.data.status, tx.data.transaction_id, tx.data.end_to_end_id);use Compense\Sdk\Exception\ValidationException;
if (microtime(true) >= $venceEn - 15) {
[$riel, $r, $venceEn] = resolver($client, $doc, '3109876543', 'PHONE'); // y reconfirma
}
$recurso = $riel === 'p2p' ? $client->wallet->p2p : $client->wallet->breb;
try {
$tx = $recurso->transfer(
document: $doc,
resolutionToken: $r->resolutionToken,
amount: (string) $monto,
description: 'Pago del almuerzo',
idempotencyKey: 'nova-envio-ENV-2026-004512',
);
} catch (ValidationException $e) { // 422: no se movió dinero
exit('No se pudo enviar: ' . $e->detail . PHP_EOL);
}
echo $tx->data['status'], ' ', $tx->data['transaction_id'], PHP_EOL;if (DateTimeOffset.UtcNow >= venceEn.AddSeconds(-15))
(riel, res, venceEn) = await Resolver("3109876543", "PHONE"); // y reconfirma
Transaction tx;
try
{
tx = riel == "p2p"
? await client.Wallet.P2P.TransferAsync(Doc, res!.ResolutionToken, Monto, "Pago del almuerzo",
idempotencyKey: "nova-envio-ENV-2026-004512")
: await client.Wallet.BreB.TransferAsync(Doc, res!.ResolutionToken, Monto, "Pago del almuerzo",
idempotencyKey: "nova-envio-ENV-2026-004512");
}
catch (ValidationException e) // 422: no se movió dinero
{
Console.WriteLine($"No se pudo enviar: {e.Detail}");
return;
}
Console.WriteLine($"{tx.Data["status"]} {tx.Data["transaction_id"]}");if time.Now().After(venceEn.Add(-15 * time.Second)) {
if riel, r, venceEn, err = resolver("3109876543", "PHONE"); err != nil { // y reconfirma
log.Fatal(err)
}
}
params := &compense.WalletTransferParams{
Document: doc, ResolutionToken: r.ResolutionToken,
Amount: compense.AmountFromInt(int64(monto)), Description: "Pago del almuerzo",
}
llave := compense.WithIdempotencyKey("nova-envio-ENV-2026-004512")
var tx *compense.Transaction
if riel == "p2p" {
tx, err = client.Wallet.P2P.Transfer(ctx, params, llave)
} else {
tx, err = client.Wallet.BreB.Transfer(ctx, params, llave)
}
if compense.IsValidationError(err) { // 422: no se movió dinero
log.Fatal("no se pudo enviar: ", err)
} else if err != nil {
log.Fatal(err)
}
fmt.Println(tx.Data["status"], tx.Data["transaction_id"])# Bre-B (para P2P cambia breb/transfers/ por p2p/transfers/ y usa el token de p2p/resolve/)
curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/wallet/breb/transfers/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: nova-envio-ENV-2026-004512" \
-H "Content-Type: application/json" \
-d '{"document": "1000000001", "resolution_token": "'"$TOKEN"'", "amount": "150000", "description": "Pago del almuerzo"}'Respuesta de un Bre-B en el sandbox:
{"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:
| HTTP | Código del motor | Qué hacer |
|---|---|---|
422 | INSUFFICIENT_FUNDS | Muestra el saldo disponible; no se movió dinero |
422 | RESOLUTION_EXPIRED | Resuelve y confirma de nuevo; nueva llave |
422 | TRANSFER_REJECTED | El banco destino rechazó; informa el motivo del detail |
422 | SELF_TRANSFER | El destino es el mismo titular |
422 | — | Límite superado o Bre-B no habilitado en tu entidad |
400 | RESOLUTION_INVALID | Token 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 --> [*]| Evento | Qué significa | Qué hacer en Nova |
|---|---|---|
wallets.transfer.created | Envío asentado (P2P) o aceptado por el riel (Bre-B, con rail: "breb") | Marca el envío como enviado |
wallets.transfer.settled | Una transferencia en curso quedó liquidada | Marca «Entregado» y notifica |
wallets.transfer.failed | El 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.
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 200import express from "express";
import { verifyWebhook, WebhookVerificationError } from "@compense/sdk/webhooks";
const ESTADO: Record<string, string> = {
"wallets.transfer.created": "ENVIADO",
"wallets.transfer.settled": "ENTREGADO",
"wallets.transfer.failed": "FALLIDO",
};
const app = express();
app.post("/compense/webhooks", express.raw({ type: "*/*" }), (req, res) => {
let ev;
try {
ev = verifyWebhook(req.body, req.headers, process.env.COMPENSE_WEBHOOK_SECRET!);
} catch (err) {
if (err instanceof WebhookVerificationError) return res.sendStatus(401);
throw err;
}
if (!ev.isTest && ESTADO[ev.event] && !yaProcesado(ev.eventId)) {
actualizarEnvio(ev.resource.transaction_id, ESTADO[ev.event], ev.resource.reason);
registrarProcesado(ev.eventId);
}
res.sendStatus(200);
});use Compense\Sdk\Webhook\Webhook;
use Compense\Sdk\Webhook\WebhookVerificationException;
$estado = [
'wallets.transfer.created' => 'ENVIADO',
'wallets.transfer.settled' => 'ENTREGADO',
'wallets.transfer.failed' => 'FALLIDO',
];
try {
$ev = Webhook::verify(file_get_contents('php://input'), getallheaders(), getenv('COMPENSE_WEBHOOK_SECRET'));
} catch (WebhookVerificationException) {
http_response_code(401);
exit;
}
if (!$ev->isTest() && isset($estado[$ev->event()]) && !yaProcesado($ev->eventId)) {
$r = $ev->resource();
actualizarEnvio($r['transaction_id'], $estado[$ev->event()], $r['reason'] ?? null);
registrarProcesado($ev->eventId);
}
http_response_code(200);using Compense.Sdk.Webhooks;
var estado = new Dictionary<string, string>
{
["wallets.transfer.created"] = "ENVIADO",
["wallets.transfer.settled"] = "ENTREGADO",
["wallets.transfer.failed"] = "FALLIDO",
};
app.MapPost("/compense/webhooks", async (HttpRequest req) =>
{
using var cuerpo = new MemoryStream();
await req.Body.CopyToAsync(cuerpo);
WebhookEvent ev;
try { ev = CompenseWebhooks.Verify(cuerpo.ToArray(), req.Headers, Environment.GetEnvironmentVariable("COMPENSE_WEBHOOK_SECRET")!); }
catch (WebhookVerificationException) { return Results.Unauthorized(); }
if (!ev.IsTest && estado.TryGetValue(ev.Event, out var nuevo) && !YaProcesado(ev.EventId))
{
ActualizarEnvio(ev.Resource["transaction_id"]!.GetValue<string>(), nuevo, ev.Resource["reason"]?.ToString());
RegistrarProcesado(ev.EventId);
}
return Results.Ok();
});import "github.com/Appiasoft/compense-saas-sdk-go/webhook"
estado := map[string]string{
"wallets.transfer.created": "ENVIADO",
"wallets.transfer.settled": "ENTREGADO",
"wallets.transfer.failed": "FALLIDO",
}
secrets := []string{os.Getenv("COMPENSE_WEBHOOK_SECRET")}
http.Handle("/compense/webhooks", webhook.Handler(secrets, func(ctx context.Context, ev *webhook.Event) error {
nuevo, ok := estado[ev.Event()]
if ev.IsTest() || !ok || yaProcesado(ev.ID) {
return nil
}
r := ev.Resource()
actualizarEnvio(r["transaction_id"], nuevo, r["reason"])
registrarProcesado(ev.ID)
return nil
}))# Evento de prueba hacia tu endpoint
curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/endpoints/$ENDPOINT_ID/test/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"event_type": "wallets.transfer.failed"}'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 probar | Haz esto |
|---|---|
| Llave inexistente | Alias o llave 9999999999 → found: false en P2P y en Bre-B |
| Envío a sí mismo | P2P con alias igual al documento del pagador → 422 SELF_TRANSFER |
| Token cruzado | Envía por P2P un token de Bre-B (o al revés) → 400 RESOLUTION_INVALID |
| Rechazo del banco destino | Bre-B por 11111 → 422 TRANSFER_REJECTED |
| Sin saldo | Más de 500.000, o el titular 1000000002 (saldo 0) / 1000000004 (saldo 100) → 422 INSUFFICIENT_FUNDS |
| Reintento seguro | Repite 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)let estado = "PENDING";
for (;;) {
const paso = await client.sandbox.simulate.walletTransfer({ status: estado, amount: "33.33", transactionId: "wtx-E-3f1c9a0b2d4e" });
console.log(paso.previous_status, "→", paso.status, paso.reason_code ?? "");
if (paso.terminal) break;
estado = String(paso.status);
}$estado = 'PENDING';
do {
$paso = $client->sandbox->simulate->walletTransfer(status: $estado, amount: '33.33', transactionId: 'wtx-E-3f1c9a0b2d4e');
echo $paso['previous_status'], ' → ', $paso['status'], ' ', $paso['reason_code'] ?? '', PHP_EOL;
$estado = $paso['status'];
} while (!$paso['terminal']);string estadoSim = "PENDING";
while (true)
{
var paso = await client.Sandbox.Simulate.WalletTransferAsync(estadoSim, "33.33", "wtx-E-3f1c9a0b2d4e");
Console.WriteLine($"{paso["previous_status"]} → {paso["status"]} {paso["reason_code"]}");
if (paso["terminal"]!.GetValue<bool>()) break;
estadoSim = paso["status"]!.GetValue<string>();
}estadoSim := "PENDING"
for {
paso, err := client.Sandbox.Simulate.WalletTransfer(ctx, &compense.SimulationState{
Status: estadoSim, Amount: "33.33", TransactionID: "wtx-E-3f1c9a0b2d4e",
})
if err != nil {
log.Fatal(err)
}
fmt.Println(paso["previous_status"], "→", paso["status"], paso["reason_code"])
if fin, _ := paso["terminal"].(bool); fin {
break
}
estadoSim, _ = paso["status"].(string)
}curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/sandbox/wallet/simulate/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "SETTLED", "amount": "33.33", "transaction_id": "wtx-E-3f1c9a0b2d4e"}'
# {"object": "wallet_transfer", "previous_status": "SETTLED", "status": "RETURNED",
# "terminal": true, "reason_code": "TRANSFER_RETURNED", "amount": 33.33, ...}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-Keysale del id de tu envío; reintentas timeouts y5xxcon la misma llave. - Tratas
PENDINGcomo «En proceso» y concilias porwallets.transfer.settled/.failed. - Probaste en sandbox
SELF_TRANSFER,TRANSFER_REJECTED,INSUFFICIENT_FUNDSy los montos mágicos22.22y33.33.