Recipe
Charge an invoice from the wallet
Pay an affiliate’s utility bill from their Nova wallet: billers, inquiry, user confirmation, payment with a stable Idempotency-Key, safe retries, the wallets.bill_payment.completed webhook and a receipt.
On this page
In this recipe, the Nova app lets its users pay their electricity bill from their wallet. You’ll walk through the full flow on Nova’s backend: choose the biller, look up the bill, confirm with the user, pay without any risk of charging twice even if the network fails, learn the outcome by webhook and hand over a receipt. The details of each operation are in the Wallet guide.
What you’ll build #
sequenceDiagram
autonumber
participant U as User (Nova app)
participant N as Nova backend
participant G as Compense Gateway
U->>N: I want to pay my electricity bill (EPM, ref. 123456789)
N->>G: GET wallet/billers/?category=POWER
N->>G: POST wallet/bills/inquiry/ {document, biller_code, reference}
G-->>N: holder_name, amount, due_date
N->>U: Sa*** Ti*** · $90.000 · due Nov 15. Pay?
U->>N: I confirm
N->>N: creates order NOVA-ORD-000981 (key = nova-bill-NOVA-ORD-000981)
N->>G: POST wallet/bills/pay/ + Idempotency-Key
alt timeout or 502/504
N->>G: same POST with the SAME key
end
G-->>N: 201 APPROVED (approval_number) · or 202 PENDING
G-)N: webhook wallets.bill_payment.completed
N->>G: GET wallet/bills/payments/{id}/?document=…
N->>U: ReceiptRequirements #
- An API key with
wallet:read(billers and history) andwallet:payments(inquire and pay). - A webhook endpoint subscribed to
wallets.bill_payment.completed(see Subscription). - In your database, a payment orders table with your own id (
NOVA-ORD-…): that’s where theIdempotency-Keycomes from.
The examples use the sandbox test holder 1000000001; in Go they assume ctx and client created
as in the quickstart.
1. List the billers #
The catalog belongs to your entity (it doesn’t need document). Load it when the payments screen
opens and cache it for a few minutes; filter by category (POWER, WATER, GAS, INTERNET…) or by
text with q.
import os
from compense_gateway import CompenseClient
client = CompenseClient(api_key=os.environ["COMPENSE_API_KEY"], environment="sandbox")
DOC = "1000000001"
for b in client.wallet.bills.billers(category="POWER"):
print(b["code"], b["name"])import { CompenseClient } from "@compense/sdk";
const client = new CompenseClient({ apiKey: process.env.COMPENSE_API_KEY!, environment: "sandbox" });
const DOC = "1000000001";
for (const b of await client.wallet.bills.billers({ category: "POWER" })) console.log(b.code, b.name);<?php
require __DIR__ . '/vendor/autoload.php';
use Compense\Sdk\CompenseClient;
$client = new CompenseClient(apiKey: getenv('COMPENSE_API_KEY'), environment: 'sandbox');
$doc = '1000000001';
foreach ($client->wallet->bills->billers(category: 'POWER') as $b) {
echo $b['code'], ' ', $b['name'], PHP_EOL;
}using System.Text.Json.Nodes;
using Compense.Sdk;
using Compense.Sdk.Models;
using var client = new CompenseClient(
Environment.GetEnvironmentVariable("COMPENSE_API_KEY")!, CompenseEnvironment.Sandbox);
const string Doc = "1000000001";
foreach (JsonObject b in await client.Wallet.Bills.BillersAsync(category: "POWER"))
Console.WriteLine($"{b["code"]} {b["name"]}");const doc = "1000000001"
billers, err := client.Wallet.Bills.Billers(ctx, &compense.BillersParams{Category: "POWER"})
if err != nil {
log.Fatal(err)
}
for _, b := range billers {
fmt.Println(b["code"], b["name"])
}curl "https://sandbox.dev.compense.co/gw/v1/gateway/wallet/billers/?category=POWER" \
-H "X-API-Key: $COMPENSE_API_KEY"
# {"billers": [{"code": "EPM", "name": "EPM Energía", "category": "POWER", "is_enabled": true, "products": []}]}2. Look up the bill #
The inquiry gives you the exact amount, the bill’s holder (masked), the due date and whether it
accepts partial payment. Never charge an amount the user typed in by hand: pay the inquiry’s
amount (or, if allows_partial is true, an amount between min_amount and max_amount).
If the bill is already paid, the inquiry itself responds 409 (BILL_ALREADY_PAID): tell the user
before you get to the payment.
from compense_gateway import IdempotencyConflictError
try:
factura = client.wallet.bills.inquire(document=DOC, biller_code="EPM", reference="123456789")
except IdempotencyConflictError as e: # 409 on a read: the bill is already paid
raise SystemExit(f"Nothing to pay: {e.detail}")
print(factura["holder_name"], factura["amount"], factura["due_date"], factura["allows_partial"])import { IdempotencyConflictError } from "@compense/sdk";
let factura;
try {
factura = await client.wallet.bills.inquire({ document: DOC, billerCode: "EPM", reference: "123456789" });
} catch (err) {
if (err instanceof IdempotencyConflictError) throw new Error(`Nothing to pay: ${err.detail}`);
throw err;
}
console.log(factura.holder_name, factura.amount, factura.due_date, factura.allows_partial);use Compense\Sdk\Exception\IdempotencyConflictException;
try {
$factura = $client->wallet->bills->inquire(document: $doc, billerCode: 'EPM', reference: '123456789');
} catch (IdempotencyConflictException $e) { // 409 on a read: the bill is already paid
exit('Nothing to pay: ' . $e->detail . PHP_EOL);
}
echo $factura['holder_name'], ' ', $factura['amount'], ' ', $factura['due_date'], PHP_EOL;JsonObject factura;
try
{
factura = await client.Wallet.Bills.InquireAsync(Doc, billerCode: "EPM", reference: "123456789");
}
catch (IdempotencyConflictException e) // 409 on a read: the bill is already paid
{
Console.WriteLine($"Nothing to pay: {e.Detail}");
return;
}
Console.WriteLine($"{factura["holder_name"]} {factura["amount"]} {factura["due_date"]}");factura, err := client.Wallet.Bills.Inquire(ctx, &compense.BillInquiryParams{
Document: doc, BillerCode: "EPM", Reference: "123456789",
})
if compense.IsIdempotencyConflict(err) { // 409 on a read: the bill is already paid
log.Fatal("nothing to pay: ", err)
} else if err != nil {
log.Fatal(err)
}
fmt.Println(factura["holder_name"], factura["amount"], factura["due_date"])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"}'{"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"}3. Confirm with the user #
Show in the app what the inquiry returned: biller, reference, masked holder, amount and due date. When the user confirms, create the order in your database before calling the payment, with the amount and data from the inquiry. That order’s id is what makes the next step’s retry safe: if the process crashes halfway through, the order is still there with the same key.
One key per order, not per attempt
Derive the Idempotency-Key from your order id (nova-bill-NOVA-ORD-000981) and store it with the
order. A new UUID on every attempt defeats the purpose: the gateway would see each retry as a
different payment. More in Idempotency.
4. Pay with a stable Idempotency-Key #
The payment can end in five ways, and each one is handled differently:
| Outcome | How you recognize it | What to do |
|---|---|---|
| Approved | 201, data.status = APPROVED, data.approval_number | Mark the order as paid and wait for the webhook |
| Pending | 202, data.status = PENDING | The collector hasn’t confirmed yet: wait for the webhook or look up the payment (step 5) |
| Already paid | 409 with type …/upstream-error (BILL_ALREADY_PAID) | Don’t retry; show the existing payment |
| Insufficient funds | 422 (INSUFFICIENT_FUNDS) | Ask the user to top up or pay another way; no money moved |
| No response | Timeout, 502 or 504; or 409 with type …/idempotency-conflict | Retry with the same key until you get a response |
A retry with the same key never charges twice: if the first attempt did reach the engine, the
gateway returns its result with idempotent_replay: true.
import time
import httpx
from compense_gateway import IdempotencyConflictError, UpstreamError, ValidationError
def pagar_factura(orden_id: str, factura: dict):
llave = f"nova-bill-{orden_id}" # same order → same key, always
for intento in range(5):
try:
return client.wallet.bills.pay(
document=DOC, biller_code=factura["biller"]["code"],
reference=factura["reference"], amount=factura["amount"],
idempotency_key=llave,
)
except (UpstreamError, httpx.TransportError):
pass # timeout / 502 / 504: retry as is
except IdempotencyConflictError as e:
if not e.problem.type.endswith("/idempotency-conflict"):
raise # business 409: BILL_ALREADY_PAID
time.sleep(2 ** intento) # 1, 2, 4, 8… seconds
raise RuntimeError("No response: leave the order under review and reconcile via webhook")
try:
tx = pagar_factura("NOVA-ORD-000981", factura)
print(tx.data["status"], tx.data.get("approval_number"), tx.idempotent_replay)
except IdempotencyConflictError as e:
print("The bill was already paid:", e.detail)
except ValidationError as e: # 422: INSUFFICIENT_FUNDS, AMOUNT_MISMATCH…
print("Payment failed:", e.detail)import { CompenseConnectionError, IdempotencyConflictError, UpstreamError, ValidationError } from "@compense/sdk";
async function pagarFactura(ordenId: string, factura: Record<string, any>) {
const llave = `nova-bill-${ordenId}`; // same order → same key, always
for (let intento = 0; intento < 5; intento++) {
try {
return await client.wallet.bills.pay({
document: DOC,
billerCode: factura.biller.code,
reference: factura.reference,
amount: factura.amount,
idempotencyKey: llave,
});
} catch (err) {
const reintentable =
err instanceof UpstreamError ||
err instanceof CompenseConnectionError ||
(err instanceof IdempotencyConflictError && err.problem.type.endsWith("/idempotency-conflict"));
if (!reintentable) throw err; // BILL_ALREADY_PAID, INSUFFICIENT_FUNDS…
}
await new Promise((r) => setTimeout(r, 1000 * 2 ** intento));
}
throw new Error("No response: leave the order under review and reconcile via webhook");
}
try {
const tx = await pagarFactura("NOVA-ORD-000981", factura);
console.log(tx.data.status, tx.data.approval_number, tx.idempotentReplay);
} catch (err) {
if (err instanceof IdempotencyConflictError) console.log("The bill was already paid:", err.detail);
else if (err instanceof ValidationError) console.log("Payment failed:", err.detail);
else throw err;
}use Compense\Sdk\Exception\IdempotencyConflictException;
use Compense\Sdk\Exception\TransportException;
use Compense\Sdk\Exception\UpstreamException;
use Compense\Sdk\Exception\ValidationException;
use Compense\Sdk\Model\Transaction;
function pagarFactura(CompenseClient $client, string $doc, string $ordenId, array $factura): Transaction
{
$llave = "nova-bill-{$ordenId}"; // same order → same key, always
for ($intento = 0; $intento < 5; $intento++) {
try {
return $client->wallet->bills->pay(
document: $doc,
billerCode: $factura['biller']['code'],
reference: $factura['reference'],
amount: $factura['amount'],
idempotencyKey: $llave,
);
} catch (UpstreamException | TransportException) {
// timeout / 502 / 504: retry as is
} catch (IdempotencyConflictException $e) {
if (!str_ends_with($e->problem->type, '/idempotency-conflict')) {
throw $e; // business 409: BILL_ALREADY_PAID
}
}
sleep(2 ** $intento);
}
throw new RuntimeException('No response: leave the order under review and reconcile via webhook');
}
try {
$tx = pagarFactura($client, $doc, 'NOVA-ORD-000981', $factura);
echo $tx->data['status'], ' ', $tx->data['approval_number'] ?? '-', PHP_EOL;
} catch (IdempotencyConflictException $e) {
echo 'The bill was already paid: ', $e->detail, PHP_EOL;
} catch (ValidationException $e) { // 422: INSUFFICIENT_FUNDS, AMOUNT_MISMATCH…
echo 'Payment failed: ', $e->detail, PHP_EOL;
}async Task<Transaction> PagarFacturaAsync(string ordenId, JsonObject factura)
{
string llave = $"nova-bill-{ordenId}"; // same order → same key, always
for (int intento = 0; intento < 5; intento++)
{
try
{
return await client.Wallet.Bills.PayAsync(
Doc, factura["biller"]!["code"]!.GetValue<string>(),
factura["reference"]!.GetValue<string>(), factura["amount"]!.GetValue<string>(),
idempotencyKey: llave);
}
catch (Exception e) when (e is UpstreamException or HttpRequestException or TaskCanceledException)
{
// timeout / 502 / 504: retry as is
}
catch (IdempotencyConflictException e) when (e.Problem.Type.EndsWith("/idempotency-conflict", StringComparison.Ordinal))
{
// the previous attempt is still in progress
}
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, intento)));
}
throw new InvalidOperationException("No response: leave the order under review and reconcile via webhook");
}
try
{
Transaction tx = await PagarFacturaAsync("NOVA-ORD-000981", factura);
Console.WriteLine($"{tx.Data["status"]} {tx.Data["approval_number"]} {tx.IdempotentReplay}");
}
catch (IdempotencyConflictException e) { Console.WriteLine($"The bill was already paid: {e.Detail}"); }
catch (ValidationException e) { Console.WriteLine($"Payment failed: {e.Detail}"); }pagarFactura := func(ordenID string, factura map[string]any) (*compense.Transaction, error) {
llave := "nova-bill-" + ordenID // same order → same key, always
biller, _ := factura["biller"].(map[string]any)
codigo, _ := biller["code"].(string)
referencia, _ := factura["reference"].(string)
valor, _ := factura["amount"].(string)
var ultimo error
for intento := 0; intento < 5; intento++ {
tx, err := client.Wallet.Bills.Pay(ctx, &compense.BillPayParams{
Document: doc, BillerCode: codigo, Reference: referencia, Amount: compense.Amount(valor),
}, compense.WithIdempotencyKey(llave))
if err == nil {
return tx, nil
}
var apiErr *compense.APIError
enCurso := compense.IsIdempotencyConflict(err) && errors.As(err, &apiErr) &&
strings.HasSuffix(apiErr.Problem.Type, "/idempotency-conflict")
red := !errors.As(err, &apiErr) // timeout or transport error
if !(compense.IsUpstreamError(err) || enCurso || red) {
return nil, err // BILL_ALREADY_PAID, INSUFFICIENT_FUNDS…
}
ultimo = err
time.Sleep(time.Duration(1<<intento) * time.Second)
}
return nil, fmt.Errorf("no response, reconcile via webhook: %w", ultimo)
}
tx, err := pagarFactura("NOVA-ORD-000981", factura)
switch {
case compense.IsIdempotencyConflict(err):
fmt.Println("The bill was already paid:", err)
case compense.IsValidationError(err):
fmt.Println("Payment failed:", err)
case err != nil:
log.Fatal(err)
default:
fmt.Println(tx.Data["status"], tx.Data["approval_number"], tx.IdempotentReplay)
}# Retry exactly this command (same Idempotency-Key) if you don't get a response
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-NOVA-ORD-000981" \
-H "Content-Type: application/json" \
-d '{"document": "1000000001", "biller_code": "EPM", "reference": "123456789", "amount": "90000.00"}'{"status": "success", "transaction_id": "gw-…", "module": "wallet",
"data": {"id": "pay-E-…", "kind": "BILL", "status": "APPROVED", "status_label": "Aprobado",
"biller": {"code": "EPM", "name": "EPM Energía", "category": "POWER"},
"reference": "123456789", "amount": "90000.00", "currency": "COP",
"approval_number": "8C21F0", "transaction_id": "wtx-E-…", "due_date": "2026-11-15",
"completed_at": "2026-10-10T09:15:00+00:00"}}Store on the order the data.id (the payment), the data.transaction_id (the wallet movement, which
the webhook arrives with) and the envelope’s transaction_id (for support).
If you run out of retries
Don’t tell the user the payment failed, and don’t let them pay again with another key: the first
attempt may have gone through. Leave the order “under review”, wait for the webhook and, if it doesn’t
arrive, look for the payment in bills.list_payments before you decide.
5. If it’s pending, look up the payment #
A PENDING payment is reconciled with the collector every time you fetch its details. If the
webhook is slow, poll at a sensible interval (for example, at 30 seconds, 2 minutes and 10 minutes).
pago = client.wallet.bills.get_payment(tx.data["id"], document=DOC)
print(pago["status"], pago.get("approval_number"))const pago = await client.wallet.bills.getPayment(String(tx.data.id), { document: DOC });
console.log(pago.status, pago.approval_number);$pago = $client->wallet->bills->getPayment($tx->data['id'], $doc);
echo $pago['status'], ' ', $pago['approval_number'] ?? '-', PHP_EOL;JsonObject pago = await client.Wallet.Bills.GetPaymentAsync(tx.Data["id"]!.GetValue<string>(), Doc);
Console.WriteLine($"{pago["status"]} {pago["approval_number"]}");pagoID, _ := tx.Data["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/bills/payments/$PAGO_ID/?document=1000000001" \
-H "X-API-Key: $COMPENSE_API_KEY"6. Receive the webhook #
wallets.bill_payment.completed arrives when the payment has been approved and posted. It’s
emitted in the same transaction as the movement (no money, no event), and a retry with the same key
doesn’t duplicate it. Delivery is at least once: deduplicate by the event id (X-Webhook-Id).
{"event": "wallets.bill_payment.completed", "version": "1", "tenant_id": "nova",
"occurred_at": "2026-11-02T15:04:11Z",
"resource": {"transaction_id": "wtx-E-…", "wallet_id": "…", "service_id": "EPM",
"amount": "90000.00", "currency": "COP", "provider": "…",
"provider_reference": "…", "status": "completed"}}import os
from compense_gateway.webhooks import verify_webhook, WebhookVerificationError
def recibir(raw_body: bytes, headers) -> int:
try:
evento = verify_webhook(raw_body, headers, secret=os.environ["COMPENSE_WEBHOOK_SECRET"])
except WebhookVerificationError:
return 401
if evento.is_test or ya_procesado(evento.event_id):
return 200
if evento.event == "wallets.bill_payment.completed":
r = evento.resource
marcar_orden_pagada(movimiento=r["transaction_id"], valor=r["amount"]) # your logic
registrar_procesado(evento.event_id)
return 200import express from "express";
import { verifyWebhook, WebhookVerificationError } from "@compense/sdk/webhooks";
const app = express();
app.post("/compense/webhooks", express.raw({ type: "*/*" }), (req, res) => {
let evento;
try {
evento = verifyWebhook(req.body, req.headers, process.env.COMPENSE_WEBHOOK_SECRET!);
} catch (err) {
if (err instanceof WebhookVerificationError) return res.sendStatus(401);
throw err;
}
if (!evento.isTest && !yaProcesado(evento.eventId) && evento.event === "wallets.bill_payment.completed") {
marcarOrdenPagada(evento.resource.transaction_id, evento.resource.amount); // your logic
registrarProcesado(evento.eventId);
}
res.sendStatus(200);
});use Compense\Sdk\Webhook\Webhook;
use Compense\Sdk\Webhook\WebhookVerificationException;
try {
$evento = Webhook::verify(file_get_contents('php://input'), getallheaders(), getenv('COMPENSE_WEBHOOK_SECRET'));
} catch (WebhookVerificationException) {
http_response_code(401);
exit;
}
if (!$evento->isTest() && !yaProcesado($evento->eventId)
&& $evento->event() === 'wallets.bill_payment.completed') {
$r = $evento->resource();
marcarOrdenPagada($r['transaction_id'], $r['amount']); // your logic
registrarProcesado($evento->eventId);
}
http_response_code(200);using Compense.Sdk.Webhooks;
app.MapPost("/compense/webhooks", async (HttpRequest req) =>
{
using var cuerpo = new MemoryStream();
await req.Body.CopyToAsync(cuerpo);
WebhookEvent evento;
try
{
evento = CompenseWebhooks.Verify(cuerpo.ToArray(), req.Headers,
Environment.GetEnvironmentVariable("COMPENSE_WEBHOOK_SECRET")!);
}
catch (WebhookVerificationException)
{
return Results.Unauthorized();
}
if (!evento.IsTest && !YaProcesado(evento.EventId) && evento.Event == "wallets.bill_payment.completed")
{
MarcarOrdenPagada(evento.Resource["transaction_id"]!.GetValue<string>(), evento.Resource["amount"]!.ToString());
RegistrarProcesado(evento.EventId);
}
return Results.Ok();
});import "github.com/Appiasoft/compense-saas-sdk-go/webhook"
secrets := []string{os.Getenv("COMPENSE_WEBHOOK_SECRET")}
http.Handle("/compense/webhooks", webhook.Handler(secrets, func(ctx context.Context, ev *webhook.Event) error {
if ev.IsTest() || yaProcesado(ev.ID) {
return nil
}
if ev.Event() == "wallets.bill_payment.completed" {
r := ev.Resource()
marcarOrdenPagada(r["transaction_id"], r["amount"]) // your logic
}
registrarProcesado(ev.ID)
return nil
}))# Send a test event to your endpoint to validate the receiver (it arrives with id test-…)
curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/endpoints/$ENDPOINT_ID/test/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"event_type": "wallets.bill_payment.completed"}'Signature and secret-rotation details are in Verification and Rotation.
7. Hand over the receipt #
Build the receipt from the payment details (not from the webhook, which doesn’t include the biller or the reference): biller, reference, amount, approval number, date and the movement id.
| Receipt field | bills.get_payment field |
|---|---|
| Biller | biller.name |
| Reference | reference |
| Amount | amount + currency |
| Approval number | approval_number |
| Date | completed_at |
| Movement id | transaction_id |
| Status | status / status_label |
If the user asks for their history, bills.list_payments(document, kind="BILL") returns all their
bill payments, paginated with limit/offset.
Try it in the sandbox #
| To test | Do this |
|---|---|
| Approved payment | EPM / 123456789 with holder 1000000001 (inquiry amount: 90,000) |
| Bill already paid | Any reference ending in 0 (for example 1234567890) → 409 BILL_ALREADY_PAID on both the inquiry and the payment |
| Insufficient funds | Holder 1000000002 (balance 0) or 1000000004 (balance 100) → 422 INSUFFICIENT_FUNDS |
| Wrong amount | Pay an amount different from the inquiry’s → 422 AMOUNT_MISMATCH |
| Nonexistent biller | A biller_code other than EPM, ACUEDUCTO, VANTI or ETB → 404 |
| Safe retry | Repeat the payment with the same key → same transaction_id and idempotent_replay: true |
The sandbox doesn’t emit the real webhook: use the test event from step 6 to validate your receiver.
Checklist #
- The
Idempotency-Keycomes from your order id and is stored with it. - You pay the inquiry’s
amount, never a typed-in value. - You tell
409responses apart bytype:…/upstream-error(already paid) versus…/idempotency-conflict(key in progress). - On a timeout or
5xxyou retry with the same key and, if you run out of attempts, you leave the order under review. - The webhook receiver verifies the signature, ignores test events and deduplicates by id.
- The receipt comes from
bills.get_payment.