Implementation guide
Wallet
Operate your affiliates’ wallet server-to-server: balance and transactions, P2P and Bre-B transfers, bill payments, mobile top-ups, pockets, cards and currency exchange, with idempotency, business errors and wallets.* events.
On this page
This guide is for the technical team at a fintech (such as Nova) or a family compensation fund
(caja de compensación, such as Caja Horizonte) that operates its affiliates’ wallet through the
API, with the same rules as the app: balance and transactions, P2P transfers to another affiliate,
Bre-B to other banks, utility bill payments, mobile top-ups, sub-accounts (pockets, savings, goals),
cards and currency exchange. Everything lives under the contract’s wallet tag and, in the SDKs,
under client.wallet.
Before you continue, have your sandbox key at hand and review Idempotency and Amounts: almost everything here moves money.
Before you start #
The holder is identified by document #
There is no affiliate identifier in the paths: the key already identifies your entity and the holder
is always identified by document (their ID document number). In GET requests it goes in the query
string (?document=1000000001) and in POST requests inside the JSON body. A document that doesn’t
exist in your entity returns 404.
Scopes #
| Scope | What it allows |
|---|---|
wallet:read | Balance, transactions and their detail, Bre-B limits, billers, payment history, top-up operators, accounts, cards and their transactions |
wallet:transfer | P2P (resolve and send) and Bre-B (resolve and send) |
wallet:payments | Look up and pay utility bills |
wallet:topup | Top up a mobile phone |
wallet:accounts | Create pockets/savings/goals and transfer between the holder’s own accounts |
wallet:cards | Issue cards and change their status (freeze, unfreeze, block) |
wallet:fx | Quote and execute currency exchange |
Request only the ones your integration needs. wallet:transfer, wallet:payments and wallet:topup
move money and are on the list of scopes that will require an HMAC request signature once that
protection is turned on for your key (inactive today; you’ll be notified in advance). If your entity
hasn’t contracted the module, every path returns 403 with «El módulo de billetera no está
contratado para esta entidad.» (“The wallet module is not contracted for this entity.”).
Amounts and idempotency #
- Amounts travel as decimal strings (
"30000","65000.00"), in Colombian pesos. The SDKs accept numbers or strings and convert them; see Amounts. - P2P, Bre-B, bill payment and top-up require
Idempotency-Key: without it the gateway returns400. The SDKs always send one (they generate a UUID if you don’t pass your own), but if your system may resend the operation, pass a key that is stable per business operation (for examplenova-p2p-2026-11-02-000123). Repeating the same key returns the original result withidempotent_replay: trueand never moves money twice. - Reads done via
POST(p2p.resolve,breb.resolve,bills.inquire,fx.quote) carry the personal data in the body so it doesn’t end up in URLs or logs; they don’t move money, don’t take a key, and the SDKs never retry them.
client.wallet.accounts is client.accounts
client.wallet.accounts, client.wallet.cards and client.wallet.fx are the same objects as
client.accounts, client.cards and client.fx (in .NET client.Wallet.Accounts and
client.Accounts; in Go client.Wallet.Accounts and client.Accounts). Use whichever path you
prefer: they call exactly the same operations.
Operations map #
Balance #
wallet.balance returns a typed Balance. available is what the holder can spend (the engine’s
available_cash, net of holds); total includes held and pending funds.
import os
from compense_gateway import CompenseClient
client = CompenseClient(api_key=os.environ["COMPENSE_API_KEY"], environment="sandbox")
DOC = "1000000001" # test holder: eligible profile, balance 500,000
saldo = client.wallet.balance(DOC)
print(saldo.available.amount, saldo.total.amount, saldo.currency)
if saldo.pending is not None:
print("In transit:", saldo.pending.amount)import { CompenseClient } from "@compense/sdk";
const client = new CompenseClient({ apiKey: process.env.COMPENSE_API_KEY!, environment: "sandbox" });
const DOC = "1000000001"; // test holder: eligible profile, balance 500,000
const saldo = await client.wallet.balance(DOC);
console.log(saldo.available.amount, saldo.total.amount, saldo.currency);
if (saldo.pending) console.log("In transit:", saldo.pending.amount);<?php
require __DIR__ . '/vendor/autoload.php';
use Compense\Sdk\CompenseClient;
$client = new CompenseClient(apiKey: getenv('COMPENSE_API_KEY'), environment: 'sandbox');
$doc = '1000000001'; // test holder: eligible profile, balance 500,000
$saldo = $client->wallet->balance($doc);
echo $saldo->available->amount, ' ', $saldo->total->amount, ' ', $saldo->currency, PHP_EOL;
if ($saldo->pending !== null) {
echo 'In transit: ', $saldo->pending->amount, 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"; // test holder: eligible profile, balance 500,000
Balance saldo = await client.Wallet.BalanceAsync(Doc);
Console.WriteLine($"{saldo.Available.Amount} {saldo.Total.Amount} {saldo.Currency}");
if (saldo.Pending is not null) Console.WriteLine($"In transit: {saldo.Pending.Amount}");package main
import (
"context"
"fmt"
"log"
"os"
compense "github.com/Appiasoft/compense-saas-sdk-go"
)
func main() {
ctx := context.Background()
client, err := compense.NewClient(os.Getenv("COMPENSE_API_KEY"),
compense.WithEnvironment(compense.Sandbox))
if err != nil {
log.Fatal(err)
}
const doc = "1000000001" // test holder: eligible profile, balance 500,000
saldo, err := client.Wallet.Balance(ctx, doc)
if err != nil {
log.Fatal(err)
}
fmt.Println(saldo.Available.Amount, saldo.Total.Amount, saldo.Currency)
if saldo.Pending != nil {
fmt.Println("In transit:", saldo.Pending.Amount)
}
}curl "https://sandbox.dev.compense.co/gw/v1/gateway/wallet/balance/?document=1000000001" \
-H "X-API-Key: $COMPENSE_API_KEY"{"total": 500000.0, "available_cash": 500000.0, "restricted_amount": 0.0,
"pending_amount": 0.0, "currency": "COP", "environment": "sandbox"}| Contract field | In the SDK | What it is |
|---|---|---|
total | total | The holder’s total balance |
available_cash | available | Available to spend (net of holds) |
restricted_amount | restricted | Held (optional; null if the engine doesn’t send it) |
pending_amount | pending | In transit (optional) |
currency | currency | The entity’s currency (COP in Colombia) |
The rest of the examples on this page reuse client and the document 1000000001; in Go they assume
ctx, client and doc from the previous block.
Transactions #
GET/v1/gateway/wallet/transactions/
paginates with limit/offset and returns an OffsetPage:
results, limit, offset, has_more and next_offset (details in
Pagination). Filters: start_date and end_date (YYYY-MM-DD or ISO 8601;
a bare date in end_date includes that whole day), category (TRANSFER, PAYMENT…), type (the
engine’s type: TRANSFER_OUT, TOPUP…) and limit from 1 to 100 (50 by default). Transactions come
from newest to oldest.
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)const pagina = await client.wallet.transactions.list(DOC, {
startDate: "2026-10-01",
endDate: "2026-10-31",
limit: 20,
});
// `for await` walks every page by following next_offset
for await (const mov of pagina) {
console.log(mov.date, mov.transaction_type, mov.amount, mov.status);
}
const detalle = await client.wallet.transactions.get(String(pagina.results[0].id), { document: DOC });$offset = 0;
do {
$pagina = $client->wallet->transactions->list(
$doc, startDate: '2026-10-01', endDate: '2026-10-31', limit: 20, offset: $offset,
);
foreach ($pagina as $mov) {
echo $mov['date'], ' ', $mov['transaction_type'], ' ', $mov['amount'], ' ', $mov['status'], PHP_EOL;
}
$offset = $pagina->nextOffset;
} while ($pagina->hasMore && $offset !== null);
$detalle = $client->wallet->transactions->get($pagina->results[0]['id'], $doc);using System.Text.Json.Nodes;
// Walks every page by following next_offset
await foreach (JsonObject mov in client.Wallet.Transactions.ListAutoPagingAsync(
Doc, startDate: "2026-10-01", endDate: "2026-10-31", limit: 20))
{
Console.WriteLine($"{mov["date"]} {mov["transaction_type"]} {mov["amount"]} {mov["status"]}");
}
// Or a single page:
OffsetPage pagina = await client.Wallet.Transactions.ListAsync(Doc, startDate: "2026-10-01", limit: 20);
JsonObject detalle = await client.Wallet.Transactions.GetAsync(
pagina.Results[0]["id"]!.GetValue<string>(), Doc);it := compense.NewOffsetIterator(ctx, func(ctx context.Context, offset int) (*compense.OffsetPage, error) {
return client.Wallet.Transactions.List(ctx, doc, &compense.WalletTransactionsParams{
StartDate: "2026-10-01", EndDate: "2026-10-31", Limit: 20, Offset: offset,
})
})
for it.Next() {
mov := it.Item()
fmt.Println(mov["date"], mov["transaction_type"], mov["amount"], mov["status"])
}
if err := it.Err(); err != nil {
log.Fatal(err)
}
detalle, err := client.Wallet.Transactions.Get(ctx, "wtx-E-3f1c9a0b2d4e", doc)
if err != nil {
log.Fatal(err)
}
fmt.Println(detalle["status"])curl "https://sandbox.dev.compense.co/gw/v1/gateway/wallet/transactions/?document=1000000001&start_date=2026-10-01&end_date=2026-10-31&limit=20&offset=0" \
-H "X-API-Key: $COMPENSE_API_KEY"
curl "https://sandbox.dev.compense.co/gw/v1/gateway/wallet/transactions/$MOV_ID/?document=1000000001" \
-H "X-API-Key: $COMPENSE_API_KEY"{"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}The detail only shows transactions of the given holder: the id of another affiliate’s
transaction returns 404, even if it exists.
P2P transfer to another affiliate #
A P2P transfer moves money between two affiliates of your own entity. It takes two steps, just like in the app: resolve (find out who you’re about to pay) and send.
The entity never sends a bare recipient id. resolve looks up the alias inside the engine and
returns the masked name and a signed, short-lived (300 s) resolution_token, bound to your
entity, to the paying wallet and to the receiving wallet. The send only accepts that token: you can’t
confirm one person and pay another, or reuse the token with a different holder.
sequenceDiagram
autonumber
participant U as User (Nova app)
participant N as Nova backend
participant G as Compense Gateway
U->>N: Send $30.000 to 300 123 4567
N->>G: POST wallet/p2p/resolve/ {document, alias}
G-->>N: found, display_name "Sa*** Re***", resolution_token (300 s)
N->>U: Confirm sending to Sa*** Re***?
U->>N: Yes
N->>G: POST wallet/p2p/transfers/ + Idempotency-Key
G-->>N: 201 {transaction_id, data.status: COMPLETED}
G-)N: webhook wallets.transfer.createdr = client.wallet.p2p.resolve(document=DOC, alias="3001234567", key_type="PHONE")
if not r.found:
raise SystemExit("No one in the entity has that alias")
if r.is_local is False:
raise SystemExit("The key belongs to another entity: use Bre-B")
# Show r.display_name to the user and ask for confirmation within r.expires_in seconds
tx = client.wallet.p2p.transfer(
document=DOC,
resolution_token=r.resolution_token,
amount="30000",
description="Rent",
idempotency_key="nova-p2p-2026-11-02-000123",
)
print(tx.transaction_id, tx.data["transaction_id"], tx.data["status"], tx.idempotent_replay)const r = await client.wallet.p2p.resolve({ document: DOC, alias: "3001234567", keyType: "PHONE" });
if (!r.found) throw new Error("No one in the entity has that alias");
if (r.isLocal === false) throw new Error("The key belongs to another entity: use Bre-B");
// Show r.displayName to the user and ask for confirmation within r.expiresIn seconds
const tx = await client.wallet.p2p.transfer({
document: DOC,
resolutionToken: r.resolutionToken,
amount: "30000",
description: "Rent",
idempotencyKey: "nova-p2p-2026-11-02-000123",
});
console.log(tx.transactionId, tx.data.transaction_id, tx.data.status, tx.idempotentReplay);$r = $client->wallet->p2p->resolve(document: $doc, alias: '3001234567', keyType: 'PHONE');
if (!$r->found) {
exit("No one in the entity has that alias\n");
}
if ($r->isLocal === false) {
exit("The key belongs to another entity: use Bre-B\n");
}
// Show $r->displayName to the user and ask for confirmation within $r->expiresIn seconds
$tx = $client->wallet->p2p->transfer(
document: $doc,
resolutionToken: $r->resolutionToken,
amount: '30000',
description: 'Rent',
idempotencyKey: 'nova-p2p-2026-11-02-000123',
);
echo $tx->transactionId, ' ', $tx->data['transaction_id'], ' ', $tx->data['status'], PHP_EOL;Resolution r = await client.Wallet.P2P.ResolveAsync(Doc, "3001234567", keyType: "PHONE");
if (!r.Found) throw new InvalidOperationException("No one in the entity has that alias");
if (r.IsLocal == false) throw new InvalidOperationException("The key belongs to another entity: use Bre-B");
// Show r.DisplayName to the user and ask for confirmation within r.ExpiresIn seconds
Transaction tx = await client.Wallet.P2P.TransferAsync(
Doc, r.ResolutionToken, "30000", description: "Rent",
idempotencyKey: "nova-p2p-2026-11-02-000123");
Console.WriteLine($"{tx.TransactionId} {tx.Data["transaction_id"]} {tx.Data["status"]} {tx.IdempotentReplay}");r, err := client.Wallet.P2P.Resolve(ctx, &compense.P2PResolveParams{
Document: doc, Alias: "3001234567", KeyType: "PHONE",
})
if err != nil {
log.Fatal(err)
}
if !r.Found {
log.Fatal("no one in the entity has that alias")
}
if r.IsLocal != nil && !*r.IsLocal {
log.Fatal("the key belongs to another entity: use Bre-B")
}
// Show r.DisplayName to the user and ask for confirmation within r.ExpiresIn seconds
tx, err := client.Wallet.P2P.Transfer(ctx, &compense.WalletTransferParams{
Document: doc,
ResolutionToken: r.ResolutionToken,
Amount: "30000",
Description: "Rent",
}, compense.WithIdempotencyKey("nova-p2p-2026-11-02-000123"))
if err != nil {
log.Fatal(err)
}
fmt.Println(tx.TransactionID, tx.Data["transaction_id"], tx.Data["status"], tx.IdempotentReplay)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": "3001234567", "key_type": "PHONE"}'
curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/wallet/p2p/transfers/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: nova-p2p-2026-11-02-000123" \
-H "Content-Type: application/json" \
-d '{"document": "1000000001", "resolution_token": "'"$TOKEN"'", "amount": "30000", "description": "Rent"}'Response from resolve and from transfer:
{"found": true, "is_local": true, "display_name": "Sa*** Re***",
"resolution_token": "p2p-E-…", "expires_in": 300}{"status": "success", "transaction_id": "gw-…", "module": "wallet",
"data": {"transaction_id": "wtx-E-…", "status": "COMPLETED", "amount": 30000.0,
"currency": "COP", "display_name": "Sa*** Re***"}}aliasaccepts a mobile number, email, document or @alias;key_type(PHONE,EMAIL,DOCUMENT,ALIAS) is optional and disambiguates a string of digits.found: false: no one has that alias.is_local: false: the key belongs to another entity, use Bre-B.tx.transaction_idis the gateway id (for Transactions and support);tx.data["transaction_id"]is the wallet transaction (usable withwallet.transactions.get).- If the risk engine holds the transfer, the response includes
review_status: EN_REVISION.
Bre-B to another bank #
Bre-B (Colombia’s instant payments) are 24/7 instant payments to accounts at other banks using a
key (mobile number, document, email or alias). The flow is the same as P2P: resolve the key and send
with the token. Your entity must have Bre-B enabled; otherwise the send returns 422 with «Esta
entidad aún no tiene habilitadas las transferencias Bre-B.» (“This entity doesn’t have Bre-B
transfers enabled yet.”).
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:
# Show the user r.display_name, r.institution_name and r.key_display
tx = client.wallet.breb.transfer(
document=DOC, resolution_token=r.resolution_token, amount="25000",
description="Payment", idempotency_key="nova-breb-2026-11-02-000045",
)
if tx.data["status"] == "PENDING":
print("The destination bank hasn't confirmed yet: wait for wallets.transfer.settled")const limites = await client.wallet.breb.limits(DOC);
console.log(limites.max_per_transaction, limites.available_today);
const r = await client.wallet.breb.resolve({ document: DOC, keyType: "PHONE", value: "3109876543" });
if (r.found) {
// Show the user r.displayName, r.institutionName and r.keyDisplay
const tx = await client.wallet.breb.transfer({
document: DOC,
resolutionToken: r.resolutionToken,
amount: "25000",
description: "Payment",
idempotencyKey: "nova-breb-2026-11-02-000045",
});
if (tx.data.status === "PENDING") console.log("The destination bank hasn't confirmed yet");
}$limites = $client->wallet->breb->limits($doc);
echo $limites['max_per_transaction'], ' ', $limites['available_today'], PHP_EOL;
$r = $client->wallet->breb->resolve(document: $doc, keyType: 'PHONE', value: '3109876543');
if ($r->found) {
// Show the user $r->displayName, $r->institutionName and $r->keyDisplay
$tx = $client->wallet->breb->transfer(
document: $doc,
resolutionToken: $r->resolutionToken,
amount: '25000',
description: 'Payment',
idempotencyKey: 'nova-breb-2026-11-02-000045',
);
if ($tx->data['status'] === 'PENDING') {
echo "The destination bank hasn't confirmed yet\n";
}
}JsonObject limites = await client.Wallet.BreB.LimitsAsync(Doc);
Console.WriteLine($"{limites["max_per_transaction"]} {limites["available_today"]}");
Resolution r = await client.Wallet.BreB.ResolveAsync(Doc, "PHONE", "3109876543");
if (r.Found)
{
// Show the user r.DisplayName, r.InstitutionName and r.KeyDisplay
Transaction tx = await client.Wallet.BreB.TransferAsync(
Doc, r.ResolutionToken, "25000", description: "Payment",
idempotencyKey: "nova-breb-2026-11-02-000045");
if (tx.Data["status"]?.GetValue<string>() == "PENDING")
Console.WriteLine("The destination bank hasn't confirmed yet");
}limites, err := client.Wallet.BreB.Limits(ctx, doc)
if err != nil {
log.Fatal(err)
}
fmt.Println(limites["max_per_transaction"], limites["available_today"])
r, err := client.Wallet.BreB.Resolve(ctx, &compense.BreBResolveParams{
Document: doc, KeyType: "PHONE", Value: "3109876543",
})
if err != nil {
log.Fatal(err)
}
if r.Found {
// Show the user r.DisplayName, r.InstitutionName and r.KeyDisplay
tx, err := client.Wallet.BreB.Transfer(ctx, &compense.WalletTransferParams{
Document: doc, ResolutionToken: r.ResolutionToken, Amount: "25000", Description: "Payment",
}, compense.WithIdempotencyKey("nova-breb-2026-11-02-000045"))
if err != nil {
log.Fatal(err)
}
if tx.Data["status"] == "PENDING" {
fmt.Println("The destination bank hasn't confirmed yet")
}
}curl "https://sandbox.dev.compense.co/gw/v1/gateway/wallet/breb/limits/?document=1000000001" \
-H "X-API-Key: $COMPENSE_API_KEY"
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"}'
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-breb-2026-11-02-000045" \
-H "Content-Type: application/json" \
-d '{"document": "1000000001", "resolution_token": "'"$TOKEN"'", "amount": "25000", "description": "Payment"}'{"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,EMAILorALIAS.- If the key turns out to belong to an affiliate of your own entity (
is_local: true), the send is executed as an internal transfer with the same token. data.statusof the send:COMPLETED(settled) orPENDING(the destination bank hasn’t confirmed yet; the outcome arrives via event or lookup). A rejection by the destination bank is a422with the reason and no money is debited.
Limits depend on the holder’s tier:
| Field | What it is |
|---|---|
tier | The holder’s tier (TIER_1…) |
max_per_transaction | Cap per transfer |
max_daily_amount / max_monthly_amount | Daily and monthly caps |
used_today / used_month | Amount already used |
available_today | What they can still send today |
Check the limits before offering the transfer: an amount above them returns 422.
Utility bills #
Bill payment has three steps: choose the biller (agreement), look up the bill to get the exact amount, and pay it. For a complete flow, with user confirmation, retries and receipt, follow the recipe Collect a bill from the wallet.
convenios = client.wallet.bills.billers(category="POWER", q="epm") # doesn't need 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"], # the amount from the lookup
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)const convenios = await client.wallet.bills.billers({ category: "POWER", q: "epm" });
const factura = await client.wallet.bills.inquire({ document: DOC, billerCode: "EPM", reference: "123456789" });
console.log(factura.holder_name, factura.amount, factura.due_date, factura.status);
const tx = await client.wallet.bills.pay({
document: DOC,
billerCode: "EPM",
reference: "123456789",
amount: String(factura.amount), // the amount from the lookup
idempotencyKey: "nova-bill-EPM-123456789-2026-10",
});
console.log(tx.data.status, tx.data.approval_number);
const historial = await client.wallet.bills.listPayments(DOC, { kind: "BILL", limit: 20 });
const pago = await client.wallet.bills.getPayment(String(historial.results[0].id), { document: DOC });$convenios = $client->wallet->bills->billers(category: 'POWER', q: 'epm');
$factura = $client->wallet->bills->inquire(document: $doc, billerCode: 'EPM', reference: '123456789');
echo $factura['holder_name'], ' ', $factura['amount'], ' ', $factura['due_date'], PHP_EOL;
$tx = $client->wallet->bills->pay(
document: $doc,
billerCode: 'EPM',
reference: '123456789',
amount: $factura['amount'], // the amount from the lookup
idempotencyKey: 'nova-bill-EPM-123456789-2026-10',
);
echo $tx->data['status'], ' ', $tx->data['approval_number'] ?? '-', PHP_EOL;
$historial = $client->wallet->bills->listPayments($doc, kind: 'BILL', limit: 20);
$pago = $client->wallet->bills->getPayment($historial->results[0]['id'], $doc);IReadOnlyList<JsonObject> convenios = await client.Wallet.Bills.BillersAsync("POWER", "epm");
JsonObject factura = await client.Wallet.Bills.InquireAsync(Doc, billerCode: "EPM", reference: "123456789");
string valor = factura["amount"]!.GetValue<string>();
Console.WriteLine($"{factura["holder_name"]} {valor} {factura["due_date"]}");
Transaction tx = await client.Wallet.Bills.PayAsync(
Doc, "EPM", "123456789", valor, // the amount from the lookup
idempotencyKey: "nova-bill-EPM-123456789-2026-10");
Console.WriteLine($"{tx.Data["status"]} {tx.Data["approval_number"]}");
OffsetPage historial = await client.Wallet.Bills.ListPaymentsAsync(Doc, kind: "BILL", limit: 20);
JsonObject pago = await client.Wallet.Bills.GetPaymentAsync(historial.Results[0]["id"]!.GetValue<string>(), Doc);convenios, err := client.Wallet.Bills.Billers(ctx, &compense.BillersParams{Category: "POWER", Q: "epm"})
if err != nil {
log.Fatal(err)
}
fmt.Println(len(convenios), "billers")
factura, err := client.Wallet.Bills.Inquire(ctx, &compense.BillInquiryParams{
Document: doc, BillerCode: "EPM", Reference: "123456789",
})
if err != nil {
log.Fatal(err)
}
valor, _ := factura["amount"].(string)
tx, err := client.Wallet.Bills.Pay(ctx, &compense.BillPayParams{
Document: doc, BillerCode: "EPM", Reference: "123456789",
Amount: compense.Amount(valor), // the amount from the lookup
}, compense.WithIdempotencyKey("nova-bill-EPM-123456789-2026-10"))
if err != nil {
log.Fatal(err)
}
fmt.Println(tx.Data["status"], tx.Data["approval_number"])
historial, err := client.Wallet.Bills.ListPayments(ctx, doc, &compense.BillPaymentsParams{Kind: "BILL", Limit: 20})
if err != nil {
log.Fatal(err)
}
pagoID, _ := historial.Results[0]["id"].(string)
pago, err := client.Wallet.Bills.GetPayment(ctx, pagoID, doc)
if err != nil {
log.Fatal(err)
}
fmt.Println(pago["status"], pago["approval_number"])curl "https://sandbox.dev.compense.co/gw/v1/gateway/wallet/billers/?category=POWER&q=epm" \
-H "X-API-Key: $COMPENSE_API_KEY"
curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/wallet/bills/inquiry/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"document": "1000000001", "biller_code": "EPM", "reference": "123456789"}'
curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/wallet/bills/pay/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: nova-bill-EPM-123456789-2026-10" \
-H "Content-Type: application/json" \
-d '{"document": "1000000001", "biller_code": "EPM", "reference": "123456789", "amount": "90000.00"}'
curl "https://sandbox.dev.compense.co/gw/v1/gateway/wallet/bills/payments/?document=1000000001&kind=BILL&limit=20" \
-H "X-API-Key: $COMPENSE_API_KEY"Bill lookup (in the sandbox, EPM / 123456789 is worth 90,000):
{"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"}- Biller categories:
UTILITIES,TELCO,TV,WATER,POWER,GAS,TAX,INTERNET,EDUCATION,OTHER. - Instead of
biller_code+referenceyou can look up withbarcode(the bill’s GS1 code). - The amount to pay must be the one from the lookup, except for billers that allow partial
payment (
allows_partial: true), which accept any value betweenmin_amountandmax_amount. data.statusof the payment:APPROVED(HTTP201, withapproval_number) orPENDING(HTTP202: the collector hasn’t confirmed yet). APENDINGpayment is reconciled with the collector every time you fetch its detail withbills.get_payment.- History
kind:BILL(bills) orTOPUP(top-ups).
Mobile top-ups #
GET/v1/gateway/wallet/topups/operators/
returns exactly what a top-up accepts: operators, amount range,
suggested amounts and bundles. source: catalog means your entity has an aggregator catalog;
default means the country’s operators with the default range.
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"])const catalogo = await client.wallet.topups.operators();
console.log(catalogo.source, catalogo.operators);
const tx = await client.wallet.topups.create({
document: DOC,
phoneNumber: "3001234567",
carrier: "CLARO",
amount: "20000",
idempotencyKey: "nova-topup-2026-11-02-000123",
});
console.log(tx.data.status, tx.data.phone_number_masked);$catalogo = $client->wallet->topups->operators();
echo $catalogo['source'], ' ', count($catalogo['operators']), PHP_EOL;
$tx = $client->wallet->topups->create(
document: $doc,
phoneNumber: '3001234567',
carrier: 'CLARO',
amount: '20000',
idempotencyKey: 'nova-topup-2026-11-02-000123',
);
echo $tx->data['status'], ' ', $tx->data['phone_number_masked'], PHP_EOL;JsonObject catalogo = await client.Wallet.Topups.OperatorsAsync();
Console.WriteLine($"{catalogo["source"]} {catalogo["operators"]!.AsArray().Count}");
Transaction tx = await client.Wallet.Topups.CreateAsync(
Doc, "3001234567", "CLARO", "20000", idempotencyKey: "nova-topup-2026-11-02-000123");
Console.WriteLine($"{tx.Data["status"]} {tx.Data["phone_number_masked"]}");catalogo, err := client.Wallet.Topups.Operators(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println(catalogo["source"], catalogo["operators"])
tx, err := client.Wallet.Topups.Create(ctx, &compense.TopupParams{
Document: doc, PhoneNumber: "3001234567", Carrier: "CLARO", Amount: "20000",
}, compense.WithIdempotencyKey("nova-topup-2026-11-02-000123"))
if err != nil {
log.Fatal(err)
}
fmt.Println(tx.Data["status"], tx.Data["phone_number_masked"])curl https://sandbox.dev.compense.co/gw/v1/gateway/wallet/topups/operators/ \
-H "X-API-Key: $COMPENSE_API_KEY"
curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/wallet/topups/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: nova-topup-2026-11-02-000123" \
-H "Content-Type: application/json" \
-d '{"document": "1000000001", "phone_number": "3001234567", "carrier": "CLARO", "amount": "20000"}'Validations: a 10-digit Colombian mobile number starting with 3 (INVALID_PHONE), an enabled
operator (OPERATOR_NOT_FOUND), an amount within the operator’s range (INVALID_AMOUNT), plus the
holder’s balance and limits.
Accounts and pockets #
Each holder has their main wallet and can have sub-accounts: pockets (pocket), savings
(savings) or goals (goal). Creating sub-accounts and moving money between them requires the
wallet:accounts scope; listing them, 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="Vacation", 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"])const cuentas = await client.wallet.accounts.list(DOC); // === client.accounts
const principal = cuentas.find((c) => c.isPrimary || c.kind.toLowerCase() === "wallet")!;
const ahorro = await client.wallet.accounts.createPocket({
document: DOC,
name: "Vacation",
kind: "savings",
targetAmount: "500000",
targetDate: "2026-12-31",
idempotencyKey: "nova-pocket-1000000001-vacaciones",
});
const envelope = await client.wallet.accounts.transfer({
document: DOC,
fromAccount: principal.id,
toAccount: ahorro.id,
amount: "20000",
idempotencyKey: "nova-acc-tx-2026-11-02-0001",
});
console.log(envelope.transaction_id, envelope.data);$cuentas = $client->wallet->accounts->list($doc); // === $client->accounts
$principal = $cuentas[0];
$ahorro = $client->wallet->accounts->createPocket(
document: $doc,
name: 'Vacation',
kind: 'savings',
targetAmount: '500000',
targetDate: '2026-12-31',
idempotencyKey: 'nova-pocket-1000000001-vacaciones',
);
$envelope = $client->wallet->accounts->transfer(
document: $doc,
fromAccount: $principal->id,
toAccount: $ahorro->id,
amount: '20000',
idempotencyKey: 'nova-acc-tx-2026-11-02-0001',
);
echo $envelope['transaction_id'], PHP_EOL;IReadOnlyList<Account> cuentas = await client.Wallet.Accounts.ListAsync(Doc); // == client.Accounts
Account principal = cuentas.First(c => c.IsPrimary || c.Kind.Equals("wallet", StringComparison.OrdinalIgnoreCase));
Account ahorro = await client.Wallet.Accounts.CreatePocketAsync(
Doc, name: "Vacation", kind: "savings", targetAmount: "500000", targetDate: "2026-12-31",
idempotencyKey: "nova-pocket-1000000001-vacaciones");
JsonObject envelope = await client.Wallet.Accounts.TransferAsync(
Doc, principal.Id, ahorro.Id, "20000", idempotencyKey: "nova-acc-tx-2026-11-02-0001");
Console.WriteLine(envelope["transaction_id"]);cuentas, err := client.Wallet.Accounts.List(ctx, doc) // == client.Accounts
if err != nil {
log.Fatal(err)
}
principal := cuentas[0]
ahorro, err := client.Wallet.Accounts.CreatePocket(ctx, &compense.CreatePocketParams{
Document: doc, Name: "Vacation", Kind: "savings",
TargetAmount: "500000", TargetDate: "2026-12-31",
}, compense.WithIdempotencyKey("nova-pocket-1000000001-vacaciones"))
if err != nil {
log.Fatal(err)
}
envelope, err := client.Wallet.Accounts.Transfer(ctx, &compense.AccountTransferParams{
Document: doc, FromAccount: principal.ID, ToAccount: ahorro.ID, Amount: "20000",
}, compense.WithIdempotencyKey("nova-acc-tx-2026-11-02-0001"))
if err != nil {
log.Fatal(err)
}
fmt.Println(envelope["transaction_id"])curl "https://sandbox.dev.compense.co/gw/v1/gateway/wallet/accounts/?document=1000000001" \
-H "X-API-Key: $COMPENSE_API_KEY"
curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/wallet/accounts/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: nova-pocket-1000000001-vacaciones" \
-H "Content-Type: application/json" \
-d '{"document": "1000000001", "kind": "savings", "name": "Vacation"}'
curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/wallet/accounts/transfer/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: nova-acc-tx-2026-11-02-0001" \
-H "Content-Type: application/json" \
-d '{"document": "1000000001", "from_account": "'"$CUENTA_PRINCIPAL"'", "to_account": "'"$BOLSILLO"'", "amount": "20000"}'kindis lowercase (pocket,savings,goal); the SDKs normalize it.- The SDKs send
target_amountandtarget_date(savings goal) when you pass them; the contract accepts them as additional fields. accounts.transferreturns the gateway envelope as is (a dictionary/object), not a typedTransaction.
Cards #
Cards are debit cards, virtual or physical. The gateway only exposes non-sensitive metadata
(last_four, brand, status): the PAN and CVV never leave the processor.
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 is terminal: a blocked card can’t be reactivated; issue a new one.
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)const tarjeta = await client.wallet.cards.issue({
document: DOC,
formFactor: "virtual",
idempotencyKey: "nova-card-1000000001-v1",
});
console.log(tarjeta.id, tarjeta.lastFour, tarjeta.status);
await client.wallet.cards.setStatus(tarjeta.id, { document: DOC, status: "freeze" });
await client.wallet.cards.setStatus(tarjeta.id, { document: DOC, status: "unfreeze" });
const tarjetas = await client.wallet.cards.list(DOC);
const movs = await client.wallet.cards.transactions(tarjeta.id, { document: DOC });$tarjeta = $client->wallet->cards->issue(
document: $doc, formFactor: 'virtual', idempotencyKey: 'nova-card-1000000001-v1',
);
echo $tarjeta->id, ' ', $tarjeta->lastFour, ' ', $tarjeta->status, PHP_EOL;
$client->wallet->cards->setStatus($tarjeta->id, document: $doc, status: 'freeze');
$client->wallet->cards->setStatus($tarjeta->id, document: $doc, status: 'unfreeze');
$tarjetas = $client->wallet->cards->list($doc);
$movs = $client->wallet->cards->transactions($tarjeta->id, $doc);Card tarjeta = await client.Wallet.Cards.IssueAsync(Doc, "virtual", idempotencyKey: "nova-card-1000000001-v1");
Console.WriteLine($"{tarjeta.Id} {tarjeta.LastFour} {tarjeta.Status}");
await client.Wallet.Cards.SetStatusAsync(tarjeta.Id, Doc, "freeze");
await client.Wallet.Cards.SetStatusAsync(tarjeta.Id, Doc, "unfreeze");
IReadOnlyList<Card> tarjetas = await client.Wallet.Cards.ListAsync(Doc);
JsonObject movs = await client.Wallet.Cards.TransactionsAsync(tarjeta.Id, Doc);tarjeta, err := client.Wallet.Cards.Issue(ctx, &compense.IssueCardParams{
Document: doc, FormFactor: "virtual",
}, compense.WithIdempotencyKey("nova-card-1000000001-v1"))
if err != nil {
log.Fatal(err)
}
fmt.Println(tarjeta.ID, tarjeta.LastFour, tarjeta.Status)
if _, err := client.Wallet.Cards.SetStatus(ctx, tarjeta.ID, &compense.CardStatusParams{
Document: doc, Status: "freeze",
}); err != nil {
log.Fatal(err)
}
movs, err := client.Wallet.Cards.Transactions(ctx, tarjeta.ID, doc)
if err != nil {
log.Fatal(err)
}
fmt.Println(movs["results"])curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/wallet/cards/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: nova-card-1000000001-v1" \
-H "Content-Type: application/json" \
-d '{"document": "1000000001", "form_factor": "virtual"}'
curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/wallet/cards/$CARD_ID/status/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"document": "1000000001", "status": "freeze"}'
curl "https://sandbox.dev.compense.co/gw/v1/gateway/wallet/cards/$CARD_ID/transactions/?document=1000000001" \
-H "X-API-Key: $COMPENSE_API_KEY"form_factor is virtual (default) or physical; status is freeze, unfreeze or block.
The SDKs send them in lowercase even if you write them in uppercase. Issuing a card emits the
wallets.card.issued event.
Currency exchange (FX) #
First you quote (a pure read: it doesn’t move money and isn’t billed) and then you execute the
conversion, passing the quote’s quote_id. The rate is indicative: the engine resolves it again at
execution time.
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",
)const cotizacion = await client.wallet.fx.quote({
document: DOC,
sellCurrency: "COP",
buyCurrency: "USD",
amount: "1000000",
});
console.log(cotizacion.rate, cotizacion.converted_amount);
const envelope = await client.wallet.fx.convert({
document: DOC,
sellCurrency: "COP",
buyCurrency: "USD",
amount: "1000000",
quoteId: cotizacion.quote_id as string | undefined,
idempotencyKey: "nova-fx-2026-11-02-0007",
});$cotizacion = $client->wallet->fx->quote(
amount: '1000000', sellCurrency: 'COP', buyCurrency: 'USD', document: $doc,
);
echo $cotizacion['rate'], ' ', $cotizacion['converted_amount'], PHP_EOL;
$envelope = $client->wallet->fx->convert(
amount: '1000000',
sellCurrency: 'COP',
buyCurrency: 'USD',
document: $doc,
quoteId: $cotizacion['quote_id'] ?? null,
idempotencyKey: 'nova-fx-2026-11-02-0007',
);JsonObject cotizacion = await client.Wallet.Fx.QuoteAsync(
amount: "1000000", sellCurrency: "COP", buyCurrency: "USD", document: Doc);
Console.WriteLine($"{cotizacion["rate"]} {cotizacion["converted_amount"]}");
JsonObject envelope = await client.Wallet.Fx.ConvertAsync(
amount: "1000000", sellCurrency: "COP", buyCurrency: "USD", document: Doc,
quoteId: cotizacion["quote_id"]?.GetValue<string>(),
idempotencyKey: "nova-fx-2026-11-02-0007");cotizacion, err := client.Wallet.FX.Quote(ctx, &compense.FXQuoteParams{
Document: doc, SellCurrency: "COP", BuyCurrency: "USD", Amount: "1000000",
})
if err != nil {
log.Fatal(err)
}
quoteID, _ := cotizacion["quote_id"].(string)
envelope, err := client.Wallet.FX.Convert(ctx, &compense.FXConvertParams{
Document: doc, SellCurrency: "COP", BuyCurrency: "USD", Amount: "1000000", QuoteID: quoteID,
}, compense.WithIdempotencyKey("nova-fx-2026-11-02-0007"))
if err != nil {
log.Fatal(err)
}
fmt.Println(envelope["transaction_id"])curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/wallet/fx/quote/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"document": "1000000001", "sell_currency": "COP", "buy_currency": "USD", "amount": "1000000"}'
curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/wallet/fx/convert/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: nova-fx-2026-11-02-0007" \
-H "Content-Type: application/json" \
-d '{"document": "1000000001", "sell_currency": "COP", "buy_currency": "USD", "amount": "1000000", "quote_id": "'"$QUOTE_ID"'"}'The contract names are sell_currency and buy_currency (the SDKs send them in uppercase).
fx.convert returns the gateway envelope as is. A currency pair without a published rate returns
422.
Business errors #
Errors arrive as application/problem+json (see Errors). When the engine
rejects a request, detail carries the reason the engine gave—its message in Spanish, for
example «Esta factura ya está pagada.» (“This bill is already paid.")—and, if the engine sends no
message, the code:
The request was rejected by the processing engine (code: BILL_ALREADY_PAID). The “Engine code”
column is the engine code behind each case: use it to recognize the case in logs and when talking to
support (always quote the trace_id).
| HTTP | Engine code | When | What to do |
|---|---|---|---|
400 | — | Idempotency-Key missing on P2P, Bre-B, bill or top-up | Send the header |
400 | RESOLUTION_REQUIRED / RESOLUTION_INVALID | Send without a token, or with a token from another flow (Bre-B on P2P or vice versa) | Resolve again with the correct operation |
400 | INVALID_PHONE / INVALID_AMOUNT | Top-up phone number or amount breaks the rules | Fix the value |
404 | MEMBER_NOT_FOUND | The document isn’t an active affiliate of your entity | Sync the affiliate |
404 | BILLER_NOT_FOUND / OPERATOR_NOT_FOUND | Biller or operator not enabled | List the catalog again |
409 | BILL_ALREADY_PAID | The bill (or that period) is already paid | Show the existing payment; don’t retry |
409 | — | The same Idempotency-Key is still in progress | Wait and retry with the same key |
422 | INSUFFICIENT_FUNDS | Insufficient available balance | Ask for a top-up or a smaller amount |
422 | RESOLUTION_EXPIRED | More than 300 s since resolve | Resolve again and confirm again |
422 | SELF_TRANSFER | The recipient is the same holder | — |
422 | AMOUNT_MISMATCH | You’re paying an amount different from the bill’s | Use the amount from the lookup |
422 | TRANSFER_REJECTED / PAYMENT_REJECTED | The destination bank or the collector rejected it; no money was debited | Inform the user |
422 | — | Bre-B limit exceeded, Bre-B not enabled | Check breb.limits |
502 / 504 | — | The engine failed or didn’t respond | Retry with the same Idempotency-Key |
A 409 isn’t always an idempotency conflict. The SDKs raise the same exception for both; tell them
apart by the problem’s type: …/idempotency-conflict when it’s your key still in progress and
…/upstream-error when the engine rejected it on a business rule.
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: balance, limits, expired token…
print("Rejected:", e.detail, e.trace_id)
except IdempotencyConflictError as e: # 409
if e.problem.type.endswith("/idempotency-conflict"):
print("The same key is still in progress: retry later with the SAME key")
else:
print("Business conflict:", e.detail)
except NotFoundError as e: # 404: holder doesn't exist
print("Not found:", e.trace_id)
except CompenseAPIError as e: # 400 and the rest
print(e.status, e.detail, e.trace_id)import { CompenseAPIError, IdempotencyConflictError, NotFoundError, ValidationError } from "@compense/sdk";
try {
await client.wallet.p2p.transfer({
document: DOC,
resolutionToken: r.resolutionToken,
amount: "30000",
idempotencyKey: "nova-p2p-2026-11-02-000123",
});
} catch (err) {
if (err instanceof ValidationError) console.log("Rejected:", err.detail, err.traceId);
else if (err instanceof IdempotencyConflictError) {
if (err.problem.type.endsWith("/idempotency-conflict")) console.log("Key in progress: retry with the same one");
else console.log("Business conflict:", err.detail);
} else if (err instanceof NotFoundError) console.log("Not found:", err.traceId);
else if (err instanceof CompenseAPIError) console.log(err.status, err.detail, err.traceId);
else throw err;
}use Compense\Sdk\Exception\CompenseApiException;
use Compense\Sdk\Exception\IdempotencyConflictException;
use Compense\Sdk\Exception\NotFoundException;
use Compense\Sdk\Exception\ValidationException;
try {
$client->wallet->p2p->transfer(
document: $doc, resolutionToken: $r->resolutionToken, amount: '30000',
idempotencyKey: 'nova-p2p-2026-11-02-000123',
);
} catch (ValidationException $e) { // 422
echo 'Rejected: ', $e->detail, ' ', $e->traceId, PHP_EOL;
} catch (IdempotencyConflictException $e) { // 409
if (str_ends_with($e->problem->type, '/idempotency-conflict')) {
echo "The same key is still in progress: retry with the SAME key\n";
} else {
echo 'Business conflict: ', $e->detail, PHP_EOL;
}
} catch (NotFoundException $e) {
echo 'Not found: ', $e->traceId, PHP_EOL;
} catch (CompenseApiException $e) {
echo $e->status, ' ', $e->detail, ' ', $e->traceId, PHP_EOL;
}try
{
await client.Wallet.P2P.TransferAsync(Doc, r.ResolutionToken, "30000",
idempotencyKey: "nova-p2p-2026-11-02-000123");
}
catch (ValidationException e) // 422
{
Console.WriteLine($"Rejected: {e.Detail} {e.TraceId}");
}
catch (IdempotencyConflictException e) // 409
{
if (e.Problem.Type.EndsWith("/idempotency-conflict", StringComparison.Ordinal))
Console.WriteLine("The same key is still in progress: retry with the SAME key");
else
Console.WriteLine($"Business conflict: {e.Detail}");
}
catch (NotFoundException e)
{
Console.WriteLine($"Not found: {e.TraceId}");
}
catch (CompenseApiException e)
{
Console.WriteLine($"{e.Status} {e.Detail} {e.TraceId}");
}_, err = client.Wallet.P2P.Transfer(ctx, &compense.WalletTransferParams{
Document: doc, ResolutionToken: r.ResolutionToken, Amount: "30000",
}, compense.WithIdempotencyKey("nova-p2p-2026-11-02-000123"))
var apiErr *compense.APIError
if errors.As(err, &apiErr) {
switch {
case compense.IsValidationError(err): // 422
fmt.Println("Rejected:", apiErr.Detail, apiErr.TraceID)
case compense.IsIdempotencyConflict(err): // 409
if strings.HasSuffix(apiErr.Problem.Type, "/idempotency-conflict") {
fmt.Println("The same key is still in progress: retry with the SAME key")
} else {
fmt.Println("Business conflict:", apiErr.Detail)
}
case compense.IsNotFound(err):
fmt.Println("Not found:", apiErr.TraceID)
default:
fmt.Println(apiErr.StatusCode, apiErr.Detail, apiErr.TraceID)
}
}# Typical problem for a business rejection
# HTTP/1.1 422
# {"type": "https://errors.compense.co/gateway/upstream-error",
# "title": "Request rejected by engine", "status": 422,
# "detail": "Saldo insuficiente: pide 600000.00 COP, disponible 500000.00 COP.",
# "trace_id": "9f1c…"}Events #
Wallet events are emitted in the same transaction as the money movement: if the money didn’t
move, there’s no success event, and a retry with the same Idempotency-Key doesn’t duplicate it.
Subscribe to them as explained in Subscription and verify the signature as
described in Verification.
Wallet #
| Event | Since | When it's emitted |
|---|---|---|
wallets.cashin.initiated | 2026-07-21 | A cash-in intent was recorded; no money has moved yet. |
wallets.cashin.confirmed | 2026-07-21 | The source confirmed the cash-in and e-money was issued to the wallet. |
wallets.transfer.created | 2026-07-21 | A transfer (P2P, Bre-B or between pockets) was created and posted to the ledger. |
wallets.transfer.settled | 2026-07-21 | The transfer was settled. |
wallets.transfer.failed | 2026-07-21 | The rail rejected the transfer; no money moved. |
wallets.disbursement.credited | 2026-07-21 | A subsidy or credit was paid directly into the wallet. |
wallets.withdrawal.authorized | 2026-07-21 | An ATM withdrawal was authorized (single-use token and funds on hold). |
wallets.bill_payment.completed | 2026-07-21 | The holder paid a utility bill and the biller accepted it. |
wallets.qr_payment.completed | 2026-07-21 | The holder paid an interoperable QR code (EMVCo). |
wallets.transaction.reversed | 2026-07-21 | A transaction was reversed (refund). |
wallets.dispute.opened | 2026-07-21 | The holder opened a dispute on a transaction. |
wallets.dispute.resolved | 2026-07-21 | An operator resolved the dispute (in the holder’s favor, with a reversal, or rejected). |
wallets.card.issued | 2026-07-21 | A card was issued (only the last 4 digits are sent). |
wallets.topup.completed | 2026-10-27 | A mobile top-up was posted (carrier, masked phone number, amount). |
resource fields of the events this guide triggers:
| Event | resource fields |
|---|---|
wallets.transfer.created | transaction_id, source_wallet_id, recipient_wallet_id (null if it goes to another bank), amount, currency, rail_reference, is_internal, status; Bre-B adds rail: "breb" |
wallets.transfer.failed | transaction_id, source_wallet_id, amount, currency, rail_reference, reason, status |
wallets.transfer.settled | The in-flight transfer has settled (see the catalog) |
wallets.bill_payment.completed | transaction_id, wallet_id, service_id, amount, currency, provider, provider_reference, status |
wallets.topup.completed | transaction_id, wallet_id, carrier, phone_number_masked, amount, currency, status |
{"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"}}Reconcile by resource.transaction_id, which is the same tx.data["transaction_id"] the write
returned to you.
Sandbox #
With a sandbox key nothing reaches the real engine: the gateway returns deterministic data
(same request, same response) with "environment": "sandbox". The full table is in
Test data.
| Holder document | Profile | Wallet behavior |
|---|---|---|
1000000001 | Eligible | Balance 500,000; everything works |
1000000002 | Ineligible | Balance 0 → INSUFFICIENT_FUNDS on any send; can’t issue a card |
1000000004 | With debts | Balance 100 → INSUFFICIENT_FUNDS |
9999999999 | Doesn’t exist | 404 MEMBER_NOT_FOUND |
| any other | Eligible | Same as 1000000001 |
Test cases:
- Alias or key
9999999999→{"found": false}. - P2P with an
aliasequal to the payer’sdocument→422 SELF_TRANSFER. - Send with a token from another flow (Bre-B on P2P or vice versa) →
400 RESOLUTION_INVALID. - Bre-B for
11111→422 TRANSFER_REJECTED(rejection by the destination bank). - Bill with a reference ending in
0→409 BILL_ALREADY_PAID; unknown biller →404; paying an amount different from the lookup →422 AMOUNT_MISMATCH. Each bill’s amount is fixed per biller and reference (between 50,000 and 95,000): look it up first. - Simulated billers:
EPM,ACUEDUCTO,VANTI,ETB. Operators:CLARO,MOVISTAR,TIGO,WOM(1,000–200,000). - Transactions: three fixed ones per holder (October 1, 5 and 10, 2026), paginated and filterable by
date; their
idworks in the detail endpoint.
Magic amounts: settle, fail or return #
A real transfer can stay PENDING and end up settled, failed or returned days later. To test those
outcomes without waiting, the transfer simulator advances one status per
call (PENDING → PROCESSING → …) and the amount decides the ending:
| Amount | Outcome |
|---|---|
22.22 | FAILED (doesn’t settle; reason_code: TRANSFER_FAILED) |
33.33 | RETURNED (settles and is then returned; reason_code: TRANSFER_RETURNED) |
11.11 or any other | SETTLED |
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 → FAILEDlet status = "PENDING";
for (;;) {
const paso = await client.sandbox.simulate.walletTransfer({
status,
amount: "22.22",
transactionId: "wtx-E-3f1c9a0b2d4e",
});
console.log(paso.previous_status, "→", paso.status);
if (paso.terminal) break;
status = String(paso.status);
}$status = 'PENDING';
do {
$paso = $client->sandbox->simulate->walletTransfer(
status: $status, amount: '22.22', transactionId: 'wtx-E-3f1c9a0b2d4e',
);
echo $paso['previous_status'], ' → ', $paso['status'], PHP_EOL;
$status = $paso['status'];
} while (!$paso['terminal']);string status = "PENDING";
while (true)
{
JsonObject paso = await client.Sandbox.Simulate.WalletTransferAsync(status, "22.22", "wtx-E-3f1c9a0b2d4e");
Console.WriteLine($"{paso["previous_status"]} → {paso["status"]}");
if (paso["terminal"]!.GetValue<bool>()) break;
status = paso["status"]!.GetValue<string>();
}status := "PENDING"
for {
paso, err := client.Sandbox.Simulate.WalletTransfer(ctx, &compense.SimulationState{
Status: status, Amount: "22.22", TransactionID: "wtx-E-3f1c9a0b2d4e",
})
if err != nil {
log.Fatal(err)
}
fmt.Println(paso["previous_status"], "→", paso["status"])
if terminal, _ := paso["terminal"].(bool); terminal {
break
}
status, _ = 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": "PENDING", "amount": "22.22", "transaction_id": "wtx-E-3f1c9a0b2d4e"}'
# {"object": "wallet_transfer", "previous_status": "PENDING", "status": "PROCESSING", "terminal": false, ...}The simulator is a pure transformation: it doesn’t move balances or emit webhooks. Use it in your
tests to walk through every branch of your reconciliation (wallets.transfer.settled and
wallets.transfer.failed).
Accounts, cards and FX in the sandbox
The simulated responses for accounts/pockets, cards and FX come from the previous wallet surface:
amounts are simulated in DOP and their shape doesn’t fully match the contract yet (the account and
card lists arrive in results, the card status change expects ACTIVE/FROZEN/BLOCKED, and the
quote ignores sell_currency/buy_currency). Use them to test the flow and error handling, not to
validate values; balance, transactions, P2P, Bre-B, bills and top-ups do follow the contract.