Concepts
Idempotency
Why every write carries an Idempotency-Key, what happens when you repeat it (stored response or 409), how to choose stable keys per business operation, and how the five SDKs handle it.
On this page
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.
- The first request with a key is executed and the gateway stores the response.
- A repeated request with the same key, once the original has finished, isn’t executed:
you get the stored response, with the same
transaction_idand"idempotent_replay": true. - If you repeat it while the original is still being processed, you get a
409withtype: https://errors.compense.co/gateway/idempotency-conflict. Wait a moment and repeat it: you’ll get the stored response.
Which operations carry it #
| Operation type | Idempotency-Key | Examples |
|---|---|---|
| Writes that move money | Required: without it, 400 | POST/v1/gateway/wallet/p2p/transfers/
POST/v1/gateway/wallet/breb/transfers/
POST/v1/gateway/wallet/bills/pay/
POST/v1/gateway/wallet/topups/ |
| All other writes | Recommended (the SDKs always send it) | submitting and disbursing credits, registering collections, creating endpoints, payroll changes… |
Reads (GET) | Not applicable | balances, lists, details |
POSTs that only query | Not applicable | resolving 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)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("Already done:", 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 'Already done: ', $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($"Already done: {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("already done:", 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"}'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 attemptimport { 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; // business rejection
}
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) // business HTTP error: don't retry
}
time.Sleep(time.Duration(1<<attempt) * time.Second) // transport or in-flight 409
}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))
doneA 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.type | Meaning | What to do |
|---|---|---|
…/gateway/idempotency-conflict | The 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 responds409 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).