Developers

Guide

Idempotency

Networks fail mid-request, and a blind retry of a POST can create the thing twice. The Idempotency-Key header lets you retry any public POST safely: the same key replays the original outcome instead of repeating the work.

How it works

Send a unique key — a UUID is ideal — in the Idempotency-Key header. Keys are remembered per organisation for 24 hours together with the request payload and the stored response:

SituationResult
First request with the key Executed normally; the response is stored.
Same key, same payload (a retry) The stored response is replayed — the operation does not run again.
Same key, different payload 409 idempotency_key_reused — a bug in key management on the caller's side.
Same key while the first request is still running 409 idempotency_key_in_flight — back off and retry shortly.
The first request failed The key is released — retrying with it runs the operation fresh.
curl
curl -X POST https://api.kelomo.fi/v1/... \
  -H "Authorization: Bearer kelomo_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 0d4f6c2a-8b1e-4e6a-9c3d-2f5a7b9c1d3e" \
  -d '{ … }'

Where the header is required

When calling with an API key, Idempotency-Key is required on money-document POSTs — issuing an invoice, creating a credit note, dispatching, recording or importing payments — and on POST /v1/memberships, the creation of a person. Omitting it fails fast with 400 idempotency_key_required.

For money the reason is unforgiving arithmetic: issuing an invoice burns a number in an unbroken, legally required sequence, and there is no void — a credit note is the only correction. A double-fired issue is therefore a real accounting event, not a cosmetic duplicate. Two identical requests with the same key burn exactly one invoice number.

For a new employee the reason is recovery: a duplicate is already refused with 409 already_member, but that refusal cannot tell you the id of the person your timed-out first attempt may have created. With the key, the retry replays the original 201 and your sync moves on.

Everywhere else the header is optional — and still recommended for any POST your code might retry.

The retry pattern

Node.js
import { randomUUID } from 'node:crypto';

// One key per LOGICAL operation — generate it once, reuse it on every retry.
async function postWithRetry(path, body, apiKey, attempts = 3) {
  const idempotencyKey = randomUUID();
  for (let attempt = 1; ; attempt += 1) {
    const response = await fetch(`https://api.kelomo.fi${path}`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${apiKey}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': idempotencyKey,
      },
      body: JSON.stringify(body),
    });
    // 5xx and network errors are safe to retry — the same key guarantees the
    // operation runs at most once even if an earlier attempt actually landed.
    if (response.status < 500 || attempt >= attempts) return response;
    await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
  }
}
  • One key per logical operation, not per HTTP attempt — the key is what ties the retries together.
  • Generate keys randomly (UUIDv4); never derive them from timestamps.
  • Retries beyond the 24-hour window are new operations — check state through a GET first if you resume that late.
  • Domain-level dedupe (for example client-generated punch ids) is a separate layer and keeps working with or without this header.