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 onePOST /v1/batches— bulk money-movingPOST /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-1b6f7c2a3d4eScope: 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:
| Scenario | Server response |
|---|---|
| First request | Processes normally. Stores the result. |
| Retry with same key + same body | Returns the cached response. No side effect. |
| Retry with same key + different body | Returns 409 idempotency_key_conflict. The original request is unaffected. |
| Same key, different environment | Treated 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).
Recommended pattern
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 key401 invalid_api_key— fix the key403 permission_denied— escalate to your account owner409 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
| Code | HTTP | Cause |
|---|---|---|
idempotency_key_required | 400 | Header omitted on a state-changing endpoint |
idempotency_key_too_long | 400 | Key longer than 255 characters |
idempotency_key_invalid_chars | 400 | Key contains characters outside [A-Za-z0-9._~-] |
idempotency_key_conflict | 409 | Same key, different body — within the same merchant and environment |