Guides

Idempotency Keys in a Stablecoin API: Never Pay Twice

An idempotency key makes a payout retry safe: same key, same result, no second payment. How to generate, store and handle 409s, with the Lumx behavior.

Caio Barbosa

Founder & CO-CEO

Forbes Under 30. One of the leading voices in Fintech & Crypto in Brazil. Writes weekly about stablecoins, payments, and the future of financial infrastructure in Latin America.

Cover image for Lumx blog article: Idempotency Keys in a Stablecoin API: Never Pay Twice
Cover image for Lumx blog article: Idempotency Keys in a Stablecoin API: Never Pay Twice

An idempotency key is a unique value a client sends with a request that moves money, so a retry of the same request returns the first result instead of running a second time. In a stablecoin API it separates a safe retry after a timeout from a duplicate payout.

This guide covers where duplicates come from, how to store the key, what each retry returns, and the layers beside it. The category is defined in what a stablecoin API is, and the order of a full build is in the seven-step integration guide. Behavior described for Lumx was read from the Lumx docs on November 1, 2026.

Why duplicate payouts happen without anyone clicking twice

A payout request can succeed on the server and still look like a failure to the caller. The API creates the payout, starts the transfer, and the connection drops before the response arrives. The HTTP client throws a timeout, the retry logic does what it was built to do, and the same payout goes out again. Without a key, the server cannot tell a retry from a new order.

Retries are not the only source. A queue that redelivers a job after a worker restart, a user who taps pay twice, a batch that is rerun after a partial failure and a webhook handler that credits a customer every time the same event arrives all end the same way: one business intent, two executions. Each has a different fix, so the key is one layer, not the whole defense.

Once a payout has settled to the recipient's account or wallet, an API call cannot undo it, so the duplicate has to be prevented before it is sent.

On Lumx, a payout is a POST to /transactions/off-ramp that answers 202 with the transaction and its state. If that response is lost, the caller holds no transaction id until it looks the payout up.

One key per business intent, stored before the first call

The unit of an idempotency key is the business intent, not the HTTP call. One payout order in your system gets one key, and every attempt to execute that order reuses it. A key minted inside a retry loop is the same as no key, because each pass produces a new one.

The Lumx docs ask for a UUID v4 per unique operation, so the key is random and not derived from the order number. That means it has to be stored. Generate it when the payout row is created, write it in the same database transaction, and read it back on every attempt.

Store four things beside it:

  • the exact request body you sent;

  • the state of the attempt (pending, sent, answered);

  • the transaction id once you have one;

  • your own reference, such as an order id.

Serialize the body once and send the same bytes on each attempt. Whether a provider compares raw bytes or parsed JSON, identical bytes is the safe choice, and a reordered field never turns a harmless retry into a conflict.

The own reference matters because it lets you find the transaction without the key. Lumx accepts a metadata object on on-ramp, off-ramp and transfer requests, any valid JSON object, so an orderId field travels with the transaction and comes back in every read.

On Lumx the header is Idempotency-Key, a UUID v4, accepted on POST, PUT and PATCH, and optional. Nothing forces a client to send it, so a payout service should refuse to send a money-moving request that has no key.

What happens when you retry a payout with the same key?

Eight situations cover almost every retry. The right side is what the client should do, because the response alone does not tell you.

Retry situations for a money-moving request, what Lumx returns, and what the client does.

Situation

What Lumx returns

What your code does

First request with a new key

Processes normally and caches the response

Save the transaction id, mark the attempt answered

Same key, same body, first request finished

The cached response with the X-Idempotency-Cached: true header

Treat it as the original answer. Nothing ran twice

Same key, different body

409 IDEMPOTENCY_KEY_CONFLICT

A bug in your code. Do not retry. Alert and compare with the stored body

Same key while the first request is still processing

409 IDEMPOTENCY_KEY_IN_FLIGHT

Keep the key and wait, then read the transaction by your reference. Do not mint a new key

Same key after the first request returned an error

The same error, because failed responses are cached too

Fix the cause. Look up whether a transaction exists before sending under a new key

Same key after 24 hours

Treated as a new request, and the key can be reused

Check your ledger and the transaction list before sending again

No key sent

Each request runs as its own operation

Nothing links the retry to the original. Add the key

Timeout with no response at all

Unknown

Retry with the same key and the same body

A cached response means the second call did not debit a wallet or start a second transfer. The X-Idempotency-Cached: true header lets the client log that the answer came from the cache.

The errors page also marks TIMEOUT_ERROR, NETWORK_ERROR and SERVICE_UNAVAILABLE, all 503, as safe to retry, and INVALID_REQUEST (400) as not retryable. Branch on the code, never on the message. A 5xx response is cached under the key like any other failed response, so a retry with the same key returns the same error, flagged with X-Idempotency-Cached: true, and that retry never ran. Read the transaction by your reference, and send under a new key stored against the same payout row only when none exists.

Lumx marks a replay with X-Idempotency-Cached: true, and both 409s come back in the standard error body, so the code field is what the client branches on.

