Guía de implementación
Billetera
Opera la billetera de tus afiliados server-to-server: saldo y movimientos, transferencias P2P y Bre-B, pago de facturas, recargas, bolsillos, tarjetas y cambio de divisas, con idempotencia, errores de negocio y eventos wallets.*.
En esta página
Esta guía es para el equipo técnico de una fintech (como Nova) o de una caja (como Caja
Horizonte) que opera por API la billetera de sus afiliados, con las mismas reglas que la app:
saldo y movimientos, transferencias P2P a otro afiliado, Bre-B a otros bancos, pago de facturas de
servicios, recargas de celular, sub-cuentas (bolsillos, ahorros, metas), tarjetas y cambio de divisas.
Todo vive bajo la etiqueta wallet del contrato y, en los SDK, bajo client.wallet.
Antes de seguir, ten a mano tu llave de sandbox y repasa Idempotencia y Montos: aquí casi todo mueve dinero.
Antes de empezar #
El titular se nombra por documento #
No hay identificador de afiliado en las rutas: la llave ya identifica a tu entidad y el titular se
nombra siempre por document (su número de documento). En los GET va como query
(?document=1000000001) y en los POST dentro del cuerpo JSON. Un documento que no existe en tu
entidad responde 404.
Scopes #
| Scope | Qué permite |
|---|---|
wallet:read | Saldo, movimientos y su detalle, límites Bre-B, facturadores, historial de pagos, operadores de recarga, cuentas, tarjetas y sus movimientos |
wallet:transfer | P2P (resolver y enviar) y Bre-B (resolver y enviar) |
wallet:payments | Consultar y pagar facturas de servicios |
wallet:topup | Recargar un celular |
wallet:accounts | Crear bolsillos/ahorros/metas y transferir entre cuentas propias |
wallet:cards | Emitir tarjetas y cambiar su estado (congelar, descongelar, bloquear) |
wallet:fx | Cotizar y ejecutar cambio de divisas |
Pide solo los que tu integración necesita. wallet:transfer, wallet:payments y wallet:topup
mueven dinero y están en la lista de scopes que exigirán firma HMAC de la petición cuando se active
esa protección en tu llave (hoy inactiva; se avisará antes). Si tu entidad no tiene contratado el
módulo, toda ruta responde 403 con «El módulo de billetera no está contratado para esta
entidad.».
Montos e idempotencia #
- Los montos viajan como texto decimal (
"30000","65000.00"), en pesos colombianos. Los SDK aceptan números o texto y los convierten; ver Montos. - P2P, Bre-B, pagar factura y recarga exigen
Idempotency-Key: sin ella el gateway responde400. Los SDK siempre envían una (generan un UUID si no pasas la tuya), pero si tu sistema puede reenviar la operación, pásale una estable por operación de negocio (por ejemplonova-p2p-2026-11-02-000123). Repetir la misma llave devuelve el resultado original conidempotent_replay: truey no mueve dinero dos veces. - Las lecturas por
POST(p2p.resolve,breb.resolve,bills.inquire,fx.quote) llevan el dato personal en el cuerpo para que no quede en URLs ni logs; no mueven dinero, no llevan llave y los SDK nunca las reintentan.
client.wallet.accounts es client.accounts
client.wallet.accounts, client.wallet.cards y client.wallet.fx son los mismos objetos que
client.accounts, client.cards y client.fx (en .NET client.Wallet.Accounts y
client.Accounts; en Go client.Wallet.Accounts y client.Accounts). Usa el camino que prefieras:
llaman exactamente a las mismas operaciones.
Mapa de operaciones #
Saldo #
wallet.balance devuelve un Balance tipado. available es lo que el titular puede gastar (el
available_cash del motor, neto de retenciones); total incluye lo retenido y lo pendiente.
import os
from compense_gateway import CompenseClient
client = CompenseClient(api_key=os.environ["COMPENSE_API_KEY"], environment="sandbox")
DOC = "1000000001" # titular de prueba: perfil elegible, saldo 500.000
saldo = client.wallet.balance(DOC)
print(saldo.available.amount, saldo.total.amount, saldo.currency)
if saldo.pending is not None:
print("En tránsito:", saldo.pending.amount)import { CompenseClient } from "@compense/sdk";
const client = new CompenseClient({ apiKey: process.env.COMPENSE_API_KEY!, environment: "sandbox" });
const DOC = "1000000001"; // titular de prueba: perfil elegible, saldo 500.000
const saldo = await client.wallet.balance(DOC);
console.log(saldo.available.amount, saldo.total.amount, saldo.currency);
if (saldo.pending) console.log("En tránsito:", 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'; // titular de prueba: perfil elegible, saldo 500.000
$saldo = $client->wallet->balance($doc);
echo $saldo->available->amount, ' ', $saldo->total->amount, ' ', $saldo->currency, PHP_EOL;
if ($saldo->pending !== null) {
echo 'En tránsito: ', $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"; // titular de prueba: perfil elegible, saldo 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($"En tránsito: {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" // titular de prueba: perfil elegible, saldo 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("En tránsito:", 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"}| Campo del contrato | En el SDK | Qué es |
|---|---|---|
total | total | Saldo total del titular |
available_cash | available | Disponible para gastar (neto de retenciones) |
restricted_amount | restricted | Retenido (opcional; null si el motor no lo envía) |
pending_amount | pending | En tránsito (opcional) |
currency | currency | Moneda de la entidad (COP en Colombia) |
Los demás ejemplos de esta página reutilizan client y el documento 1000000001; en Go asumen
ctx, client y doc del bloque anterior.
Movimientos #
GET/v1/gateway/wallet/transactions/
pagina con limit/offset y devuelve un OffsetPage:
results, limit, offset, has_more y next_offset (detalles en
Paginación). Filtros: start_date y end_date (AAAA-MM-DD o ISO 8601;
una fecha sola en end_date incluye todo ese día), category (TRANSFER, PAYMENT…), type (tipo
del motor: TRANSFER_OUT, TOPUP…) y limit de 1 a 100 (50 por defecto). Los movimientos llegan
del más reciente al más antiguo.
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` recorre todas las páginas siguiendo 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;
// Recorre todas las páginas siguiendo 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"]}");
}
// O una sola página:
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}El detalle solo muestra movimientos del titular indicado: el id de un movimiento de otro
afiliado responde 404, aunque exista.
Transferencia P2P a otro afiliado #
Una transferencia P2P mueve dinero entre dos afiliados de tu misma entidad. Son dos pasos, como en la app: resolver (saber a quién le vas a pagar) y enviar.
La entidad nunca envía un id de destinatario suelto. resolve busca el alias dentro del motor y
devuelve el nombre enmascarado y un resolution_token firmado, de vida corta (300 s), atado a
tu entidad, a la billetera que paga y a la que recibe. El envío solo acepta ese token: no se puede
confirmar a una persona y pagarle a otra, ni reutilizar el token con otro titular.
sequenceDiagram
autonumber
participant U as Usuario (app de Nova)
participant N as Backend de Nova
participant G as Gateway Compense
U->>N: Enviar $30.000 al 300 123 4567
N->>G: POST wallet/p2p/resolve/ {document, alias}
G-->>N: found, display_name "Sa*** Re***", resolution_token (300 s)
N->>U: ¿Confirmas el envío a Sa*** Re***?
U->>N: Sí
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("Nadie tiene ese alias en la entidad")
if r.is_local is False:
raise SystemExit("La llave es de otra entidad: usa Bre-B")
# Muestra r.display_name al usuario y pide confirmación antes de r.expires_in segundos
tx = client.wallet.p2p.transfer(
document=DOC,
resolution_token=r.resolution_token,
amount="30000",
description="Arriendo",
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("Nadie tiene ese alias en la entidad");
if (r.isLocal === false) throw new Error("La llave es de otra entidad: usa Bre-B");
// Muestra r.displayName al usuario y pide confirmación antes de r.expiresIn segundos
const tx = await client.wallet.p2p.transfer({
document: DOC,
resolutionToken: r.resolutionToken,
amount: "30000",
description: "Arriendo",
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("Nadie tiene ese alias en la entidad\n");
}
if ($r->isLocal === false) {
exit("La llave es de otra entidad: usa Bre-B\n");
}
// Muestra $r->displayName al usuario y pide confirmación antes de $r->expiresIn segundos
$tx = $client->wallet->p2p->transfer(
document: $doc,
resolutionToken: $r->resolutionToken,
amount: '30000',
description: 'Arriendo',
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("Nadie tiene ese alias en la entidad");
if (r.IsLocal == false) throw new InvalidOperationException("La llave es de otra entidad: usa Bre-B");
// Muestra r.DisplayName al usuario y pide confirmación antes de r.ExpiresIn segundos
Transaction tx = await client.Wallet.P2P.TransferAsync(
Doc, r.ResolutionToken, "30000", description: "Arriendo",
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("nadie tiene ese alias en la entidad")
}
if r.IsLocal != nil && !*r.IsLocal {
log.Fatal("la llave es de otra entidad: usa Bre-B")
}
// Muestra r.DisplayName al usuario y pide confirmación antes de r.ExpiresIn segundos
tx, err := client.Wallet.P2P.Transfer(ctx, &compense.WalletTransferParams{
Document: doc,
ResolutionToken: r.ResolutionToken,
Amount: "30000",
Description: "Arriendo",
}, 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": "Arriendo"}'Respuesta de resolve y de 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***"}}aliasacepta celular, correo, documento o @alias;key_type(PHONE,EMAIL,DOCUMENT,ALIAS) es opcional y desambigua una cadena de dígitos.found: false: nadie tiene ese alias.is_local: false: la llave es de otra entidad, usa Bre-B.tx.transaction_ides el id del gateway (para Transacciones y soporte);tx.data["transaction_id"]es el movimiento en la billetera (sirve enwallet.transactions.get).- Si el motor de riesgo retiene la transferencia, la respuesta trae
review_status: EN_REVISION.
Bre-B a otro banco #
Bre-B son pagos inmediatos 24/7 a cuentas de otros bancos usando una llave (celular, documento,
correo o alias). El flujo es el mismo de P2P: resolver la llave y enviar con el token. Tu entidad
debe tener Bre-B habilitado; si no, el envío responde 422 con «Esta entidad aún no tiene
habilitadas las transferencias Bre-B.».
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:
# Muestra al usuario r.display_name, r.institution_name y r.key_display
tx = client.wallet.breb.transfer(
document=DOC, resolution_token=r.resolution_token, amount="25000",
description="Pago", idempotency_key="nova-breb-2026-11-02-000045",
)
if tx.data["status"] == "PENDING":
print("El banco destino aún no confirma: espera 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) {
// Muestra al usuario r.displayName, r.institutionName y r.keyDisplay
const tx = await client.wallet.breb.transfer({
document: DOC,
resolutionToken: r.resolutionToken,
amount: "25000",
description: "Pago",
idempotencyKey: "nova-breb-2026-11-02-000045",
});
if (tx.data.status === "PENDING") console.log("El banco destino aún no confirma");
}$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) {
// Muestra al usuario $r->displayName, $r->institutionName y $r->keyDisplay
$tx = $client->wallet->breb->transfer(
document: $doc,
resolutionToken: $r->resolutionToken,
amount: '25000',
description: 'Pago',
idempotencyKey: 'nova-breb-2026-11-02-000045',
);
if ($tx->data['status'] === 'PENDING') {
echo "El banco destino aún no confirma\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)
{
// Muestra al usuario r.DisplayName, r.InstitutionName y r.KeyDisplay
Transaction tx = await client.Wallet.BreB.TransferAsync(
Doc, r.ResolutionToken, "25000", description: "Pago",
idempotencyKey: "nova-breb-2026-11-02-000045");
if (tx.Data["status"]?.GetValue<string>() == "PENDING")
Console.WriteLine("El banco destino aún no confirma");
}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 {
// Muestra al usuario r.DisplayName, r.InstitutionName y r.KeyDisplay
tx, err := client.Wallet.BreB.Transfer(ctx, &compense.WalletTransferParams{
Document: doc, ResolutionToken: r.ResolutionToken, Amount: "25000", Description: "Pago",
}, compense.WithIdempotencyKey("nova-breb-2026-11-02-000045"))
if err != nil {
log.Fatal(err)
}
if tx.Data["status"] == "PENDING" {
fmt.Println("El banco destino aún no confirma")
}
}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": "Pago"}'{"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,EMAILoALIAS.- Si la llave resulta ser de un afiliado de tu misma entidad (
is_local: true), el envío se hace como transferencia interna con el mismo token. data.statusdel envío:COMPLETED(liquidado) oPENDING(el banco destino aún no confirma; llega por evento o consulta). Un rechazo del banco destino es422con el motivo y no descuenta dinero.
Los límites dependen del nivel del titular:
| Campo | Qué es |
|---|---|
tier | Nivel del titular (TIER_1…) |
max_per_transaction | Tope por envío |
max_daily_amount / max_monthly_amount | Topes por día y por mes |
used_today / used_month | Lo ya usado |
available_today | Lo que aún puede enviar hoy |
Consulta los límites antes de ofrecer el envío: un monto por encima responde 422.
Facturas de servicios #
El pago de facturas tiene tres momentos: elegir el facturador (convenio), consultar la factura para conocer el valor exacto y pagarla. Para un flujo completo, con confirmación del usuario, reintentos y comprobante, sigue la receta Cobrar una factura desde la billetera.
convenios = client.wallet.bills.billers(category="POWER", q="epm") # no necesita 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"], # el valor de la consulta
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), // el valor de la consulta
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'], // el valor de la consulta
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, // el valor de la consulta
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), "convenios")
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), // el valor de la consulta
}, 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"Consulta de la factura (en el sandbox, EPM / 123456789 vale 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"}- Categorías de facturador:
UTILITIES,TELCO,TV,WATER,POWER,GAS,TAX,INTERNET,EDUCATION,OTHER. - En lugar de
biller_code+referencepuedes consultar conbarcode(código GS1 de la factura). - El monto a pagar debe ser el de la consulta, salvo convenios con pago parcial
(
allows_partial: true), que aceptan cualquier valor entremin_amountymax_amount. data.statusdel pago:APPROVED(HTTP201, conapproval_number) oPENDING(HTTP202: el recaudador aún no confirma). Un pagoPENDINGse concilia con el recaudador cada vez que consultas su detalle conbills.get_payment.kinddel historial:BILL(facturas) oTOPUP(recargas).
Recargas de celular #
GET/v1/gateway/wallet/topups/operators/
devuelve exactamente lo que la recarga acepta: operadores, rango de
montos, montos sugeridos y paquetes. source: catalog indica que tu entidad tiene catálogo del
agregador; default, los operadores del país con el rango por defecto.
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"}'Validaciones: celular colombiano de 10 dígitos que empieza por 3 (INVALID_PHONE), operador
habilitado (OPERATOR_NOT_FOUND), monto dentro del rango del operador (INVALID_AMOUNT), saldo y
límites del titular.
Cuentas y bolsillos #
Cada titular tiene su billetera principal y puede tener sub-cuentas: bolsillos (pocket),
ahorros (savings) o metas (goal). Crear sub-cuentas y mover dinero entre ellas pide el scope
wallet:accounts; listarlas, 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="Vacaciones", 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: "Vacaciones",
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: 'Vacaciones',
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: "Vacaciones", 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: "Vacaciones", 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": "Vacaciones"}'
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"}'kindva en minúsculas (pocket,savings,goal); los SDK lo normalizan.target_amountytarget_date(meta de ahorro) los envían los SDK cuando los pasas; el contrato los admite como campos adicionales.accounts.transferdevuelve el sobre del gateway tal cual (un diccionario/objeto), no unTransactiontipado.
Tarjetas #
Las tarjetas son débito, virtuales o físicas. El gateway solo expone metadatos no sensibles
(last_four, marca, estado): el PAN y el CVV nunca salen del procesador.
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 es terminal: una tarjeta bloqueada no se reactiva; emite una nueva.
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 es virtual (por defecto) o physical; status es freeze, unfreeze o block.
Los SDK los envían en minúsculas aunque los escribas en mayúsculas. Al emitirse una tarjeta se
emite el evento wallets.card.issued.
Cambio de divisas (FX) #
Primero cotizas (lectura pura: no mueve dinero, no se factura) y luego ejecutas la
conversión pasando el quote_id de la cotización. La tasa es indicativa: el motor la vuelve a
resolver al ejecutar.
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"'"}'Los nombres del contrato son sell_currency y buy_currency (los SDK los envían en mayúsculas).
fx.convert devuelve el sobre del gateway tal cual. Un par de monedas sin tasa publicada responde
422.
Errores de negocio #
Los errores llegan en application/problem+json (ver Errores). En un rechazo
del motor, detail trae el motivo que dio el motor —su mensaje en español, por ejemplo «Esta
factura ya está pagada.»— y, si el motor no envía mensaje, el código:
The request was rejected by the processing engine (code: BILL_ALREADY_PAID). La columna «Código»
es el código del motor que origina cada caso: úsalo para reconocerlo en logs y al hablar con soporte
(cita siempre el trace_id).
| HTTP | Código del motor | Cuándo | Qué hacer |
|---|---|---|---|
400 | — | Falta Idempotency-Key en P2P, Bre-B, factura o recarga | Envía la cabecera |
400 | RESOLUTION_REQUIRED / RESOLUTION_INVALID | Envío sin token, o con un token de otro flujo (Bre-B en P2P o al revés) | Resuelve de nuevo con la operación correcta |
400 | INVALID_PHONE / INVALID_AMOUNT | Celular o monto de recarga fuera de regla | Corrige el dato |
404 | MEMBER_NOT_FOUND | El document no es un afiliado activo de tu entidad | Sincroniza el afiliado |
404 | BILLER_NOT_FOUND / OPERATOR_NOT_FOUND | Convenio u operador no habilitado | Lista de nuevo el catálogo |
409 | BILL_ALREADY_PAID | La factura (o ese periodo) ya está pagada | Muestra el pago existente; no reintentes |
409 | — | La misma Idempotency-Key sigue en curso | Espera y reintenta con la misma llave |
422 | INSUFFICIENT_FUNDS | Saldo disponible insuficiente | Pide recargar o un monto menor |
422 | RESOLUTION_EXPIRED | Pasaron más de 300 s desde resolve | Resuelve otra vez y vuelve a confirmar |
422 | SELF_TRANSFER | El destinatario es el mismo titular | — |
422 | AMOUNT_MISMATCH | Pagas un valor distinto al de la factura | Usa el amount de la consulta |
422 | TRANSFER_REJECTED / PAYMENT_REJECTED | El banco destino o el recaudador rechazó; no se descontó dinero | Informa al usuario |
422 | — | Límite Bre-B superado, Bre-B no habilitado | Consulta breb.limits |
502 / 504 | — | El motor falló o no respondió | Reintenta con la misma Idempotency-Key |
Un 409 no siempre es un conflicto de idempotencia. Los SDK levantan la misma excepción para
ambos; distínguelos por el type del problema: …/idempotency-conflict cuando es tu llave en curso
y …/upstream-error cuando el motor rechazó por una regla de negocio.
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: saldo, límites, token vencido…
print("Rechazada:", e.detail, e.trace_id)
except IdempotencyConflictError as e: # 409
if e.problem.type.endswith("/idempotency-conflict"):
print("La misma llave sigue en curso: reintenta luego con la MISMA llave")
else:
print("Conflicto de negocio:", e.detail)
except NotFoundError as e: # 404: titular inexistente
print("No existe:", e.trace_id)
except CompenseAPIError as e: # 400 y demás
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("Rechazada:", err.detail, err.traceId);
else if (err instanceof IdempotencyConflictError) {
if (err.problem.type.endsWith("/idempotency-conflict")) console.log("Llave en curso: reintenta igual");
else console.log("Conflicto de negocio:", err.detail);
} else if (err instanceof NotFoundError) console.log("No existe:", 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 'Rechazada: ', $e->detail, ' ', $e->traceId, PHP_EOL;
} catch (IdempotencyConflictException $e) { // 409
if (str_ends_with($e->problem->type, '/idempotency-conflict')) {
echo "La misma llave sigue en curso: reintenta con la MISMA llave\n";
} else {
echo 'Conflicto de negocio: ', $e->detail, PHP_EOL;
}
} catch (NotFoundException $e) {
echo 'No existe: ', $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($"Rechazada: {e.Detail} {e.TraceId}");
}
catch (IdempotencyConflictException e) // 409
{
if (e.Problem.Type.EndsWith("/idempotency-conflict", StringComparison.Ordinal))
Console.WriteLine("La misma llave sigue en curso: reintenta con la MISMA llave");
else
Console.WriteLine($"Conflicto de negocio: {e.Detail}");
}
catch (NotFoundException e)
{
Console.WriteLine($"No existe: {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("Rechazada:", apiErr.Detail, apiErr.TraceID)
case compense.IsIdempotencyConflict(err): // 409
if strings.HasSuffix(apiErr.Problem.Type, "/idempotency-conflict") {
fmt.Println("La misma llave sigue en curso: reintenta con la MISMA llave")
} else {
fmt.Println("Conflicto de negocio:", apiErr.Detail)
}
case compense.IsNotFound(err):
fmt.Println("No existe:", apiErr.TraceID)
default:
fmt.Println(apiErr.StatusCode, apiErr.Detail, apiErr.TraceID)
}
}# Problema típico de un rechazo de negocio
# 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…"}Eventos #
Los eventos de billetera se emiten en la misma transacción que el movimiento de dinero: si el
dinero no se movió, no hay evento de éxito, y un reintento con la misma Idempotency-Key no lo
duplica. Suscríbelos como se explica en Suscripción y verifica la firma
según Verificación.
Billetera #
| Evento | Desde | Cuándo se emite |
|---|---|---|
wallets.cashin.initiated | 2026-07-21 | Se registró una intención de recarga; aún no se mueve dinero. |
wallets.cashin.confirmed | 2026-07-21 | El origen confirmó la recarga y se emitió dinero electrónico a la billetera. |
wallets.transfer.created | 2026-07-21 | Se creó una transferencia (P2P, Bre-B o entre bolsillos) y quedó asentada en el libro mayor. |
wallets.transfer.settled | 2026-07-21 | La transferencia quedó liquidada. |
wallets.transfer.failed | 2026-07-21 | El riel rechazó la transferencia; no se movió dinero. |
wallets.disbursement.credited | 2026-07-21 | Se abonó un subsidio o crédito directamente a la billetera. |
wallets.withdrawal.authorized | 2026-07-21 | Se autorizó un retiro en cajero (token de un solo uso y fondos retenidos). |
wallets.bill_payment.completed | 2026-07-21 | El titular pagó una factura de servicios y el facturador la aceptó. |
wallets.qr_payment.completed | 2026-07-21 | El titular pagó un QR interoperable (EMVCo). |
wallets.transaction.reversed | 2026-07-21 | Se reversó una transacción (reembolso). |
wallets.dispute.opened | 2026-07-21 | El titular abrió una disputa sobre una transacción. |
wallets.dispute.resolved | 2026-07-21 | Un operador resolvió la disputa (a favor, con reverso, o rechazada). |
wallets.card.issued | 2026-07-21 | Se emitió una tarjeta (solo viajan los últimos 4 dígitos). |
wallets.topup.completed | 2026-10-27 | Se asentó una recarga de celular (carrier, teléfono enmascarado, amount). |
Campos de resource de los eventos que dispara esta guía:
| Evento | Campos de resource |
|---|---|
wallets.transfer.created | transaction_id, source_wallet_id, recipient_wallet_id (null si es a otro banco), amount, currency, rail_reference, is_internal, status; Bre-B añade rail: "breb" |
wallets.transfer.failed | transaction_id, source_wallet_id, amount, currency, rail_reference, reason, status |
wallets.transfer.settled | La transferencia en curso quedó liquidada (ver el catálogo) |
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"}}Concilia por resource.transaction_id, que es el mismo tx.data["transaction_id"] que te devolvió
la escritura.
Sandbox #
Con una llave de sandbox nada llega al motor real: el gateway responde datos deterministas
(misma petición, misma respuesta) con "environment": "sandbox". La tabla completa está en
Datos de prueba.
| Documento del titular | Perfil | Comportamiento en billetera |
|---|---|---|
1000000001 | Elegible | Saldo 500.000; todo funciona |
1000000002 | Inelegible | Saldo 0 → INSUFFICIENT_FUNDS en cualquier envío; no puede emitir tarjeta |
1000000004 | Con deudas | Saldo 100 → INSUFFICIENT_FUNDS |
9999999999 | No existe | 404 MEMBER_NOT_FOUND |
| cualquier otro | Elegible | Como 1000000001 |
Casos de prueba:
- Alias o llave
9999999999→{"found": false}. - P2P con
aliasigual aldocumentdel pagador →422 SELF_TRANSFER. - Envío con un token de otro flujo (Bre-B en P2P o al revés) →
400 RESOLUTION_INVALID. - Bre-B por
11111→422 TRANSFER_REJECTED(rechazo del banco destino). - Factura con referencia terminada en
0→409 BILL_ALREADY_PAID; convenio desconocido →404; pagar un valor distinto al de la consulta →422 AMOUNT_MISMATCH. El valor de cada factura es fijo por convenio y referencia (entre 50.000 y 95.000): consúltalo primero. - Convenios simulados:
EPM,ACUEDUCTO,VANTI,ETB. Operadores:CLARO,MOVISTAR,TIGO,WOM(1.000–200.000). - Movimientos: tres fijos por titular (1, 5 y 10 de octubre de 2026), paginables y filtrables por
fecha; su
idsirve en el detalle.
Montos mágicos: liquidar, fallar o devolver #
Una transferencia real puede quedar PENDING y terminar días después en liquidada, fallida o
devuelta. Para probar esos desenlaces sin esperar, el simulador de
transferencias avanza un estado por llamada (PENDING → PROCESSING → …) y el monto decide el
final:
| Monto | Desenlace |
|---|---|
22.22 | FAILED (no se liquida; reason_code: TRANSFER_FAILED) |
33.33 | RETURNED (se liquida y luego se devuelve; reason_code: TRANSFER_RETURNED) |
11.11 o cualquier otro | 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, ...}El simulador es una transformación pura: no mueve saldo ni emite webhooks. Úsalo para recorrer en
tus pruebas cada rama de tu conciliación (wallets.transfer.settled y wallets.transfer.failed).
Cuentas, tarjetas y FX en el sandbox
Las respuestas simuladas de cuentas/bolsillos, tarjetas y FX vienen de la superficie anterior de
billetera: los montos se simulan en DOP y su forma todavía no coincide del todo con el contrato (el
listado de cuentas y de tarjetas llega en results, el cambio de estado de tarjeta espera
ACTIVE/FROZEN/BLOCKED y la cotización ignora sell_currency/buy_currency). Úsalas para
probar el flujo y el manejo de errores, no para validar valores; saldo, movimientos, P2P, Bre-B,
facturas y recargas sí siguen el contrato.