En una red real las peticiones se cortan: un timeout, un balanceador que reinicia, tu proceso que muere justo después de enviar. En ese momento no sabes si Compense ejecutó la operación. Si reintentas a ciegas puedes desembolsar dos veces; si no reintentas, puedes dejar un pago sin hacer. La Idempotency-Key resuelve ese dilema: reintentar con la misma llave es siempre seguro.

Cómo funciona #

sequenceDiagram
  autonumber
  participant C as Tu sistema
  participant G as Gateway
  participant M as Motor
  C->>G: POST /wallet/bills/pay/ · Idempotency-Key: nova-bill-000123
  G->>M: pagar factura
  M-->>G: pagada
  G--xC: (la respuesta se pierde: timeout)
  Note over C: No sabes si se pagó.<br/>Reintentas con la MISMA llave.
  C->>G: POST /wallet/bills/pay/ · Idempotency-Key: nova-bill-000123
  G-->>C: 200 respuesta guardada · idempotent_replay: true
  Note over C,G: Sin segundo cobro. Mismo transaction_id.
  1. La primera petición con una llave se ejecuta y el gateway guarda la respuesta.
  2. Una petición repetida con la misma llave, cuando la original ya terminó, no se ejecuta: recibes la respuesta guardada, con el mismo transaction_id y "idempotent_replay": true.
  3. Si la repites mientras la original sigue en proceso, recibes 409 con type: https://errors.compense.co/gateway/idempotency-conflict. Espera un momento y repite: obtendrás la respuesta guardada.

Qué operaciones la llevan #

Tipo de operaciónIdempotency-KeyEjemplos
Escrituras que mueven dineroObligatoria: sin ella, 400POST/v1/gateway/wallet/p2p/transfers/ POST/v1/gateway/wallet/breb/transfers/ POST/v1/gateway/wallet/bills/pay/ POST/v1/gateway/wallet/topups/
Resto de escriturasRecomendada (los SDK siempre la envían)radicar y desembolsar créditos, registrar recaudos, crear endpoints, novedades de nómina…
Lecturas (GET)No aplicasaldos, listas, detalles
POST que solo consultanNo aplicaresolver P2P/Bre-B, consultar factura, cotizar FX, calcular impuestos, simuladores del sandbox

La referencia del API marca cada operación como obligatoria, recomendada o no aplica.

Cómo elegir la llave #

  • Una llave por operación de negocio, no por intento. Derívala de algo estable de tu lado: el número de solicitud, el id del pago en tu ERP, el id de la orden en tu app (horizonte-cred-2026-000123, nova-topup-2026-11-02-000123). Así, aunque tu proceso se reinicie, el reintento usa la misma llave.
  • Nunca reutilices una llave para otra operación distinta: recibirías la respuesta de la primera.
  • Usa caracteres ASCII sin espacios (letras, dígitos, -, _); un UUID también vale.
  • Guarda la llave junto al registro de tu operación hasta conocer el resultado final.

En los SDK #

Si no pasas una llave, el SDK genera un UUIDv4 por llamada. Eso protege los reintentos automáticos que haga el propio SDK (reutiliza la misma llave en cada intento), pero no los reintentos que hagas tú después de que el proceso falle: para eso pasa una llave estable.

order_id = "000123"   # id de la orden en tu sistema

tx = client.wallet.topups.create(
    document="1032456789", phone_number="3001234567", carrier="CLARO", amount="20000",
    idempotency_key=f"nova-topup-2026-11-02-{order_id}",
)
if tx.idempotent_replay:
    print("Ya estaba hecha; no se cobró de nuevo:", tx.transaction_id)

Reintentar después de un error de red #

Cuando la petición falla sin respuesta HTTP (timeout, conexión rechazada), reenvíala tú con la misma llave. Los SDK no reintentan solos los errores de transporte, y por defecto tampoco las escrituras (Reintentos).

import time
import httpx
from compense_gateway import IdempotencyConflictError

key = "nova-bill-2026-11-02-000123"
for attempt in range(4):
    try:
        tx = client.wallet.bills.pay(document="1032456789", biller_code="EPM",
                                     reference="123456789", amount="90000", idempotency_key=key)
        break
    except (httpx.TransportError, IdempotencyConflictError) as e:
        if isinstance(e, IdempotencyConflictError) and not e.problem.type.endswith("/idempotency-conflict"):
            raise                       # 409 de negocio (p. ej. BILL_ALREADY_PAID): no es un reintento
        time.sleep(2 ** attempt)        # misma llave en cada intento

409 no siempre es idempotencia #

Los cinco SDK convierten todo 409 en la excepción de conflicto de idempotencia (IdempotencyConflictError / IdempotencyConflictException / compense.ErrIdempotencyConflict). Pero el motor también usa 409 para rechazos de negocio: BILL_ALREADY_PAID, SLOT_NOT_AVAILABLE, REFERENCE_REUSED, PERIOD_CLOSED… Distínguelos por el type del problema:

problem.typeSignificaQué hacer
…/gateway/idempotency-conflictLa petición original con esa llave sigue en proceso.Esperar y repetir con la misma llave.
Otro (…/upstream-error, about:blank)Rechazo de negocio del motor; el mensaje está en detail.No reintentar; resolver el caso.

Llaves naturales del motor #

Algunos módulos deduplican además por una llave natural del negocio. Úsalas junto con la Idempotency-Key:

  • Cartera: un recaudo se identifica por crédito + canal + reference; repetir la referencia con otro monto responde 409 REFERENCE_REUSED (Cartera).
  • Facturación de salud: el pago de un pagador se identifica por método + reference (Salud).

Webhooks: el secreto no se repite

La repetición de crear endpoint o rotar secreto devuelve el endpoint sin secret, porque el gateway no guarda secretos. Si perdiste el de la primera respuesta, rótalo (Rotar el secreto).