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: Receipt

Requirements #

  • An API key with wallet:read (billers and history) and wallet: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 the Idempotency-Key comes 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"])

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"])
JSON
{"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:

OutcomeHow you recognize itWhat to do
Approved201, data.status = APPROVED, data.approval_numberMark the order as paid and wait for the webhook
Pending202, data.status = PENDINGThe collector hasn’t confirmed yet: wait for the webhook or look up the payment (step 5)
Already paid409 with type …/upstream-error (BILL_ALREADY_PAID)Don’t retry; show the existing payment
Insufficient funds422 (INSUFFICIENT_FUNDS)Ask the user to top up or pay another way; no money moved
No responseTimeout, 502 or 504; or 409 with type …/idempotency-conflictRetry 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)
JSON
{"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"))

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).

JSON
{"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"}}
Webhook receiver
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 200

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 fieldbills.get_payment field
Billerbiller.name
Referencereference
Amountamount + currency
Approval numberapproval_number
Datecompleted_at
Movement idtransaction_id
Statusstatus / 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 testDo this
Approved paymentEPM / 123456789 with holder 1000000001 (inquiry amount: 90,000)
Bill already paidAny reference ending in 0 (for example 1234567890) → 409 BILL_ALREADY_PAID on both the inquiry and the payment
Insufficient fundsHolder 1000000002 (balance 0) or 1000000004 (balance 100) → 422 INSUFFICIENT_FUNDS
Wrong amountPay an amount different from the inquiry’s → 422 AMOUNT_MISMATCH
Nonexistent billerA biller_code other than EPM, ACUEDUCTO, VANTI or ETB → 404
Safe retryRepeat 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-Key comes from your order id and is stored with it.
  • You pay the inquiry’s amount, never a typed-in value.
  • You tell 409 responses apart by type: …/upstream-error (already paid) versus …/idempotency-conflict (key in progress).
  • On a timeout or 5xx you 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.