Recipe
Send money: P2P and Bre-B
Build Nova’s «Send money» screen: resolve the key, show the masked name, transfer before the token expires, choose between P2P and Bre-B, respect limits, reconcile with wallets.transfer.*, and test every outcome with magic amounts.
On this page
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}?")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(`Send to ${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 'Send to ', $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($"Send to {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("Send to %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"}'
# If found is false or is_local is 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. 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']}")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(`Over your limit: today you can send up to ${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("Over your limit: today you can send up to {$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($"Over your limit: today you can send up to {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 } // the SDK decodes with UseNumber
porEnvio, hoy := num(lim["max_per_transaction"]), num(lim["available_today"])
if monto > porEnvio || monto > hoy {
log.Fatalf("over your limit: today you can send up to %.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. 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"))import { ValidationError } from "@compense/sdk";
let actual = destino!;
if (Date.now() >= actual.venceEn - 15_000) actual = (await resolver("3109876543", "PHONE"))!; // and reconfirm
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: "Lunch payment",
idempotencyKey: "nova-envio-ENV-2026-004512",
});
} catch (err) {
if (err instanceof ValidationError) throw new Error(`Could not send: ${err.detail}`); // 422: no money moved
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'); // and reconfirm
}
$recurso = $riel === 'p2p' ? $client->wallet->p2p : $client->wallet->breb;
try {
$tx = $recurso->transfer(
document: $doc,
resolutionToken: $r->resolutionToken,
amount: (string) $monto,
description: 'Lunch payment',
idempotencyKey: 'nova-envio-ENV-2026-004512',
);
} catch (ValidationException $e) { // 422: no money moved
exit('Could not send: ' . $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"); // and reconfirm
Transaction tx;
try
{
tx = riel == "p2p"
? await client.Wallet.P2P.TransferAsync(Doc, res!.ResolutionToken, Monto, "Lunch payment",
idempotencyKey: "nova-envio-ENV-2026-004512")
: await client.Wallet.BreB.TransferAsync(Doc, res!.ResolutionToken, Monto, "Lunch payment",
idempotencyKey: "nova-envio-ENV-2026-004512");
}
catch (ValidationException e) // 422: no money moved
{
Console.WriteLine($"Could not send: {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 { // and reconfirm
log.Fatal(err)
}
}
params := &compense.WalletTransferParams{
Document: doc, ResolutionToken: r.ResolutionToken,
Amount: compense.AmountFromInt(int64(monto)), Description: "Lunch payment",
}
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 money moved
log.Fatal("could not send: ", err)
} else if err != nil {
log.Fatal(err)
}
fmt.Println(tx.Data["status"], tx.Data["transaction_id"])# Bre-B (for P2P, replace breb/transfers/ with p2p/transfers/ and use the token from 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": "Lunch payment"}'Response for a Bre-B transfer in the 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)."}}Errors you need to handle in this step:
| HTTP | Engine code | What to do |
|---|---|---|
422 | INSUFFICIENT_FUNDS | Show the available balance; no money moved |
422 | RESOLUTION_EXPIRED | Resolve and confirm again; new key |
422 | TRANSFER_REJECTED | The receiving bank rejected it; show the reason from detail |
422 | SELF_TRANSFER | The recipient is the same account holder |
422 | — | Limit exceeded or Bre-B not enabled for your entity |
400 | RESOLUTION_INVALID | Token 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 --> [*]| Event | What it means | What to do in Nova |
|---|---|---|
wallets.transfer.created | Transfer posted (P2P) or accepted by the rail (Bre-B, with rail: "breb") | Mark the transfer as sent |
wallets.transfer.settled | An in-flight transfer has settled | Mark it «Delivered» and notify |
wallets.transfer.failed | The 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.
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
}))# Test event to your 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"}'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 test | Do this |
|---|---|
| Nonexistent key | Alias or key 9999999999 → found: false in both P2P and Bre-B |
| Sending to yourself | P2P with alias equal to the payer’s document → 422 SELF_TRANSFER |
| Mixed-up token | Send a Bre-B token through P2P (or the other way around) → 400 RESOLUTION_INVALID |
| Rejection by the receiving bank | Bre-B for 11111 → 422 TRANSFER_REJECTED |
| Insufficient funds | More than 500,000, or account holder 1000000002 (balance 0) / 1000000004 (balance 100) → 422 INSUFFICIENT_FUNDS |
| Safe retry | Repeat 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)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, ...}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-Keycomes from your transfer’s id; you retry timeouts and5xxwith the same key. - You treat
PENDINGas «In progress» and reconcile withwallets.transfer.settled/.failed. - You tested
SELF_TRANSFER,TRANSFER_REJECTED,INSUFFICIENT_FUNDS, and the magic amounts22.22and33.33in the sandbox.