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 ≤ 200 chars works.

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

Behavior

The server stores a hash of the request body keyed by your idempotency key. Within a 24-hour window:

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_conflict. The original request is unaffected.

After 24 hours the key expires and can be reused.

⚠️

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.

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/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_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
missing_idempotency_key400Header omitted on a state-changing endpoint
idempotency_key_too_long400Key > 200 characters
idempotency_conflict409Same key, different body