Handling the no-JIR response
This is the most important page in this documentation. If your integration gets one thing wrong, it will be this.
If the client’s integration treats a missing JIR as an error and blocks the sale, we have made their compliance worse, not better.
—
TECHNICAL_PLAN.md§7
That sentence is the reason this page exists. Blocking a sale because a JIR did not arrive takes a situation the regulation already handles — the Tax Administration is briefly unavailable, the receipt carries a ZKI, everything is fine — and converts it into a refused customer, an unsold item, and a machine that looks broken. There is no compliance benefit on the other side of that trade. There is nothing on the other side of that trade.
What the response looks like
{ "id": "inv_01J9Z4T7M2K8Q6R3V5X1Y0B2C4", "status": "retrying", "jir": null, "zki": "e4d909c290d0fb1ca068ffaddf22cbd0", "qr_payload": "https://porezna.gov.hr/rn?zki=e4d909c290d0fb1ca068ffaddf22cbd0&datv=20260827_1032&izn=450", "number": "1041", "occurred_at": "2026-08-27T10:32:11+02:00", "total": "4.50", "currency": "EUR", "fiscal_error": { "code": "upstream_unavailable", "message": "Tax Administration did not respond. Will retry automatically.", "retry_scheduled": true }}Note the two differences from a confirmed sale, and nothing else:
jirisnullinstead of a UUID.qr_payloadcarrieszki=instead ofjir=.
Both QR forms are prescribed by the specification (§2.7). Both are compliant. The receipt you print from this response is a valid fiscal receipt.
Why this is safe
The ZKI — zaštitni kod izdavatelja, the issuer protection code — is computed by FiskHub from the company OIB, the sale datetime, the receipt number, the premises code, the ISU number and the total, signed with the company’s certificate. It never touches the network. It exists the moment we have your request, whether or not the Tax Administration is answering.
That is the design of Croatian fiscalization, not a workaround we invented. The regulation anticipates that the tax service will sometimes be unreachable, and it requires the sale to proceed anyway. The receipt carries the ZKI; the JIR arrives later, when the receipt is delivered successfully. FiskHub keeps retrying in the background until it does.
The rule
Branch on status. Never branch on jir !== null.
status | jir | What the device does |
|---|---|---|
confirmed | present | Print the receipt. Dispense. |
retrying | null | Print the receipt. Dispense. We keep retrying automatically. |
failed | null | Print the receipt. Dispense. Then alert an operator — a configuration problem needs a human. |
All three are HTTP 200. All three mean the sale is valid. In all three the zki and
qr_payload fields are populated, and they are what goes on the receipt.
The difference between retrying and failed matters to whoever operates the fleet, not to the
customer standing at the machine. See status lifecycle.
Correct and incorrect, side by side
Incorrect — blocks the sale
// DO NOT DO THIS.async function fiscalize(sale) { const res = await fetch("https://api.fiskhub.com/v1/invoices", { method: "POST", headers: { Authorization: `Bearer ${API_KEY}`, "Idempotency-Key": sale.transactionId, "Content-Type": "application/json", }, body: JSON.stringify(sale.payload), });
const invoice = await res.json();
// BUG 1: treats a missing JIR as a failure. if (!invoice.jir) { throw new Error("Fiscalization failed"); }
// BUG 2: because of BUG 1, this line is never reached during a tax service // outage, so the customer is refused a sale that was legally complete. return { receipt: buildReceipt(invoice) };}
// BUG 3: the caller cancels a valid sale on an exception that should never// have been raised, and the machine reports itself as out of order.try { const { receipt } = await fiscalize(sale); await dispense(sale); await print(receipt);} catch { await refund(sale); await showOutOfOrder();}Three bugs, one root cause: jir was read as a success flag. During a fifteen-minute outage at
the Tax Administration, this code refuses every sale on every machine in the fleet, refunds
customers who did nothing wrong, and takes the fleet offline — for receipts that were already
compliant.
Correct — completes the sale
async function fiscalize(sale) { const res = await fetch("https://api.fiskhub.com/v1/invoices", { method: "POST", headers: { Authorization: `Bearer ${API_KEY}`, // The same key for every retry of THIS sale. See /guides/idempotency/. "Idempotency-Key": sale.transactionId, "Content-Type": "application/json", }, body: JSON.stringify(sale.payload), });
// Only 4xx and 5xx are failures. A 200 is always a valid sale. if (!res.ok) { return { ok: false, error: await res.json() }; }
return { ok: true, invoice: await res.json() };}
const result = await fiscalize(sale);
if (result.ok) { const { invoice } = result;
// zki and qr_payload are ALWAYS present on a 200. Print from them, not // from jir. The receipt is compliant whether or not the JIR arrived. await dispense(sale); await print({ number: invoice.number, zki: invoice.zki, jir: invoice.jir, // may be null — print the line only when it is set qr: invoice.qr_payload, total: invoice.total, });
// The sale is finished. Everything below is fleet telemetry, not a gate. if (invoice.status !== "confirmed") { telemetry.warn("invoice not yet confirmed", { invoiceId: invoice.id, status: invoice.status, code: invoice.fiscal_error?.code, // false means a human has to fix something before it can succeed retryScheduled: invoice.fiscal_error?.retry_scheduled, }); }} else { // 4xx: our request was wrong — a missing ISU number, a bad VAT rate, an // expired certificate. Fix the cause; the sale itself was never fiscalized. // 5xx: retry with the SAME Idempotency-Key. await handleRequestError(result.error);}What to print when there is no JIR
Print the receipt exactly as you would otherwise, with two adjustments:
- The QR code encodes
qr_payloadverbatim. It carrieszki=rather thanjir=, which is the correct form for an unconfirmed receipt. Do not build the payload yourself and do not re-order its parameters. See QR codes for the printing requirements. - The JIR line is omitted when
jirisnull. Do not printJIR: null, an empty label, or a placeholder. The ZKI line and the QR code are what make the receipt valid.
The ZKI, the receipt number and the QR code are always present and always printed.
What happens after you hang up
An invoice returned as retrying is picked up by a sweep every five minutes and resent, oldest
deadline first, with late delivery (NakDost) set and a fresh message id. Nothing is required of
you. Most outages resolve within one or two passes and the invoice quietly becomes confirmed.
If you want to know when that happened, poll
GET /v1/invoices/{id} — a one-off check a few
minutes later, or a low-frequency reconciliation job over the day’s invoices. There are no
webhooks; the synchronous design removed the need for them.
An invoice returned as failed will not resolve on its own. Its fiscal_error.retry_scheduled
is false, and the fiscal_error.code tells you what to fix — usually a certificate or an ISU
number. After fixing it, call POST /v1/invoices/{id}/retry.
Testing this branch before you ship
Do not wait for a real outage to find out how your integration behaves. Drive this branch from
your own test double: the response shape is fully specified above and in the
OpenAPI contract, so a stubbed 200 with jir: null and
status: "retrying" is enough to exercise it.
Two tests worth having in your suite:
- A
retryingresponse dispenses. Assert that the goods are released and the receipt is printed with the ZKI-based QR, exactly as for aconfirmedresponse. - A
retryingresponse prints no JIR line. Assert that the absence of a JIR changes the receipt layout and nothing else.