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:
| Situation | Result |
|---|---|
| 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 -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
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.