Idempotency
Idempotency-Key is required on POST /v1/invoices. Omitting it returns
missing_idempotency_key — the request is refused before
anything is signed or stored.
It is optional, and honoured identically, on every other POST in the API.
The rules
| Situation | Result |
|---|---|
| New key | The request is processed normally and the response is stored for 24 hours. |
| Same key, byte-identical body, within 24 h | The stored response is replayed. Nothing is sent to the Tax Administration a second time. The response carries Idempotent-Replayed: true. |
| Same key, different body, within 24 h | 409 idempotency_key_reused. Nothing is processed. |
| Same key, more than 24 h later | Treated as a new key. |
No key on POST /v1/invoices | 400 missing_idempotency_key. |
The body is compared by hash, so “identical” means byte-identical after serialisation. Re-serialising
the same object with keys in a different order produces a different hash and a 409 — build the
body once and reuse the serialised form across retries.
How to choose the key
One key per sale, generated once, reused for every retry of that sale.
// At the start of the transaction — once.const sale = { idempotencyKey: randomUUID(), // ...};
// Every attempt to fiscalize THIS sale uses that same key, unchanged.await post("/v1/invoices", body, { "Idempotency-Key": sale.idempotencyKey });A UUIDv4 is the recommended form. Any string up to 255 characters works, as long as it is unique per sale.
Two anti-patterns to avoid:
- A fresh key on every attempt. This defeats the whole mechanism. A timeout on attempt one followed by a new key on attempt two is exactly the situation idempotency exists to protect, and a new key removes the protection.
- A key derived from data that can repeat, such as
device_id + total. Two identical €4.50 sales at the same machine are different sales, and the second one would be silently swallowed as a replay. If you derive a key, derive it from your own transaction id.
Why this matters legally
The first invariant of this system is that a sale is never fiscalized twice — one transaction, at most one JIR, forever.
A double-fiscalized sale is not a duplicate row that someone can clean up later. It is a second fiscal receipt filed with the Croatian Tax Administration for money that was taken once. The records now show revenue that does not exist, and the operator’s VAT return is wrong. Discovering it is difficult; correcting it means an accountant, a written explanation, and, depending on the period involved, a corrected filing. The operator carries that, not the integrator who sent the second request.
The circumstances that produce a double-submission are ordinary rather than exotic:
- a response is lost between us and the device on a marginal connection;
- your client times out after our fiscalization already succeeded;
- a supervisor process restarts mid-request and replays the queue;
- an operator retries a machine that appeared to hang.
In each case the sale happened once and the device has every reason to ask again. The
Idempotency-Key makes asking again safe.
What a replay looks like
Send the same key and body twice:
KEY=$(uuidgen)BODY='{"device_id":"6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21","occurred_at":"2026-08-27T10:32:11+02:00","total":"4.50","payment_method":"K","vat":[{"rate":"25.00","base":"3.60","amount":"0.90"}]}'
curl -sS -D- https://api.fiskhub.com/v1/invoices \ -H "Authorization: Bearer $FISKHUB_API_KEY" \ -H "Idempotency-Key: $KEY" -H "Content-Type: application/json" -d "$BODY"
curl -sS -D- https://api.fiskhub.com/v1/invoices \ -H "Authorization: Bearer $FISKHUB_API_KEY" \ -H "Idempotency-Key: $KEY" -H "Content-Type: application/json" -d "$BODY"The second response is byte-identical to the first — same id, same jir, same zki, same
number — with one added header:
HTTP/1.1 200 OKIdempotent-Replayed: trueNothing was sent to the Tax Administration the second time. Exactly one fiscal record exists.
What a conflict looks like
curl -sS https://api.fiskhub.com/v1/invoices \ -H "Authorization: Bearer $FISKHUB_API_KEY" \ -H "Idempotency-Key: $KEY" -H "Content-Type: application/json" \ -d '{"device_id":"6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21","occurred_at":"2026-08-27T10:32:11+02:00","total":"9.00","payment_method":"K","vat":[]}'{ "error": { "type": "conflict", "code": "idempotency_key_reused", "message": "Idempotency-Key 8f0b4e2a-6c31-4d9f-9a75-2b8e1c0d47f3 was already used for a different request body. Use a new key for a new sale, and the original key only for retries of the original sale.", "param": "Idempotency-Key", "upstream": null, "doc_url": "https://docs.fiskhub.com/errors/idempotency_key_reused" }}FiskHub will not guess which of the two bodies you meant. A 409 here almost always means the
key was reused for a genuinely different sale — see
idempotency_key_reused.
Retrying safely
async function fiscalizeWithRetry(sale, attempts = 3) { // Serialised ONCE. Re-serialising per attempt risks a different key order // and therefore a different body hash. const body = JSON.stringify(sale.payload);
for (let attempt = 1; attempt <= attempts; attempt++) { try { const res = await fetch(`${API}/invoices`, { method: "POST", headers: { Authorization: `Bearer ${API_KEY}`, "Idempotency-Key": sale.idempotencyKey, // NEVER regenerated "Content-Type": "application/json", }, body, signal: AbortSignal.timeout(15_000), });
// 2xx: done. A 200 without a JIR is still done — see /guides/no-jir/. if (res.status < 400) return res.json();
// 4xx: our request is wrong. Retrying it unchanged cannot help. if (res.status < 500 && res.status !== 429) { throw new FiscalRequestError(await res.json()); }
// 429 and 5xx: retry with the same key. This is what it is for. } catch (err) { if (err instanceof FiscalRequestError) throw err; if (attempt === attempts) throw err; // network error or timeout }
await sleep(500 * 2 ** (attempt - 1)); }}The important line is "Idempotency-Key": sale.idempotencyKey. It is set once when the sale
begins and is never regenerated inside the loop.
Scope and lifetime
- Keys are scoped to your API key and mode. The same string used by a
fh_test_key and afh_live_key refers to two unrelated requests. - Records live 24 hours from first use, then fall out of the store. A key reused after that window is treated as new.
- Only the response body and status are stored — never the certificate, never key material.