---
title: "Idempotency keys in a stablecoin API: how to never send the same payout twice"
seoTitle: "Stablecoin API idempotency keys"
description: "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."
date: "2026-08-19"
category: "payments"
author: "BlindPay Team"
faq:
  - q: "What is an idempotency key in a payments API?"
    a: "A unique string you send with a request that changes state, like creating a payout. If you retry the same request with the same key, the API returns the original result instead of running the action again. It turns an ambiguous timeout into a safe retry."
  - q: "How long does BlindPay keep an idempotency key?"
    a: "24 hours after the request completes. After that the key is treated as new, and a request with it runs again. Retries that can happen later than a day need their own duplicate check against your ledger."
  - q: "What happens if I reuse an idempotency key with a different request body?"
    a: "BlindPay returns 422 idempotency_key_payload_mismatch and does not run the request. The body check is a SHA-256 hash of the raw bytes, so even reordered JSON keys or different whitespace count as a different body."
  - q: "What if the original request is still running when I retry?"
    a: "BlindPay returns 409 idempotency_key_in_flight with a Retry-After header in seconds. Wait that long and retry with the same key and the same body."
  - q: "Is a single-use quote enough to prevent duplicate payouts?"
    a: "It helps, but it is not the whole answer. At BlindPay a quote_id can only back one payout, so a second payout on the same quote fails. An idempotency key goes further: a retry gets the original response back, including the payout id, so your code knows what happened instead of seeing an error."
---

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
curl --request POST \
  --url https://api.blindpay.com/v1/instances/in_000000000000/payouts/evm \
  --header 'Authorization: Bearer YOUR_API_KEY' \
  --header 'Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890' \
  --header 'Content-Type: application/json' \
  --data '{ ... }'
```

Any string up to 255 characters works. A UUID is a good default. The example comes from the [idempotency docs](/docs/learn/idempotency).

## What does the API do on each kind of retry?

Four outcomes. Handle all of them.

| Situation | BlindPay response | What your code should do |
| --- | --- | --- |
| Same key, same body, first request finished | Original response, plus `Idempotency-Replayed: true` | Treat it as success. Nothing ran twice. |
| Same key, different body | `422 idempotency_key_payload_mismatch` | Bug on your side. Don't retry. Alert. |
| Same key, first request still running | `409 idempotency_key_in_flight` with `Retry-After` | Wait the given seconds, retry with the same key and body. |
| Key over 255 characters | `400 idempotency_key_invalid` | Fix 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.

```
key  = new_uuid()
body = serialize(payout_request)       # once, keep the bytes
save_pending(ledger, key, body)        # before any network call

for attempt in 1..5:
    response = post("/payouts", body, headers={"Idempotency-Key": key})

    if response.status in 2xx:
        record_payout(ledger, key, response.body.id)
        return response.body
    if response.status == 409:
        sleep(response.headers["Retry-After"])
        continue
    if response.status in 4xx:
        mark_failed(ledger, key, response.body)   # don't retry a 4xx
        raise
    # timeout or 5xx: back off and retry with the SAME key and body
    sleep(backoff(attempt))

escalate(ledger, key)                  # still unknown, a human looks
```

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](/resources/more/stablecoin-api-quotes-explained), 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](/resources/more/how-a-stablecoin-payment-works).

## Where does BlindPay fit?

[BlindPay](/global-payments) 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](/virtual-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](/docs/learn/idempotency) has every edge case.

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