Idempotency

Idempotency

The Swappr API supports idempotent retries via the Idempotency-Key header. This prevents duplicate side effects when network errors force you to retry a request.

Required for state-changing endpoints

The Idempotency-Key header is required on:

  • POST /v1/payouts — money-moving, the most important one
  • POST /v1/batches — bulk money-moving
  • POST /v1/customers — Remittances customer create (when enabled on your account)
  • POST /v1/customers/{id}/virtual_accounts — Remittances per-customer VIBAN provisioning

It’s optional but recommended on every other POST. Read endpoints (GET) don’t need it.

Format

A unique string per logical operation. UUIDs are recommended; any string of 1–255 characters from the URL-safe set [A-Za-z0-9._~-] is accepted.

Idempotency-Key: 7e4c3a8d-9f2b-4c1e-8d5a-1b6f7c2a3d4e

Scope: per merchant, per environment

An idempotency key is scoped to your account and the environment the key was used in. Sandbox and live are completely separate namespaces.

That means a key you used in sandbox is free to use again in live — the live request runs for real and creates a new payout. It does not replay your sandbox result.

This matters most if you derive keys from your own business references — order IDs, invoice numbers, payroll rows — which naturally repeat between a batch you tested in sandbox and the one you run for real. Those repeated keys no longer collide across environments, so the live run executes normally instead of returning your sandbox result.

Two different merchants may also use the same key string without affecting each other.

Behavior

The server stores a hash of the request body keyed by your idempotency key:

ScenarioServer response
First requestProcesses normally. Stores the result.
Retry with same key + same bodyReturns the cached response. No side effect.
Retry with same key + different bodyReturns 409 idempotency_key_conflict. The original request is unaffected.
Same key, different environmentTreated as a brand-new request. Runs normally.
⚠️

Keys do not expire. An idempotency key is retained indefinitely for your account in that environment — reusing one a month later still returns the original cached response (or a 409 if the body differs). Do not plan to recycle keys after a waiting period. Always generate a fresh key per logical operation.

⚠️

Same idempotency key + different body = 409 conflict. The server treats this as a programming error: you intended one operation but accidentally sent two different ones with the same key. Always generate a fresh UUID per logical operation.

⚠️

Take care with business references as keys. Using an invoice number, payroll ID or order reference as your idempotency key is allowed, but those tend to repeat across runs. If you replay a payroll file that reuses the same references, every row returns its original cached response instead of paying again. Prefer a fresh UUID per attempt, or namespace the reference with the run (payroll-2026-07-INV-1001).

Generate the key BEFORE the first attempt, persist it alongside the operation in your DB, and reuse it on every retry:

import { randomUUID } from 'crypto';
import { db } from './db';
 
async function sendPayoutWithRetry(merchantOrderId: string) {
  // Look up or generate idempotency key for this merchant order.
  const order = await db.orders.findUnique({ where: { id: merchantOrderId } });
  const idempotencyKey = order.swapprIdempotencyKey ?? randomUUID();
 
  if (!order.swapprIdempotencyKey) {
    await db.orders.update({
      where: { id: merchantOrderId },
      data: { swapprIdempotencyKey: idempotencyKey },
    });
  }
 
  // Retry up to 3 times with the SAME key — server dedupes.
  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      const res = await fetch('https://api.swappr.me/api/v1/payouts', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${process.env.SWAPPR_API_KEY}`,
          'Idempotency-Key': idempotencyKey,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          amount_minor: order.amountMinor,
          currency: order.currency,
          recipient: order.recipient,
        }),
      });
 
      if (res.ok) return await res.json();
      if (res.status === 409) {
        // Idempotency conflict — body changed between attempts.
        // Investigate; do not retry.
        throw new Error('Idempotency conflict — body mismatch on retry');
      }
      if (res.status >= 500) {
        await sleep(1000 * attempt);
        continue;
      }
      throw new Error(`HTTP ${res.status}`);
    } catch (err) {
      if (attempt === 3) throw err;
      await sleep(1000 * attempt);
    }
  }
}

What gets cached

The server hashes the canonicalized request body (sorted keys, deterministic JSON). This means:

  • {"a": 1, "b": 2} and {"b": 2, "a": 1} are treated as the same body
  • {"amount_minor": 5000} and {"amount_minor": "5000"} are different (number vs string)
  • Whitespace + key order don’t matter

If you’re constructing the body deterministically (always the same field order, same JSON encoder), you don’t need to worry about this.

When NOT to retry

Some response codes mean “your request was malformed; retry won’t help”:

  • 400 invalid_request — fix the body, generate a new idempotency key
  • 401 invalid_api_key — fix the key
  • 403 permission_denied — escalate to your account owner
  • 409 idempotency_key_conflict — investigate the mismatch

For these, don’t retry with the same key — generate a fresh one once you’ve fixed the underlying issue.

Errors

CodeHTTPCause
idempotency_key_required400Header omitted on a state-changing endpoint
idempotency_key_too_long400Key longer than 255 characters
idempotency_key_invalid_chars400Key contains characters outside [A-Za-z0-9._~-]
idempotency_key_conflict409Same key, different body — within the same merchant and environment

What’s next