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

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

ScenarioServer behavior
First request with key KProcess normally; cache the full response (status + body + headers) under K. Returns Idempotency-Key-Replay: false.
Replay: same K, same bodyReturn cached response verbatim. Returns Idempotency-Key-Replay: true.
Replay: same K, different body422 idempotency_key_in_use_with_different_params — the original response is NOT returned.
In-flight: same K while original is still processing409 idempotency_key_in_progress — retry after the original completes.
First request with key K completes with 4xx / 5xx errorError 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:

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.