On a real network, requests get cut off: a timeout, a load balancer that restarts, your process dying right after sending. At that point you don’t know whether Compense executed the operation. If you retry blindly you might disburse twice; if you don’t retry, you might leave a payment undone. The Idempotency-Key resolves that dilemma: retrying with the same key is always safe.

How it works #

sequenceDiagram
  autonumber
  participant C as Your system
  participant G as Gateway
  participant M as Engine
  C->>G: POST /wallet/bills/pay/ · Idempotency-Key: nova-bill-000123
  G->>M: pay bill
  M-->>G: paid
  G--xC: (the response is lost: timeout)
  Note over C: You don't know if it was paid.<br/>You retry with the SAME key.
  C->>G: POST /wallet/bills/pay/ · Idempotency-Key: nova-bill-000123
  G-->>C: 200 stored response · idempotent_replay: true
  Note over C,G: No second charge. Same transaction_id.
  1. The first request with a key is executed and the gateway stores the response.
  2. A repeated request with the same key, once the original has finished, isn’t executed: you get the stored response, with the same transaction_id and "idempotent_replay": true.
  3. If you repeat it while the original is still being processed, you get a 409 with type: https://errors.compense.co/gateway/idempotency-conflict. Wait a moment and repeat it: you’ll get the stored response.

Which operations carry it #

Operation typeIdempotency-KeyExamples
Writes that move moneyRequired: without it, 400POST/v1/gateway/wallet/p2p/transfers/ POST/v1/gateway/wallet/breb/transfers/ POST/v1/gateway/wallet/bills/pay/ POST/v1/gateway/wallet/topups/
All other writesRecommended (the SDKs always send it)submitting and disbursing credits, registering collections, creating endpoints, payroll changes…
Reads (GET)Not applicablebalances, lists, details
POSTs that only queryNot applicableresolving P2P/Bre-B, looking up a bill, quoting FX, calculating taxes, sandbox simulators

The API reference marks each operation as required, recommended or not applicable.

How to choose the key #

  • One key per business operation, not per attempt. Derive it from something stable on your side: the application number, the payment id in your ERP, the order id in your app (horizonte-cred-2026-000123, nova-topup-2026-11-02-000123). That way, even if your process restarts, the retry uses the same key.
  • Never reuse a key for a different operation: you’d get the response of the first one.
  • Use ASCII characters with no spaces (letters, digits, -, _); a UUID works too.
  • Store the key alongside your operation’s record until you know the final result.

In the SDKs #

If you don’t pass a key, the SDK generates a UUIDv4 per call. That protects the automatic retries the SDK itself makes (it reuses the same key on each attempt), but not the retries you make yourself after the process fails: for those, pass a stable key.

order_id = "000123"   # order id in your system

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("Already done; not charged again:", tx.transaction_id)

Retrying after a network error #

When the request fails with no HTTP response (timeout, connection refused), resend it yourself with the same key. The SDKs don’t automatically retry transport errors, and by default they don’t retry writes either (Retries).

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                       # business 409 (e.g. BILL_ALREADY_PAID): not a retry
        time.sleep(2 ** attempt)        # same key on every attempt

A 409 isn’t always idempotency #

All five SDKs turn every 409 into the idempotency conflict exception (IdempotencyConflictError / IdempotencyConflictException / compense.ErrIdempotencyConflict). But the engine also uses 409 for business rejections: BILL_ALREADY_PAID, SLOT_NOT_AVAILABLE, REFERENCE_REUSED, PERIOD_CLOSED… Tell them apart by the problem’s type:

problem.typeMeaningWhat to do
…/gateway/idempotency-conflictThe original request with that key is still being processed.Wait and repeat with the same key.
Other (…/upstream-error, about:blank)Business rejection from the engine; the message is in detail.Don’t retry; resolve the case.

Engine natural keys #

Some modules also deduplicate on a business natural key. Use them together with the Idempotency-Key:

  • Loans: a collection is identified by credit + channel + reference; repeating the reference with a different amount responds 409 REFERENCE_REUSED (Loans).
  • Healthcare billing: a payer’s payment is identified by method + reference (Health).

Webhooks: the secret isn't repeated

Repeating create endpoint or rotate secret returns the endpoint without secret, because the gateway doesn’t store secrets. If you lost the one from the first response, rotate it (Rotate the secret).