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_redemptions is UNIQUE (tenant_id, request_id) — the row that says a voucher was spent.
  • user_activations is UNIQUE (tenant_id, request_id) — the row that says a plan was provisioned.
  • manager_journal is UNIQUE (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}/activation returns "replay": true on CommitResult; the wallet mutators return it on MoneyResult. 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-batches is 202 Accepted, a replay of it is 200 OK, and the body carries "replay": true with the original batch_id.
  • Card redemption does not signal it at all. POST /cards/redeem returns 200 with the same card_id, mode and effect_applied snapshot 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_balance is 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 missing new_balance as 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

  1. Decide to perform the operation. Mint a UUID and persist it alongside your own record of the intent, before the first HTTP call.
  2. Send. On a 2xx, record the outcome and you are done.
  3. On a timeout, a dropped connection, a 5xx, or ERR_TIMEOUT, send the same bytes again with the same request_id. Repeat with backoff.
  4. On ERR_VALIDATION or ERR_CONFLICT, stop. The request is wrong, not unlucky; the same bytes will fail identically.
  5. 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

Ask a question

Try it on your own network.

50 subscribers for 7 days. You pay nothing.