Skip to content

Idempotency

Make retries safe: how Idempotency-Key works, how to choose keys, and a retry loop that can't pay twice.

Networks fail. A request can reach us and the answer get lost on the way back. Without protection, retrying a payout could pay someone twice. Idempotency keys make retries safe.

The rule

Every request that moves money (today POST /api/v1/payouts) needs an Idempotency-Key header. Without one you get IDEMPOTENCY_KEY_REQUIRED and nothing happens.

You sendWe do
A new keyProcess the request and store the response for 24 hours.
The same key and the same body (within 24 hours)Return the stored response, with the header Idempotent-Replayed: true. No second payout.
The same key and a different bodyRefuse with IDEMPOTENCY_KEY_REUSED (409).
The same key while the first is still runningRefuse with IDEMPOTENCY_IN_PROGRESS (409). Wait a second and retry.
A key whose first request failed validation or a limitThe key is released: fix the request and retry with the same key.

Choosing keys

  • 8 to 128 characters: letters, digits, . _ : -.
  • One key per payout you intend to make, created before the first attempt and saved with your record (order, payroll line), so a retry after a crash reuses it.
  • A UUID works, or something derived from your own id, such as payroll-2026-11:line-42. Never reuse a key for a different payout.
  • Keys are separate per environment: the same key in test and live doesn't collide.

Retrying safely

retry.js
// Retry network errors and 5xx with backoff, reusing the same Idempotency-Key.
async function createPayout(body, idempotencyKey) {
  for (let attempt = 0; attempt < 5; attempt++) {
    try {
      const res = await fetch("https://api.autopayke.com/api/v1/payouts", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.AUTOPAYKE_API_KEY}`,
          "Idempotency-Key": idempotencyKey,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(body),
      });
      const json = await res.json();
      if (json.ok) return json.data;
      // Retry only what's worth retrying.
      if (["IDEMPOTENCY_IN_PROGRESS", "RATE_LIMITED", "CAPABILITY_PAUSED", "METHOD_UNAVAILABLE"].includes(json.code) || res.status >= 500) {
        await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
        continue;
      }
      throw Object.assign(new Error(json.error), { code: json.code, requestId: json.requestId });
    } catch (err) {
      if (err.code) throw err; // an API error we decided not to retry
      await new Promise((r) => setTimeout(r, 2 ** attempt * 500)); // network error: retry
    }
  }
  throw new Error("Gave up; check GET /api/v1/payouts?reference=… before trying again");
}

After 24 hours a key can be used again as new. If you're unsure whether an old attempt went through, look it up by reference with GET /api/v1/payouts?reference=… before retrying.