Handling a 409: conflict versus in flight

Both 409s look alike and need opposite reactions.

IDEMPOTENCY_KEY_CONFLICT means the key was seen before with a different body. A human or a bug changed the amount, the destination or the purpose between attempts. Retrying cannot fix it, and sending the new body under a new key may pay twice if the first one went through. Stop, alert, and read the stored first body.

IDEMPOTENCY_KEY_IN_FLIGHT means the first request is still processing. The correct reaction is to wait and look, not to retry harder and never to generate another key. A fresh key is the one move that turns an in-flight request into a duplicate.

I recommend one rule for any payout service: a payout row cannot move to sending until it holds a key, and the key is written in the same database transaction that creates the row. I would also never let an error handler replace a key automatically, because swapping keys to get past a stuck request is how one payout becomes two. When a retry comes back as in flight, I wait and read the transaction, and I only hand it to a person when the 24 hours are close. That is slower than blind retries, and I prefer it: a payout that arrives late is a support ticket, and one that arrives twice is a recovery project.

The 409 body on Lumx carries a requestId beside the code and the status, and the requestId is the value to keep for support.

The 24-hour window and what protects you after it

Idempotency keys on Lumx expire after 24 hours, and after that the same key can be reused for a new request. A retry that happens a day later is therefore a new payout, not a replay. A dead-letter queue replayed after a long weekend can send a stale request days later.

Three protections sit behind the key:

  • Set a retry budget shorter than the window. Stop automated retries well before 24 hours and hand the payout to a person.

  • Keep the transaction id. Once any attempt returned one, read GET /transactions/{id} instead of sending again.

  • Search by your own reference when no attempt ever got an answer. The transactions list filters by customer, and you match the orderId in the metadata on your side, paging with the cursor.

A locked exchange rate adds a check for free. A rate already consumed by another transaction returns 422 EXCHANGE_RATE_ALREADY_USED, so a second off-ramp on the same locked rate fails by itself. It covers only locked quotes, not floating-rate payouts.

For how those lookups feed the books, see reconciling stablecoin transactions. For the status each payout can show, see payout statuses by rail.

The Lumx transactions list takes customerId, size (up to 100) and cursor, and every transaction in it carries the metadata you attached at creation.

Webhook redelivery is the second source of duplicates

The key protects the calls you make. Duplicates also arrive in the calls you receive. If your endpoint does not answer 2xx, the platform delivers the event again, and a handler that credits a balance or releases an order on each delivery will do it twice.

The fix is the mirror image of the key: store the delivery id before any side effect, skip what you have seen, and answer 2xx fast so the retry never starts. Then treat the payload as a notification and confirm the state with a read before acting. A late or out-of-order event never overrides what the API says now. Signature checks and the full handler are in stablecoin API webhooks, and the webhook integration prompt gives a coding agent the exact build.

On Lumx, a non-2xx response triggers up to eight delivery attempts with exponential backoff, from immediately to a 10-hour delay on the last two. After that the message is marked failed and can be retried from the Dashboard. Each request carries a webhook-id header for deduplication, and the body carries an eventId.

How do you test idempotent retries before production?

Five tests cover the behavior, and each one forces a case that production will eventually produce.

  1. Send a payout with a key, then resend the same key and body. Expect the cached response and the X-Idempotency-Cached: true header, and one transaction in the list.

  2. Resend the same key with a changed amount. Expect IDEMPOTENCY_KEY_CONFLICT, and confirm your code alerts instead of retrying.

  3. Fire two identical requests at once. Depending on timing, one may come back IDEMPOTENCY_KEY_IN_FLIGHT, and your code must wait without minting a key.

  4. Kill the worker between send and response, restart it, and confirm it reuses the stored key.

  5. Deliver the same webhook event twice to your handler and confirm it acts once.

Repeat tests 1 and 2 with one small real payout before go-live, because only production exercises the full chain from your service to the recipient's bank.

Lumx runs a sandbox at https://api-sandbox.lumx.io, so these tests can run there before you move to the production API.

When an idempotency key is the wrong tool

A key is a safety net for one request, and it does the wrong job in four cases.

  • Two intents, one payee. If the same invoice is entered twice, your system creates two order ids and two keys, and both payouts run. That duplicate needs a business check for the same invoice number, or the same payee and amount, or an approval step.

  • Reads. A GET is already safe to repeat, so a key adds nothing.

  • Changed requests. Reusing a key after correcting the amount returns a conflict. A corrected payout is a new intent and gets a new key, once you have confirmed the first one did not go through.

  • Long gaps. After 24 hours a key proves nothing, and the ledger and the transaction lookup take over.

Lumx ties retries of one request to one result under a key. It does not compare different requests, so two order ids sent under two keys are two separate payouts.

What this looks like on a Lumx payout run

