Idempotency keys in a stablecoin API: how to never send the same payout twice

A timeout on a payout call is the most common way to pay someone twice. How idempotency keys prevent it, what replays, what conflicts, and how to retry.

An idempotency key is a unique string you attach to a payout request so a retry can't create a second payout. Send the same key with the same body and the API returns the first result instead of running it again. It's the difference between "the request timed out, did it go through?" and "the request timed out, I'll just send it again."

In a payments API that difference is money. Here's how it works, and where it stops protecting you.

Why do duplicate payouts happen at all?

Not because anyone clicks twice. Because networks are unreliable in one specific way: a request can succeed on the server and the response can still get lost on the way back.

Picture it. Your server sends POST /payouts. The API creates the payout, pulls the stablecoins, and starts the Pix transfer. Then the connection drops before the response arrives. Your HTTP client throws a timeout. From your side, it looks exactly like a failure.

So your retry logic does what retry logic does. Sends it again. Without an idempotency key, that's a second payout. The supplier gets paid twice, and on-chain legs don't come back.

How does an idempotency key fix it?

You generate a key before the first attempt and send the same key on every retry. The API stores the result under that key. When a retry arrives, it replays the stored response instead of running the action.

At BlindPay, you pass it as a header on any POST, PUT, PATCH, or DELETE under /v1/*:

Bash

Any string up to 255 characters works. A UUID is a good default. The example comes from the idempotency docs.

What does the API do on each kind of retry?

Four outcomes. Handle all of them.

SituationBlindPay responseWhat your code should do
Same key, same body, first request finishedOriginal response, plus Idempotency-Replayed: trueTreat it as success. Nothing ran twice.
Same key, different body422 idempotency_key_payload_mismatchBug on your side. Don't retry. Alert.
Same key, first request still running409 idempotency_key_in_flight with Retry-AfterWait the given seconds, retry with the same key and body.
Key over 255 characters400 idempotency_key_invalidFix the key generation.

The replay means side effects don't repeat either. No second database write, no second webhook, no second call to a bank.

Why does the body have to match byte for byte?

Because the check is a SHA-256 hash of the raw request body, not a semantic JSON comparison. {"a":1,"b":2} and {"b":2,"a":1} are the same object and two different hashes.

This catches people who rebuild the request on retry. If your retry path re-serializes a fresh object, key order or whitespace can change, and a harmless retry becomes a 422. The fix: serialize once, keep the exact bytes, and send those bytes on every attempt.

What does a safe retry loop look like?

Provider-neutral pseudocode. The key and body are fixed before the first attempt.

text

Two things make this safe. The key is saved to your ledger before the first call, so a crash mid-retry doesn't lose it. And every retry sends identical bytes.

When does the key stop protecting you?

Know these limits before you rely on it.

  • After 24 hours. BlindPay keeps a completed request's key for 24 hours. Retry after that and it runs again. A retry queue that can stall for a day needs a check against your own ledger first.
  • After a 5xx. Only 2xx and 4xx responses with a JSON body are stored. A 5xx releases the key, so the retry runs as a fresh attempt. That's the right behavior (the first attempt didn't finish), but it means your ledger decides what "done" means, not the key.
  • On multipart uploads. File uploads get a new random boundary on every encode, so the header is ignored there.
  • During an outage of the key store. BlindPay's idempotency is best-effort. If the store is briefly unavailable, the request still runs, as if no key was sent. A duplicate in that window isn't caught.
  • Across different paths or credentials. A key is scoped to your credentials, the HTTP method, and the exact path including query string. The same key on another path is a different key.

That's why idempotency is one layer, not the whole defense.

What are the other layers?

  • Single-use quotes. A payout at BlindPay executes against a quote, and a quote_id can only back one payout. A second payout on the same quote fails. Different mechanism, same goal.
  • Your own ledger. Store the key, the quote id, and the payout id together. Before any retry older than a few hours, check whether a payout for that key already exists.
  • Webhook deduplication. The API retries webhook deliveries too, so your handler will see the same event more than once. Dedupe on the delivery id, not on the payload.

Idempotent writes and deduplicated reads are two halves of the same thing. Skip either and duplicates find a way in.

How do you test this before production?

On a development instance, force each case on purpose:

  1. Send a payout, then resend with the same key and body. Expect Idempotency-Replayed: true.
  2. Resend with the same key and a reformatted body. Expect 422.
  3. Fire two identical requests at once. One should get 409 with Retry-After.
  4. Reuse a quote_id on a new key. Expect the payout to fail.

Development instances are free and payouts complete automatically, so this is an hour of work. For where retries sit in the whole flow, see how a stablecoin payment moves.

Where does BlindPay fit?

BlindPay is a stablecoin API for cross-border payouts and collections: USDC or USDT in, local currency out over Pix, SPEI, ACH, RTP, SEPA, and SWIFT (POBO/COBO) to 100+ countries, with no pre-funding. Every mutating endpoint accepts an Idempotency-Key header, KYC, KYB, and sanctions screening run inside the API, and virtual USD accounts turn ACH, wire, and SWIFT deposits into stablecoins.

What to do next

Grep your payout code for the retry path. If it doesn't send a key generated before the first attempt, and the same bytes on every retry, fix that first. Then run the four tests above against a development instance. The idempotency reference has every edge case.

This article is for general information only and is not legal, tax, or financial advice.

FAQ