Idempotency
Every money-moving POST takes an Idempotency-Key, so a timeout is safe to retry and a replay returns the first response byte for byte.
Networks fail after the server has already acted. Your request created a transfer, the response never arrived, and now your code has to decide: retry and risk sending twice, or give up and risk not sending at all. Idempotency keys remove the choice.
Every money-moving POST requires an Idempotency-Key header. It is not
optional. A request without one is rejected before anything happens.
Idempotency-Key: transfer-order-8891What a replay does
Once an operation has committed its stored result, a replay returns that response body and status code. The domain rows, outbox events and stored response commit together in walletd. The accounting posting commits separately in ledgerd: no database transaction spans the two services.
A timeout or failure can therefore occur after the ledger accepted a posting but before walletd recorded the response. Money paths use deterministic posting keys and stored-result probes to recover that result on replay. A 5xx does not prove that no money moved. Keep the original request and key, retry within a bounded budget, and escalate an unresolved result through your agreed support channel; do not create a replacement payment with a new key.
That is what makes a retry safe. If you do not know whether a request succeeded, send it again with the same key.
Choosing a key
A key must be stable for the operation and unique across operations. Derive it from your business object and integration namespace, or persist a once-generated UUID.
| Key | Verdict |
|---|---|
payout-invoice-8891 | Good. Retries reuse it; a different invoice cannot collide |
transfer-{your_order_id} | Good |
topup-{user_id}-{cart_id} | Good |
uuid4() generated per attempt | Broken. Every retry gets a new key, so every retry moves money |
transfer-{timestamp} | Broken, same reason |
transfer | Broken. Your second, unrelated transfer is refused as a replay |
Generate the key when you decide to do the thing, persist it alongside the business object, and reuse it for every attempt including ones after a process restart.
Keys are scoped per tenant. User-token calls add a user namespace, but machine and staff calls retain the supplied key. Merchant-bound API keys do not add a merchant namespace. Include your merchant or integration identifier, operation and business-object identifier, for example merchant-42-transfer-order-8891. Two merchants using only order-8891 can collide within the same tenant.
A random UUID is also suitable if you generate it once, persist it with the operation and reuse it. The error is generating a new value for each attempt. Idempotency records currently have no automatic expiry or cleanup; do not assume a key becomes reusable after 30 days.
Same key, different body
A replay must be the same request. WalletD stores a hash of the operation and its parameters with the key. Reuse a key with a different body and you get:
{
"type": "about:blank",
"title": "Conflict",
"status": 409,
"code": "idempotency_key_reuse",
"detail": "idempotency key reused with different parameters"
}That is a bug in your code, not a transient failure. Retrying will not fix it. It usually means a key derived from something too coarse: two different amounts sharing one order id, for example.
Retrying correctly
Retry on 429, on 5xx, and on a network timeout. Always with the same key. Back off exponentially with jitter, and respect Retry-After when it is present.
Do not retry on other 4xxs: they mean the request itself is wrong, and it will be just as wrong the second time.
In each language
The pattern is the same everywhere: pass the key from your business object.
curl -sS -X POST "$WALLETD_API/v1/transfers" \
-H "Authorization: Bearer $WALLETD_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: transfer-order-8891" \
-d '{"from_user":"'$SENDER'","to_user":"'$RECIPIENT'","amount":1500}'Which calls need a key
Anything that moves money or creates a financial object: transfers, top-ups, payments, captures, voids, refunds, subscriptions, point conversions, marketing funding, payouts, purchases, and subscribes.
Reads never need one. Configuration changes (fee plans, tier limits, reward rules) do not take one either; they are audited instead, and applying the same configuration twice is harmless by construction.