En esta receta, la app de Nova deja que sus usuarios paguen la factura de la luz desde su billetera. Vas a recorrer el flujo completo del lado del backend de Nova: elegir el facturador, consultar la factura, confirmar con el usuario, pagar sin riesgo de cobrar dos veces aunque la red falle, enterarte del resultado por webhook y entregar un comprobante. Los detalles de cada operación están en la guía de Billetera.

Qué vas a construir #

sequenceDiagram
    autonumber
    participant U as Usuario (app Nova)
    participant N as Backend de Nova
    participant G as Gateway Compense
    U->>N: Quiero pagar la luz (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 · vence 15-nov. ¿Pagar?
    U->>N: Confirmo
    N->>N: crea la orden NOVA-ORD-000981 (llave = nova-bill-NOVA-ORD-000981)
    N->>G: POST wallet/bills/pay/ + Idempotency-Key
    alt timeout o 502/504
        N->>G: mismo POST con la MISMA llave
    end
    G-->>N: 201 APPROVED (approval_number) · o 202 PENDING
    G-)N: webhook wallets.bill_payment.completed
    N->>G: GET wallet/bills/payments/{id}/?document=…
    N->>U: Comprobante

Requisitos #

  • Una llave con wallet:read (facturadores e historial) y wallet:payments (consultar y pagar).
  • Un endpoint de webhooks suscrito a wallets.bill_payment.completed (ver Suscripción).
  • En tu base, una tabla de órdenes de pago con un id propio (NOVA-ORD-…): de ahí sale la Idempotency-Key.

Los ejemplos usan el titular de prueba 1000000001 del sandbox; en Go asumen ctx y client creados como en el quickstart.

1. Lista los facturadores #

El catálogo es de tu entidad (no necesita document). Cárgalo al abrir la pantalla de pagos y guárdalo en caché unos minutos; filtra por categoría (POWER, WATER, GAS, INTERNET…) o por texto con 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. Consulta la factura #

La consulta te da el valor exacto, el titular de la factura (enmascarado), el vencimiento y si admite pago parcial. Nunca cobres un valor que el usuario escribió a mano: paga el amount de la consulta (o, si allows_partial es true, un valor entre min_amount y max_amount).

Si la factura ya está pagada, la consulta misma responde 409 (BILL_ALREADY_PAID): díselo al usuario antes de llegar al pago.

from compense_gateway import IdempotencyConflictError

try:
    factura = client.wallet.bills.inquire(document=DOC, biller_code="EPM", reference="123456789")
