Docs
Idempotency
Anything that moves money or provisions service takes a client-supplied request_id. Reuse the same one on every retry of one intent, or you pay twice.
Networks drop responses. A charge that succeeded and whose reply never arrived looks exactly like a charge that never happened, and the client's instinct — try again — is the one action that turns one problem into two.
X-Radius answers this the only way that actually works: you name the intent, and the server enforces that a named intent happens at most once.
The one rule
Reuse the same request_id on every retry of the same intent.
Generate one UUID when you decide to perform the operation, not when you send the HTTP request. Keep it for the lifetime of that intent — across timeouts, across reconnects, across a process restart if your queue survives one. A retry carrying a fresh id is not a retry. It is a second, unrelated instruction to charge the wallet, and the server will carry it out, because that is exactly what you asked for.
REQ=$(uuidgen)
# First attempt. The connection drops; you never see the reply.
curl -sS -X POST https://acme.example.com/api/v1/users/4711/deposit \
-H "Authorization: Bearer $XRADIUS_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"amount\": 250.00, \"request_id\": \"$REQ\"}"
# Retry. SAME id. The subscriber is credited once, in total.
curl -sS -X POST https://acme.example.com/api/v1/users/4711/deposit \
-H "Authorization: Bearer $XRADIUS_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"amount\": 250.00, \"request_id\": \"$REQ\"}"
Where the guarantee actually lives
It is not a middleware and it is not a cache. It is a database constraint, hand-placed in each domain on the table that records the thing having happened:
card_redemptionsisUNIQUE (tenant_id, request_id)— the row that says a voucher was spent.user_activationsisUNIQUE (tenant_id, request_id)— the row that says a plan was provisioned.manager_journalisUNIQUE (tenant_id, request_id)— the ledger line that says money moved.
That placement is the point. The uniqueness is on the record of the effect, in the same transaction as the effect, so "already charged" and "charge recorded" cannot disagree. A cache in front of the handler would be a second opinion, and second opinions drift.
Operations that compose several effects derive sub-keys from yours with a :
separator — an activation paid by card writes its wallet leg, its card leg and
its provisioning row under keys derived from the one id you sent, so each step
is independently replay-safe while the whole remains one intent.
That is also why your key may not contain : or |, and may not start with
one of the reserved internal prefixes. A key that does is refused with 400
and details.reason of request_id_reserved. Keys are capped at 255
characters (request_id_too_long), and a missing one on an endpoint that
requires it is request_id_required. A v4 UUID satisfies all three without
thinking about it, which is why every shipped client mints one.
Which operations require it
Every endpoint that moves money, consumes stock or provisions service takes a
required request_id in the body:
| Area | Endpoints |
|---|---|
| Subscriber wallet | POST /users/{id}/deposit, /withdraw, /pay-debt |
| Activation | POST /users/{id}/activation |
| Add-ons and points | POST /users/{id}/addons, /redeem-points |
| Refunds | POST /users/{id}/refund-activation, POST /admin/cards/redemptions/{id}/reversals |
| Cards | POST /cards/redeem, /admin/cards/redeem-otc, /cards/redeem-create, /admin/cards/redeem-to-wallet |
| Card stock | POST /card-batches, /card-batches/{id}/regenerate, the three transfer endpoints, /card-templates/{id}/generate |
| Manager wallet | POST /managers/{id}/wallet/deposit, /withdraw, /pay-debt, /topup |
| Point of sale | POST /pos/sales |
| Payments | the gateway initiation endpoints, which require a UUID specifically |
A second group accepts one and mints a server-side UUID when you omit it — export jobs and backups. That is safe for them and useless to you: a server-minted key is unique per request, not per intent, so it dedupes nothing across your retries. Supply your own if you care.
request_id is never a query parameter and never a header on this API. It is
always a field in the JSON body.
What a replay returns
A replay is a success, not an error. You get 200 and the original outcome,
re-read from storage rather than recomputed. Nothing is charged, nothing is
provisioned, no quota is granted twice.
How the response says it was a replay varies by domain, and this is the part worth checking against the specific endpoint you are calling:
- Money and activation carry an explicit boolean.
POST /users/{id}/activationreturns"replay": trueonCommitResult; the wallet mutators return it onMoneyResult. Side effects that are not idempotent in their own right — issuing an invoice document, for instance — are skipped precisely because the server checked that flag. - Card series generation signals it with the status code as well as the
body: a fresh
POST /card-batchesis202 Accepted, a replay of it is200 OK, and the body carries"replay": truewith the originalbatch_id. - Card redemption does not signal it at all.
POST /cards/redeemreturns200with the samecard_id,modeandeffect_appliedsnapshot it returned the first time, and no replay flag. There is one visible difference and it reads like a bug if you are not expecting it:new_balanceis absent on a replay. The first response carries the post-credit balance; the replayed one omits the field, because the replay path returns the stored effect without re-reading the wallet. Do not treat a missingnew_balanceas a failed redemption, and do not retry because of it — read the balance from the subscriber record if you need it.
When a key is refused
A 409 ERR_CONFLICT with details.reason of request_id_conflict means the
key is already bound to a different subject — another card, another
subscriber, another manager's journal line. It is neither a replay of your
request nor something the server can insert over.
This is not a retryable condition, and it is not a transient one. It means your
id generation has collided, most often because a key was derived from something
that is not unique per intent: an order number reused across a test and a
production run, a timestamp at second resolution, a counter that reset. Fix the
generator. Retrying with the same key will return the same 409 forever, and
minting a fresh key would perform the operation a second time.
Activation has its own variant: activation_request_conflict, when the key is
already bound to an activation of a different subscriber. Idempotency keys are
not transferable between subjects.
A pattern that works
- Decide to perform the operation. Mint a UUID and persist it alongside your own record of the intent, before the first HTTP call.
- Send. On a
2xx, record the outcome and you are done. - On a timeout, a dropped connection, a
5xx, orERR_TIMEOUT, send the same bytes again with the samerequest_id. Repeat with backoff. - On
ERR_VALIDATIONorERR_CONFLICT, stop. The request is wrong, not unlucky; the same bytes will fail identically. - If you genuinely do not know whether an operation ever left your process, send it. That is what the key is for.
The failure mode this prevents is worth naming plainly: a client that mints a fresh id per HTTP attempt, retrying a deposit whose response it never saw, credits the wallet twice and reconciles clean on both sides. Nothing in the ledger will look wrong. Only the balance will be.
Last updated