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 ≤ 200 chars works.
Idempotency-Key: 7e4c3a8d-9f2b-4c1e-8d5a-1b6f7c2a3d4eBehavior
The server stores a hash of the request body keyed by your idempotency key. Within a 24-hour window:
| 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_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.
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/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_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 |
|---|---|---|
missing_idempotency_key | 400 | Header omitted on a state-changing endpoint |
idempotency_key_too_long | 400 | Key > 200 characters |
idempotency_conflict | 409 | Same key, different body |