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 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.

OperationWhat 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

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.