Skip to content

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

SituationResult
New keyThe request is processed normally and the response is stored for 24 hours.
Same key, byte-identical body, within 24 hThe 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 h409 idempotency_key_reused. Nothing is processed.
Same key, more than 24 h laterTreated as a new key.
No key on POST /v1/invoices400 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:

Terminal window
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 OK
Idempotent-Replayed: true

Nothing was sent to the Tax Administration the second time. Exactly one fiscal record exists.

What a conflict looks like

Terminal window
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 a fh_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.