Receta
Cobrar una factura desde la billetera
Paga la factura de servicios de un afiliado desde su billetera de Nova: facturadores, consulta, confirmación del usuario, pago con Idempotency-Key estable, reintentos seguros, webhook wallets.bill_payment.completed y comprobante.
En esta página
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: ComprobanteRequisitos #
- Una llave con
wallet:read(facturadores e historial) ywallet: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 laIdempotency-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"])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. 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"])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(`No hay nada que pagar: ${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 en una lectura: la factura ya está pagada
exit('No hay nada que pagar: ' . $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 en una lectura: la factura ya está pagada
{
Console.WriteLine($"No hay nada que pagar: {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 en una lectura: la factura ya está pagada
log.Fatal("no hay nada que pagar: ", 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. 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:
| Resultado | Cómo lo reconoces | Qué hacer |
|---|---|---|
| Aprobado | 201, data.status = APPROVED, data.approval_number | Marca la orden pagada y espera el webhook |
| Pendiente | 202, data.status = PENDING | El recaudador aún no confirma: espera el webhook o consulta el pago (paso 5) |
| Ya pagada | 409 con type …/upstream-error (BILL_ALREADY_PAID) | No reintentes; muestra el pago existente |
| Sin saldo | 422 (INSUFFICIENT_FUNDS) | Pide recargar o pagar con otro medio; no se movió dinero |
| Sin respuesta | Timeout, 502 o 504; o 409 con type …/idempotency-conflict | Reintenta 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)import { CompenseConnectionError, IdempotencyConflictError, UpstreamError, ValidationError } from "@compense/sdk";
async function pagarFactura(ordenId: string, factura: Record<string, any>) {
const llave = `nova-bill-${ordenId}`; // misma orden → misma llave, siempre
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("Sin respuesta: deja la orden en revisión y concilia por 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("La factura ya estaba pagada:", err.detail);
else if (err instanceof ValidationError) console.log("No se pudo pagar:", 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}"; // misma orden → misma llave, siempre
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: reintenta igual
} catch (IdempotencyConflictException $e) {
if (!str_ends_with($e->problem->type, '/idempotency-conflict')) {
throw $e; // 409 de negocio: BILL_ALREADY_PAID
}
}
sleep(2 ** $intento);
}
throw new RuntimeException('Sin respuesta: deja la orden en revisión y concilia por webhook');
}
try {
$tx = pagarFactura($client, $doc, 'NOVA-ORD-000981', $factura);
echo $tx->data['status'], ' ', $tx->data['approval_number'] ?? '-', PHP_EOL;
} catch (IdempotencyConflictException $e) {
echo 'La factura ya estaba pagada: ', $e->detail, PHP_EOL;
} catch (ValidationException $e) { // 422: INSUFFICIENT_FUNDS, AMOUNT_MISMATCH…
echo 'No se pudo pagar: ', $e->detail, PHP_EOL;
}async Task<Transaction> PagarFacturaAsync(string ordenId, JsonObject factura)
{
string llave = $"nova-bill-{ordenId}"; // misma orden → misma llave, siempre
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: reintenta igual
}
catch (IdempotencyConflictException e) when (e.Problem.Type.EndsWith("/idempotency-conflict", StringComparison.Ordinal))
{
// el intento anterior sigue en curso
}
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, intento)));
}
throw new InvalidOperationException("Sin respuesta: deja la orden en revisión y concilia por 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($"La factura ya estaba pagada: {e.Detail}"); }
catch (ValidationException e) { Console.WriteLine($"No se pudo pagar: {e.Detail}"); }pagarFactura := func(ordenID string, factura map[string]any) (*compense.Transaction, error) {
llave := "nova-bill-" + ordenID // misma orden → misma llave, siempre
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 o error de transporte
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("sin respuesta, concilia por webhook: %w", ultimo)
}
tx, err := pagarFactura("NOVA-ORD-000981", factura)
switch {
case compense.IsIdempotencyConflict(err):
fmt.Println("La factura ya estaba pagada:", err)
case compense.IsValidationError(err):
fmt.Println("No se pudo pagar:", err)
case err != nil:
log.Fatal(err)
default:
fmt.Println(tx.Data["status"], tx.Data["approval_number"], tx.IdempotentReplay)
}# Reintenta exactamente este comando (misma Idempotency-Key) si no obtienes respuesta
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"}}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"))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. 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).
{"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"]) # tu lógica
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); // tu lógica
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']); // tu lógica
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"]) // tu lógica
}
registrarProcesado(ev.ID)
return nil
}))# Envía un evento de prueba a tu endpoint para validar el receptor (llega con 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"}'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 comprobante | Campo de bills.get_payment |
|---|---|
| Facturador | biller.name |
| Referencia | reference |
| Valor | amount + currency |
| Número de aprobación | approval_number |
| Fecha | completed_at |
| Id del movimiento | transaction_id |
| Estado | status / 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 probar | Haz esto |
|---|---|
| Pago aprobado | EPM / 123456789 con el titular 1000000001 (valor de la consulta: 90.000) |
| Factura ya pagada | Cualquier referencia terminada en 0 (por ejemplo 1234567890) → 409 BILL_ALREADY_PAID en la consulta y en el pago |
| Sin saldo | Titular 1000000002 (saldo 0) o 1000000004 (saldo 100) → 422 INSUFFICIENT_FUNDS |
| Valor equivocado | Paga un valor distinto al de la consulta → 422 AMOUNT_MISMATCH |
| Facturador inexistente | biller_code que no sea EPM, ACUEDUCTO, VANTI o ETB → 404 |
| Reintento seguro | Repite 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-Keysale del id de tu orden y se guarda con ella. - Pagas el
amountde la consulta, nunca un valor digitado. - Distingues los
409portype:…/upstream-error(ya pagada) frente a…/idempotency-conflict(llave en curso). - Ante timeout o
5xxreintentas 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.