Idempotency-Key
Every money-moving endpoint requires an Idempotency-Key header. Read endpoints may include one but it is optional and mostly pointless there.
When to send
- Deposit — required. Network failures during a deposit are real; idempotency prevents double-spends.
- Withdraw — required. Same reason.
- Create end user — required.
- Create portfolio — required (backend honors it; prevents duplicate portfolio creation on retry).
- Delete portfolio — required.
- On-ramp / off-ramp intents — required.
- GET (reads) — optional. Reads are idempotent by nature; the header is accepted but has no caching effect.
Missing a required Idempotency-Key returns 400 with code: "idempotency_key_required".
Generating a key
Use a UUID v4 (or any 128-bit cryptographically random value). The key is opaque to the server — it never parses the contents.
Generate a fresh key per logical operation, not per HTTP retry. If a network error occurs and you retry the exact same deposit, reuse the same key. The server will detect the replay and return the cached response safely. Generating a new key on retry defeats the purpose.
// One key per logical operation — reuse it if you retry the same op.
const idempotencyKey = crypto.randomUUID();
const res = await fetch('/v1/partner/end_users/alice-bunq-id/deposit', {
method: 'POST',
headers: {
Authorization: `Bearer ${entraToken}`,
'X-User-Token': partnerJwt,
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
portfolio_id: 'jar_01HZ4KXQM5E8WRTYN3P7VBJD6F',
amount_minor: '10000000',
}),
});State matrix
| Scenario | Server behavior |
|---|---|
| First request with key K | Process normally; cache the full response (status + body + headers) under K. Returns Idempotency-Key-Replay: false. |
| Replay: same K, same body | Return cached response verbatim. Returns Idempotency-Key-Replay: true. |
| Replay: same K, different body | 422 idempotency_key_in_use_with_different_params — the original response is NOT returned. |
| In-flight: same K while original is still processing | 409 idempotency_key_in_progress — retry after the original completes. |
| First request with key K completes with 4xx / 5xx error | Error response is also cached. Reusing the same K returns the same error. Use a new key to actually retry the operation. |
TTL
Keys expire 24 hours after first observation. After expiry the key is forgotten and may be reused with a different request body.
Keys are scoped to your tenant — two different partners can use the same literal key string without collision.
Replay-detection signaling
Every idempotency-aware response includes:
Idempotency-Key-Replay: true— the response was served from cache (replay detected).Idempotency-Key-Replay: false— the request was processed fresh.
Example
The demo partner app in src/lib/yieldforce.ts shows the recommended pattern — a fresh randomUUID() is generated for each money-flow call and passed in the Idempotency-Key header:
import { randomUUID } from 'node:crypto';
async function deposit(endUserId: string, portfolioId: string, amountMinor: string) {
// Fresh key per logical deposit — reuse this same key on network-error retry.
const idempotencyKey = randomUUID();
const res = await fetch(`/v1/partner/end_users/${endUserId}/deposit`, {
method: 'POST',
headers: {
Authorization: `Bearer ${entraToken}`,
'X-User-Token': partnerJwt,
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({ portfolio_id: portfolioId, amount_minor: amountMinor }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.code);
}
return res.json();
}Anti-patterns
Avoid these common mistakes:
Reusing one key across logically distinct operations. If you deposit $100 and then want to deposit $200, those are two different logical operations — use two different keys. Reusing the key causes a 422 idempotency_key_in_use_with_different_params error.
Generating a new key per HTTP retry of the same operation. A new key on retry means the server treats it as a brand-new request and may process the operation again (double-spend). Always reuse the original key when retrying after a network failure.
Expecting partial replay. Replay is always atomic — the full cached response is returned (status, body, and headers). There is no partial or incremental replay.