Picture this. Your server asks the API to send a payout to a customer. The request goes out, and then the connection drops. You get no answer. Did the payout happen or not?
If you guess "no" and send the request again, you might pay the customer twice. On a blockchain that is final: there is no "undo" button for an on-chain transfer. If you guess "yes" and do nothing, the customer might never get paid.
An idempotency key removes the guess. This guide explains what it is, how to use it well, and the mistakes that still cause double payouts on systems that "support" it.
#What idempotency means
A request is idempotent when sending it once or sending it ten times has the same effect. Reading a balance is naturally idempotent. Sending money is not — unless the API gives you a way to say "this is the same payout as before".
That is the job of the idempotency key. It is a string you create and send with the request. The API remembers it. If the same key comes again, the API does not create a new payout. It returns the one that already exists.
#Where our API uses it
On our platform every request that moves money accepts an idempotency_key in the request body. It is required on:
- withdrawals (on-chain payouts in USDT, USDC or TRX);
- card issuing, card top-ups and card withdrawals.
It is optional, but strongly recommended on swap and exchange orders. Treat it as required there too. A repeated swap is still money moving twice.
Keys belong to your merchant account, so they can never clash with another merchant's keys. They also do not expire, which means a retry days later is still safe.
#Step 1: create the key before the first attempt
The most important rule: one key per intended movement of money, not per HTTP attempt.
A good flow looks like this:
- Your app decides to pay someone. It writes a payout row in your own database.
- It creates the key right then and saves it in that same row.
- Only then does it call the API.
- Every retry of that payout reads the key from the row and sends it again.
A random UUID is a fine default. A key built from your own record id is even better, for example payout-8814 or card-topup-2291. It survives a crash: if your process restarts in the middle of a payout, it can rebuild the exact same key from the row, instead of generating a new random one and paying again.
#Step 2: understand what a replay returns
When you send the same key with the same details, the API returns the original record. No second payout, no second hold on the balance, no second order.
The record comes back as it is now. So if the payout has already confirmed on TRON, the replay shows it as confirmed. That makes a replay a handy status check as well.
| You send | What happens |
|---|---|
| New key | A new payout is created |
| Same key, same details | The existing payout is returned, nothing new is sent |
| Same key, different details | The API refuses with 409 idempotency_conflict |
| Fresh key for a retry | A second payout is created — this is the bug |
#Step 3: treat a key conflict as a bug in your code
"Same details" means the fields that describe the movement. For a withdrawal that is the user, the asset, the address and the amount. The quoted network fee is not compared, because it can change between honest retries of one payout.
If the details differ, the API will not guess what you meant. It refuses with 409 idempotency_conflict. It never quietly returns the old payout for a new request, and it never quietly creates a second one.
When you see this error, do not retry. It means two different payouts got the same key. Common causes:
- the key is built from something that is not unique, such as the user id alone;
- the key is built from the date, and the same user is paid twice in one day;
- a test script reuses a hard-coded key.
#Step 4: resolve the "I don't know" case
The hard case is a request that timed out. You do not know if the payout exists. There are two safe moves, and they always agree:
- Send it again with the same key. If the first try never landed, this one creates the payout. If it did land, you get the existing payout back. One call, one answer.
- Look the key up.
GET /v1/withdrawals?idempotency_key=…returns the withdrawal for that key, or404if none was ever created. Use this when you want to know the state before you write anything.
The one thing you must never do is retry with a new key. That is exactly how customers get paid twice.
#A retry policy that works
Here is a simple policy you can copy into your payout worker:
| Response | Retry? | Same key? |
|---|---|---|
Timeout, dropped connection, 500, 502, 504 | Yes, with backoff | Always |
429 too many requests | Yes, after waiting | Yes |
503 service unavailable | Yes, later, unchanged | Yes |
409 idempotency_conflict | No — fix key generation | — |
Other 409, such as low balance | Only after the state changes | Yes |
400, 401, 403 | No — fix the request | — |
When you finally stop retrying, check the payout with the key lookup or the withdrawal read before you tell your customer or your support team anything final.
#Cards: pending is not failed
Card money calls return 202 Accepted. Our ledger has moved, but the card program has not confirmed yet. The final answer comes later through a card.topup webhook or our reconciler.
Do not read 202 as a failure, and do not "fix" it by sending a new request. Refund your user only on a clear failure: a 409 provider_rejected at request time, or an order whose status becomes failed. Our cards page shows how loads work end to end.
#Idempotency on your side, too
The key protects the API call. Your own system needs the same habit. Your payout job can run twice after a deploy. A user can double-click "Withdraw". Two workers can pick up the same row.
- Put a unique constraint on the key column in your payouts table.
- Have the "Withdraw" button create the payout row once, and disable it while it waits.
- Let only one worker claim a payout row at a time.
- When a webhook arrives, deduplicate it as well. Our guide on receiving crypto webhooks safely shows how.
#Test it before real money moves
The sandbox behaves like production, so you can rehearse every case above without risk. Try these at least once:
- [ ] Send a payout, then send it again with the same key. Only one exists.
- [ ] Send the same key with a different amount. You get
409 idempotency_conflict. - [ ] Kill your worker in the middle of a payout and restart it. It reuses the stored key.
- [ ] Look up a key that was never used. You get
404. - [ ] Double-click your "Withdraw" button. One payout row, one API call.
#Common mistakes
| Mistake | What goes wrong | Fix |
|---|---|---|
| New key on every retry | The customer is paid twice | Store the key, reuse it |
| Key made in memory only | A crash loses it, the restart pays again | Save it with the payout row first |
| Key from user id or date | Real payouts collide and get refused | Build it from a unique record id |
Retrying a 409 conflict | The loop never ends | Stop and fix key generation |
| Treating a timeout as failure | Customer is refunded and also paid | Look the key up before deciding |
| No key on swaps | A repeated swap converts twice | Always send one |
#In summary
An idempotency key turns "did it go through?" into a question with a safe answer. Create it once, save it before the first call, reuse it on every retry, and look it up when you are unsure. Do that, and a dropped connection becomes a small delay instead of a double payout.
Read the full rules in the idempotency docs, and see the developer overview for the rest of the API.
Ready to send on-chain payouts that never pay twice, under your own brand? Sign up free and try it in the sandbox today.