except IdempotencyConflictError as e:      # 409 en una lectura: la factura ya está pagada
    raise SystemExit(f"No hay nada que pagar: {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. Confirma con el usuario #

Muestra en la app lo que devolvió la consulta: facturador, referencia, titular enmascarado, valor y vencimiento. Cuando el usuario confirma, crea la orden en tu base antes de llamar al pago, con el valor y los datos de la consulta. El id de esa orden es lo que hace seguro el reintento del paso siguiente: si el proceso se cae a mitad de camino, la orden sigue ahí con la misma llave.

Una llave por orden, no por intento

Deriva la Idempotency-Key del id de tu orden (nova-bill-NOVA-ORD-000981) y guárdala con ella. Un UUID nuevo por cada intento derrota el propósito: el gateway vería cada reintento como un pago distinto. Más en Idempotencia.

4. Paga con una Idempotency-Key estable #

El pago puede terminar de cinco maneras, y cada una tiene un manejo distinto:

ResultadoCómo lo reconocesQué hacer
Aprobado201, data.status = APPROVED, data.approval_numberMarca la orden pagada y espera el webhook
Pendiente202, data.status = PENDINGEl recaudador aún no confirma: espera el webhook o consulta el pago (paso 5)
Ya pagada409 con type …/upstream-error (BILL_ALREADY_PAID)No reintentes; muestra el pago existente
Sin saldo422 (INSUFFICIENT_FUNDS)Pide recargar o pagar con otro medio; no se movió dinero
Sin respuestaTimeout, 502 o 504; o 409 con type …/idempotency-conflictReintenta con la misma llave hasta tener una respuesta

Un reintento con la misma llave nunca cobra dos veces: si el primer intento sí llegó al motor, el gateway devuelve su resultado con 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}"                 # misma orden → misma llave, siempre
    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: reintenta igual
        except IdempotencyConflictError as e:
            if not e.problem.type.endswith("/idempotency-conflict"):
                raise                               # 409 de negocio: BILL_ALREADY_PAID
        time.sleep(2 ** intento)                    # 1, 2, 4, 8… segundos
    raise RuntimeError("Sin respuesta: deja la orden en revisión y concilia por 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("La factura ya estaba pagada:", e.detail)
except ValidationError as e:                        # 422: INSUFFICIENT_FUNDS, AMOUNT_MISMATCH…
    print("No se pudo pagar:", 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"}}

Guarda en la orden el data.id (el pago), el data.transaction_id (el movimiento de la billetera, con el que llega el webhook) y el transaction_id del sobre (para soporte).

Si se agotan los reintentos

No le digas al usuario que el pago falló ni le permitas pagar de nuevo con otra llave: el primer intento pudo haber llegado. Deja la orden «en revisión», espera el webhook y, si no llega, busca el pago en bills.list_payments antes de decidir.

5. Si quedó pendiente, consulta el pago #

Un pago PENDING se concilia con el recaudador cada vez que consultas su detalle. Si el webhook tarda, consulta con un intervalo prudente (por ejemplo, a los 30 segundos, 2 minutos y 10 minutos).

pago = client.wallet.bills.get_payment(tx.data["id"], document=DOC)
print(pago["status"], pago.get("approval_number"))

6. Recibe el webhook #

wallets.bill_payment.completed llega cuando el pago quedó aprobado y asentado. Se emite en la misma transacción del movimiento (si no hubo dinero, no hay evento) y un reintento con la misma llave no lo duplica. La entrega es al menos una vez: deduplica por el id del evento (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"}}
Receptor del webhook
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"])  # tu lógica
    registrar_procesado(evento.event_id)
    return 200

Los detalles de la firma y la rotación de secretos están en Verificación y Rotación.

7. Entrega el comprobante #

Arma el comprobante desde el detalle del pago (no desde el webhook, que no trae el facturador ni la referencia): facturador, referencia, valor, número de aprobación, fecha y el id del movimiento.

Dato del comprobanteCampo de bills.get_payment
Facturadorbiller.name
Referenciareference
Valoramount + currency
Número de aprobaciónapproval_number
Fechacompleted_at
Id del movimientotransaction_id
Estadostatus / status_label

Si el usuario pide el historial, bills.list_payments(document, kind="BILL") le devuelve todos sus pagos de facturas, paginados con limit/offset.

Pruébalo en el sandbox #

Para probarHaz esto
Pago aprobadoEPM / 123456789 con el titular 1000000001 (valor de la consulta: 90.000)
Factura ya pagadaCualquier referencia terminada en 0 (por ejemplo 1234567890) → 409 BILL_ALREADY_PAID en la consulta y en el pago
Sin saldoTitular 1000000002 (saldo 0) o 1000000004 (saldo 100) → 422 INSUFFICIENT_FUNDS
Valor equivocadoPaga un valor distinto al de la consulta → 422 AMOUNT_MISMATCH
Facturador inexistentebiller_code que no sea EPM, ACUEDUCTO, VANTI o ETB → 404
Reintento seguroRepite el pago con la misma llave → mismo transaction_id e idempotent_replay: true

El sandbox no emite el webhook real: usa el evento de prueba del paso 6 para validar tu receptor.

Lista de verificación #

  • La Idempotency-Key sale del id de tu orden y se guarda con ella.
  • Pagas el amount de la consulta, nunca un valor digitado.
  • Distingues los 409 por type: …/upstream-error (ya pagada) frente a …/idempotency-conflict (llave en curso).
  • Ante timeout o 5xx reintentas con la misma llave y, si se agotan los intentos, dejas la orden en revisión.
  • El receptor del webhook verifica la firma, ignora eventos de prueba y deduplica por id.
  • El comprobante sale de bills.get_payment.