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 or an OAuth access token, Idempotency-Key is
required on money-document writes (invoices, credit notes, dispatch,
payments), on payroll and billing hand-overs (creating an export batch, delivering a billing
batch) and on POST /v1/memberships,
the creation of a person. Omitting it fails fast with
400 idempotency_key_required. The web app's own requests are not affected.
| Operation | What it does |
|---|---|
POST /v1/billing-export/batches | Create a billing export batch |
POST /v1/billing-export/batches/{id}/deliver | Deliver a billing export batch to its target |
POST /v1/export-batches | Create a payroll export batch |
POST /v1/invoicing/bank-imports | Import a camt.054 bank file |
POST /v1/invoicing/invoices | Create an empty invoice draft |
POST /v1/invoicing/invoices/{id}/credit-note | Create a credit note for an issued invoice |
POST /v1/invoicing/invoices/{id}/dispatch | Dispatch an invoice |
POST /v1/invoicing/invoices/{id}/dispatch/resend | Resend an invoice dispatch |
POST /v1/invoicing/invoices/{id}/issue | Issue an invoice |
POST /v1/invoicing/payments | Record a payment |
POST /v1/invoicing/payments/{id}/allocations | Allocate a payment to invoices |
POST /v1/invoicing/reminders/{id}/charge-invoice | Bill a payment reminder's fee and interest |
POST /v1/memberships | Create a member |
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. A payroll or billing hand-over is the same kind of event on the receiving side: a second export batch is a second payroll run in the payroll system.
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.