Network calls fail in ambiguous ways: a request times out, but you don’t know whether the server processed it. Retrying naively could move money twice. The Idempotency-Key header makes a retry safe — the server recognises a repeated operation and returns the original result instead of performing it again.

How it works

1

Generate one UUID per logical operation

The client creates a fresh UUID v4 for each distinct operation (e.g. one wallet send) and sends it in the Idempotency-Key header.
2

The server reserves the key first

Before performing the operation, the server atomically reserves the key. If the reservation succeeds, this is the first attempt and the operation runs.
3

Retries replay the stored response

A retry with the same key returns the originally stored response — the operation is never performed twice.
4

Concurrent duplicates get 409

If the same key is already reserved but the first request has not finished, the duplicate returns HTTP 409 so the client can retry after a short delay.
Header
Idempotency-Key: 3f9c2a7e-1b4d-4c8a-9e21-7d5f0b6a1c33
Use a new UUID for each distinct operation. Reusing a key for a different operation will replay the first operation’s response instead of performing the new one. Reuse a key only when retrying the same operation.

Which operations support it

Idempotency keys apply to money-moving operations. In EU Rails, wallet send operations accept Idempotency-Key.
Plain payments (POST /payments) do not accept an Idempotency-Key header. To avoid duplicate payments, guard the submission on the client (for example, disable the submit control until the request resolves) and reconcile against transaction status.

Example: a wallet send

cURL
curl -X POST https://stagingapi.balansas.com/functions/v1/customer-api/wallets/{walletId}/transfers \
  -H "x-api-key: sk_test_xxxxxxxxxxxxxxxxxxxx" \
  -H "X-CSRF-Token: <64-char-hex>" \
  -H "Idempotency-Key: 3f9c2a7e-1b4d-4c8a-9e21-7d5f0b6a1c33" \
  -H "Content-Type: application/json" \
  -d '{
    "asset": "USDC",
    "network": "ethereum",
    "amount": "100.00",
    "destination": "0xabc..."
  }'
Node
const key = crypto.randomUUID(); // one per logical send

async function send() {
  const res = await fetch(
    "https://stagingapi.balansas.com/functions/v1/customer-api/wallets/" +
      walletId + "/transfers",
    {
      method: "POST",
      headers: {
        "x-api-key": "sk_test_xxxxxxxxxxxxxxxxxxxx",
        "X-CSRF-Token": csrfToken,
        "Idempotency-Key": key,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        asset: "USDC", network: "ethereum",
        amount: "100.00", destination: "0xabc...",
      }),
    }
  );

  if (res.status === 409) {
    // First attempt still in flight — wait and retry with the SAME key.
    await new Promise((r) => setTimeout(r, 1000));
    return send();
  }
  return res.json();
}

Both success and failure are stored

The server stores the response for a key whether the operation succeeded or failed. This matters: if it only stored successes, a retry after a failure would get a 409 “in progress” forever instead of the actual error. Because failures are stored too, retrying a failed operation with the same key replays the original failure — change the operation (and the key) to try again.
An invalid (non-UUID) Idempotency-Key is rejected with a validation error. Always send a well-formed UUID v4.

Errors

How the 409 and other failure responses are shaped.