Conceptos
Idempotencia
Por qué cada escritura lleva Idempotency-Key, qué pasa cuando la repites (respuesta guardada o 409), cómo elegir llaves estables por operación de negocio y cómo la manejan los cinco SDK.
En esta página
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.
- La primera petición con una llave se ejecuta y el gateway guarda la respuesta.
- Una petición repetida con la misma llave, cuando la original ya terminó, no se ejecuta:
recibes la respuesta guardada, con el mismo
transaction_idy"idempotent_replay": true. - Si la repites mientras la original sigue en proceso, recibes
409contype: https://errors.compense.co/gateway/idempotency-conflict. Espera un momento y repite: obtendrás la respuesta guardada.
Qué operaciones la llevan #
| Tipo de operación | Idempotency-Key | Ejemplos |
|---|---|---|
| Escrituras que mueven dinero | Obligatoria: sin ella, 400 | POST/v1/gateway/wallet/p2p/transfers/
POST/v1/gateway/wallet/breb/transfers/
POST/v1/gateway/wallet/bills/pay/
POST/v1/gateway/wallet/topups/ |
| Resto de escrituras | Recomendada (los SDK siempre la envían) | radicar y desembolsar créditos, registrar recaudos, crear endpoints, novedades de nómina… |
Lecturas (GET) | No aplica | saldos, listas, detalles |
POST que solo consultan | No aplica | resolver 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)const orderId = "000123";
const tx = await client.wallet.topups.create({
document: "1032456789", phoneNumber: "3001234567", carrier: "CLARO", amount: "20000",
idempotencyKey: `nova-topup-2026-11-02-${orderId}`,
});
if (tx.idempotentReplay) console.log("Ya estaba hecha:", tx.transactionId);$orderId = '000123';
$tx = $client->wallet->topups->create(
document: '1032456789', phoneNumber: '3001234567', carrier: 'CLARO', amount: '20000',
idempotencyKey: "nova-topup-2026-11-02-{$orderId}",
);
if ($tx->idempotentReplay) {
echo 'Ya estaba hecha: ', $tx->transactionId, PHP_EOL;
}var orderId = "000123";
Transaction tx = await client.Wallet.Topups.CreateAsync(
"1032456789", "3001234567", "CLARO", "20000",
idempotencyKey: $"nova-topup-2026-11-02-{orderId}");
if (tx.IdempotentReplay) Console.WriteLine($"Ya estaba hecha: {tx.TransactionId}");orderID := "000123"
tx, err := client.Wallet.Topups.Create(ctx, &compense.TopupParams{
Document: "1032456789", PhoneNumber: "3001234567", Carrier: "CLARO", Amount: "20000",
}, compense.WithIdempotencyKey("nova-topup-2026-11-02-"+orderID))
if err != nil {
log.Fatal(err)
}
if tx.IdempotentReplay {
fmt.Println("ya estaba hecha:", tx.TransactionID)
}curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/wallet/topups/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: nova-topup-2026-11-02-000123" \
-H "Content-Type: application/json" \
-d '{"document":"1032456789","phone_number":"3001234567","carrier":"CLARO","amount":"20000"}'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 intentoimport { CompenseConnectionError, IdempotencyConflictError } from "@compense/sdk";
const key = "nova-bill-2026-11-02-000123";
for (let attempt = 0; attempt < 4; attempt++) {
try {
await client.wallet.bills.pay({ document: "1032456789", billerCode: "EPM", reference: "123456789",
amount: "90000", idempotencyKey: key });
break;
} catch (err) {
const inFlight = err instanceof IdempotencyConflictError && err.problem.type.endsWith("/idempotency-conflict");
if (!(err instanceof CompenseConnectionError) && !inFlight) throw err;
await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
}
}use Compense\Sdk\Exception\IdempotencyConflictException;
use Compense\Sdk\Exception\TransportException;
$key = 'nova-bill-2026-11-02-000123';
for ($attempt = 0; $attempt < 4; $attempt++) {
try {
$client->wallet->bills->pay(document: '1032456789', billerCode: 'EPM',
reference: '123456789', amount: '90000', idempotencyKey: $key);
break;
} catch (TransportException | IdempotencyConflictException $e) {
if ($e instanceof IdempotencyConflictException
&& !str_ends_with($e->problem->type, '/idempotency-conflict')) {
throw $e; // rechazo de negocio
}
sleep(2 ** $attempt);
}
}const string key = "nova-bill-2026-11-02-000123";
for (var attempt = 0; attempt < 4; attempt++)
{
try
{
await client.Wallet.Bills.PayAsync("1032456789", "EPM", "123456789", "90000", idempotencyKey: key);
break;
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException
|| (e is IdempotencyConflictException c && c.Problem.Type.EndsWith("/idempotency-conflict")))
{
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));
}
}key := "nova-bill-2026-11-02-000123"
for attempt := 0; attempt < 4; attempt++ {
_, err := client.Wallet.Bills.Pay(ctx, &compense.BillPayParams{
Document: "1032456789", BillerCode: "EPM", Reference: "123456789", Amount: "90000",
}, compense.WithIdempotencyKey(key))
var apiErr *compense.APIError
if err == nil {
break
}
if errors.As(err, &apiErr) && !strings.HasSuffix(apiErr.Problem.Type, "/idempotency-conflict") {
log.Fatal(err) // error HTTP de negocio: no reintentar
}
time.Sleep(time.Duration(1<<attempt) * time.Second) // transporte o 409 en curso
}for i in 0 1 2 3; do
curl -sf --max-time 30 -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-2026-11-02-000123" \
-H "Content-Type: application/json" \
-d '{"document":"1032456789","biller_code":"EPM","reference":"123456789","amount":"90000"}' && break
sleep $((2 ** i))
done409 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.type | Significa | Qué hacer |
|---|---|---|
…/gateway/idempotency-conflict | La 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 responde409 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).