Lumx is stablecoin payments infrastructure for businesses that move money between Latin America and the rest of the world: one API to collect, hold, convert, and pay out in BRL, MXN, COP, USD, EUR, and GBP or in USDC and USDT, over local rails such as PIX, SPEI, PSE, Bre-B, ACH, FEDWIRE, SEPA, and Faster Payments, with SWIFT and on-behalf-of payments and collections (POBO and COBO) in USD, EUR, and GBP, plus named virtual accounts, custodial wallets, and KYB/KYC built in.

A payout run on the API has a short shape. Register the destination once and keep its id. Create the payout with POST /transactions/off-ramp, using the destination id, the source currency and amount (or a locked exchangeRateId), the purpose, and a metadata object holding your order id. Send an Idempotency-Key header with the UUID v4 you stored on your payout row before the call. The API answers 202, and the status then moves through TRANSFERRING_STABLECOIN, TRADING and TRANSFERRING_FIAT to SUCCESS, with an offramp.* webhook at each step and offramp.failed if it fails. The same header works on POST /transactions/on-ramp and POST /transactions/transfer, and the docs recommend it on customer creation as well. The header arrived in the API in version 2.5.0, released on February 13, 2026, and the Lumx API changelog records later changes.

The one case that surprises teams is a failed payout that you then fix. If a payout fails with INSUFFICIENT_BALANCE (422) and you fund the wallet, a retry under the same key returns the same error, because the docs say failed responses are cached. Send the corrected attempt under a new key stored against the same payout row, after checking that no transaction exists for your reference. The same applies to a rejected destination or a purpose mismatch: the reasons are listed in why stablecoin payouts get rejected.

Teams moving payouts from another provider can run both for one cycle with the key on every mutation, as in migrating a payout provider. The product side is on the global payments page.

Methodology and sources

This guide was checked on November 1, 2026 against the Lumx docs pages Idempotency, Errors, Webhooks, Transactions, the Off-ramp, Transfer and Read all transactions API references, the off-ramp and webhook prompts, and the Changelog entry for version 2.5.0. The primary source is the Lumx idempotency page. One key per intent, storing the key before the call and identical bytes on retry are practice, not Lumx rules.

Verified on November 1, 2026. Operational context, not legal, tax, or investment advice.

Cover photo: Steven Lewis on Unsplash.

  • What are idempotency keys in a stablecoin API?

    An idempotency key is a unique value sent with a request that changes state, such as a payout, so that a retry returns the original result instead of running the action again. It turns an unknown outcome after a timeout into a safe retry. On Lumx, you send an Idempotency-Key header with a UUID v4 on POST, PUT or PATCH requests, and the header is optional.

  • How long does an idempotency key last?

    It depends on the provider, and the useful number is the window after which a retry stops being protected. A retry queue that can stall for a day needs a check against your own ledger first. On Lumx, keys expire after 24 hours, and after that the same key can be reused for a new request.

  • What happens if I reuse a key with a different request body?

    The API refuses the request instead of running it, because the same key has to mean the same operation. Treat it as a bug in your code, do not retry, and compare the new body with the one you stored. On Lumx, the response is a 409 with the code IDEMPOTENCY_KEY_CONFLICT.

  • What should I do when a payout request times out?

    Retry with the same key and the same body, because a timeout leaves the outcome unknown and the key makes the retry safe. If the answer is IDEMPOTENCY_KEY_IN_FLIGHT, wait and read the transaction rather than creating a new key. On Lumx, a replayed answer carries the header X-Idempotency-Cached: true, and an in-flight request returns a 409 with the code IDEMPOTENCY_KEY_IN_FLIGHT.

  • Do webhooks need idempotency handling too?

    Yes. Webhook delivery is at least once, so the same event can arrive more than once, and the handler must act on it only once. Store the delivery id and skip repeats. On Lumx, a non-2xx response triggers up to eight delivery attempts, and the webhook-id header is the value to deduplicate on.

Stay up to date with what Lumx is developing.

Sign up to receive them by email.

Share on socials:

idempotency-keys-stablecoin-api

A

idempotency-keys-stablecoin-api

Idempotency Keys in a Stablecoin API: Never Pay Twice

Copy link

Copied!

idempotency-keys-stablecoin-api

TALK TO OUR TEAM

Ready to transform your business with stablecoins?

Discover how our infrastructure can seamlessly integrate stablecoins into your financial operations quickly, securely, and efficiently.

Guides

In this page

©2026. All rights reserved.

Lumx US OP LLC ("Lumx") is a financial technology and payments infrastructure company. Lumx US OP LLC is a Money Service Business (MSB) registered with the Financial Crimes Enforcement Network (FinCEN) (MSB #31000316459619). Lumx is not a state-licensed money transmitter and does not, in its own capacity, engage in the provision of regulated money transmission services. All such regulated activities are conducted exclusively through, and under the licenses of, duly authorized financial-institution partners.


Lumx is not a bank, financial institution, payment institution, or custodian of client funds. Certain services made available through the Platform may be provided by duly authorized and regulated third-party partners, in accordance with applicable laws and regulations.

Please refer to Lumx’s Terms of Use and Privacy Notice for further information regarding the conditions governing the use of the Platform and the processing of your personal data.