Fiscalizing a sale
POST /v1/invoices is the only endpoint a payment device calls at the point of sale. It is
synchronous: the request goes in, the fiscal result comes out, typically in one to three
seconds.
The request
curl -sS https://api.fiskhub.com/v1/invoices \ -H "Authorization: Bearer $FISKHUB_API_KEY" \ -H "Idempotency-Key: 8f0b4e2a-6c31-4d9f-9a75-2b8e1c0d47f3" \ -H "Content-Type: application/json" \ -d '{ "device_id": "6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21", "occurred_at": "2026-08-27T10:32:11+02:00", "total": "4.50", "currency": "EUR", "payment_method": "K", "vat": [{ "rate": "25.00", "base": "3.60", "amount": "0.90" }] }'Required fields
| Field | Type | Notes |
|---|---|---|
device_id | UUID | The device that made the sale. It resolves the company (and therefore the OIB and certificate), the premises code (OznPosPr) and the ISU number (OznNapUr). |
occurred_at | ISO 8601 with offset | The moment of the sale, not of the request. Feeds the ZKI and starts the 30-day clock. |
total | decimal string | IznosUkupno — what the customer paid, including all taxes and fees. |
payment_method | G, K, O or T | Cash, card, other, bank transfer. O also covers a sale split across methods. T is valid only on an attended receipt — there is nobody at a self-service device to take a transfer. |
Optional fields
| Field | Type | Notes |
|---|---|---|
currency | 3 letters | Defaults to EUR, which is the only accepted value. |
vat | array of tax lines | Pdv — one entry per rate. Omit it and the company’s default_vat_rate is applied to total; send it whenever the sale spans more than one rate. Send [] or null for a company that is not VAT-registered. |
consumption_tax | array of tax lines | Pnp — porez na potrošnju. Rate between 0.00 and 3.00. |
other_taxes | array of named tax lines | OstaliPor. Each line may also carry a name (NazivPor, at most 100 characters). |
vat_exempt_amount | decimal string | IznosOslobPdv. |
margin_amount | decimal string | IznosMarza. |
non_taxable_amount | decimal string | IznosNePodlOpor. |
fees | array of { name, amount } | Naknade — deposits and similar charges already included in total. |
number | digits, no leading zeros | BrOznRac. Supply it if you keep the sequence yourself; omit it and FiskHub allocates the next one for that selling point. See below. |
special_purpose | string, max 1000 | SpecNamj. Free text carried through to the Tax Administration; useful for your own machine or route reference. |
late_delivery | boolean | NakDost. Set automatically on every re-send; send true to force it on the first attempt, which is what copying up a backlog wants. |
vat_registered | boolean | USustPdv. Defaults to the company’s own registration flag. |
sale_type | unattended or attended | Which set of message rules applies. Defaults to unattended, which is what this guide describes. |
An attended receipt — one issued by a person at a till, specification §2.1 — takes five more fields and follows slightly different rules. If that is what you are building, read attended receipts instead of this table. Everything else on this page applies to both.
Omitted is not the same as null
Every optional field also accepts null, and the two are not interchangeable:
- Leave it out and the value falls back: your request, then the device’s configuration, then the company’s, then the specification default.
- Send
nulland the element is omitted outright. No configured default is consulted.
This matters the first time you configure a default. A vending machine whose configuration sets
special_purpose will carry that text on every sale — including the one where you meant to
clear it, unless you send "special_purpose": null rather than leaving the field out.
A tax line is { "rate": "25.00", "base": "3.60", "amount": "0.90" } — all three are decimal
strings. rate is a percentage, base is the amount that rate applies to, amount is the tax
charged.
VAT: send it, or let the company default fill it in
A tax line is the same shape wherever it appears, and vat is an array of them because a single
receipt can span several rates. There are three ways to fill it, and the difference matters:
Send vat and it is used exactly as given — one <Porez> per entry. This is the only option
that can describe a sale spanning more than one rate:
"total": "188.51","vat": [ { "rate": "25.00", "base": "80.00", "amount": "20.00" }, { "rate": "13.00", "base": "78.33", "amount": "10.18" }]The 25% goods came to 100.00 gross and the 13% goods to 88.51; each is split into its own base
and tax, and all four numbers add back up to total. Nothing validates that — IznosUkupno is a
standalone figure to the Tax Administration — so the arithmetic is yours to get right.
Leave vat out and the company’s default_vat_rate (set on the company, overridable per
device) is applied to total, which is treated as VAT-inclusive:
base = total x 10000 / (10000 + rate x 100) rounded half-upamount = total - baseSo total: "100.00" at a default of 25.00 files base 80.00, amount 20.00; at 13.00 it files
base 88.50, amount 11.50. Nothing is derived for an issuer outside the VAT system, and nothing
is derived when no default_vat_rate is configured — the receipt then goes out with no <Pdv>
at all.
Send "vat": null for a sale that genuinely has no VAT. null stops the chain: no default is
consulted and no <Pdv> element is written. [] behaves the same way.
The receipt number: yours or ours
The receipt number (BrOznRac) can come from either side, and you pick once:
- You keep the sequence. Send
numberand it is used exactly as sent. - We keep it. Omit
numberand FiskHub allocates the next one for that selling point and calendar year, inside the same database transaction that stores the invoice.
Either way the number used is returned as number, and that is the number to print.
Both numbering levels are allocated. OznSlijed = P counts every sale at the business
premises; N counts one cash register’s. Under N, two registers at one premises both
start at 1 — that is what N declares, and OznNapUr keeps the receipts distinct.
A premises runs one level per calendar year. OznSlijed travels on each receipt, so
nothing in the protocol stops a premises mixing them — and two allocated sequences over
one premises would both reach 1, 2, 3 without a single error. The second level is refused.
If you let us allocate, the allocation and the insert share one transaction on purpose. A sequence handed out in one step and used in another races as soon as two devices at the same premises sell in the same moment, and a duplicate receipt number is a compliance problem discovered by an auditor rather than by a test.
Rules that are checked before anything leaves our network
These come from the Croatian specification’s restrictive errors, which cause outright rejection at the Tax Administration. FiskHub checks them locally, so you get a precise error in milliseconds instead of a round trip and an opaque upstream code.
| Rule | Error if violated |
|---|---|
occurred_at no more than 30 days old | hr.sale_older_than_30_days |
occurred_at not in the future | hr.date_in_future |
VAT rate is 0.00, 5.00, 13.00 or 25.00 | hr.invalid_vat_rate |
Consumption tax rate between 0.00 and 3.00 | hr.invalid_pnp_rate |
payment_method is G, K or O (T on an attended receipt) | hr.invalid_payment_method |
| Cash and card totals within ±10,000.00 EUR | hr.amount_out_of_range |
| Company OIB passes its checksum | hr.invalid_oib |
Receipt number is digits with no leading zeros, and not 0 | hr.invalid_receipt_number |
| Device has an ISU number | hr.device_missing_isu |
| Company has a valid certificate | company_missing_certificate, certificate_expired |
What happens server-side
POST /v1/invoices 1. Authenticate; resolve tenant, company and device from device_id 2. Validate locally — restrictive errors, ISU present, certificate valid 3. Persist the invoice <- the sale is now durable 4. Compute the ZKI <- no network involved 5. Build and sign the XML (RSA-SHA256) 6. Send to the Tax Administration, wait 7. Verify the signature on their response 8. confirmed | retrying | failed, persisted either way 9. Archive both XML documents for auditStep 3 is the durability boundary: once the invoice is stored, the sale cannot be lost, whatever happens afterwards. Step 4 is why a ZKI is always available — it is computed from your data and the company’s key, and never depends on the Tax Administration being reachable.
Timeouts. Each attempt has a 4-second socket timeout and there are two sequential attempts in-request, so the worst case is roughly nine seconds before FiskHub gives up on the round trip and returns the ZKI. Set your client timeout above that — 15 seconds is comfortable. Retrying harder than this would blow your own timeout and leave you worse off than a fast, honest answer.
The response
{ "id": "inv_01J9Z4T7M2K8Q6R3V5X1Y0B2C4", "status": "confirmed", "jir": "2cf55235-9470-4b5c-a539-463f52b109d2", "zki": "e4d909c290d0fb1ca068ffaddf22cbd0", "qr_payload": "https://porezna.gov.hr/rn?jir=2cf55235-9470-4b5c-a539-463f52b109d2&datv=20260827_1032&izn=450", "number": "1041", "occurred_at": "2026-08-27T10:32:11+02:00", "total": "4.50", "currency": "EUR"}| Field | Always present | Meaning |
|---|---|---|
id | yes | The FiskHub invoice id. The handle for retrieval and retry. |
status | yes | confirmed, retrying or failed. This is the field to branch on. |
jir | yes, may be null | The Tax Administration’s receipt identifier. null when the receipt is not confirmed yet. |
zki | yes | The issuer protection code, 32 lowercase hex characters. Print it. |
qr_payload | yes | The exact string to encode in the receipt’s QR code. |
number | yes | The receipt number used — yours if you sent one, otherwise the allocated one. Print it. |
occurred_at, total, currency | yes | Echoed back as sent. |
fiscal_error | only when there is no JIR | { code, message, retry_scheduled }. Does not invalidate the sale. |
What goes on the receipt
| Printed | Source | When |
|---|---|---|
| Receipt number | number | Always |
| ZKI | zki | Always |
| JIR | jir | Only when non-null — omit the line entirely otherwise |
| QR code | qr_payload, encoded verbatim | Always |
| Date and time | occurred_at | Always |
| Total | total | Always |
The QR code has printing requirements imposed by the regulation — minimum size, quiet zone, error correction, no logo overlay. See QR codes.
A complete integration
import { randomUUID } from "node:crypto";
const API = "https://api.fiskhub.com/v1";
async function fiscalizeSale(sale) { const res = await fetch(`${API}/invoices`, { method: "POST", headers: { Authorization: `Bearer ${process.env.FISKHUB_API_KEY}`, // One key per SALE, generated once and reused across every retry of // that sale — including retries after a timeout. See /guides/idempotency/. "Idempotency-Key": sale.idempotencyKey, "X-Request-Id": sale.transactionId, "Content-Type": "application/json", }, body: JSON.stringify({ device_id: sale.deviceId, occurred_at: sale.occurredAt, // the SALE moment, ISO 8601 with offset total: sale.total, // a STRING: "4.50", never 4.5 currency: "EUR", payment_method: sale.paymentMethod, // "G" | "K" | "O" vat: sale.vatLines, }), signal: AbortSignal.timeout(15_000), // above our ~9 s worst case });
if (res.status >= 400) { const body = await res.json(); throw new FiscalRequestError(body.error); // a 4xx is OUR bug to fix }
return res.json(); // any 200 is a valid, completed sale}
// At the point of sale:const sale = { idempotencyKey: randomUUID(), // once per transaction, kept for its retries transactionId: "txn-7f3c9a21", deviceId: "6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21", occurredAt: new Date().toISOString(), total: "4.50", paymentMethod: "K", vatLines: [{ rate: "25.00", base: "3.60", amount: "0.90" }],};
const invoice = await fiscalizeSale(sale);
// Dispense and print on EVERY 200, whether or not a JIR came back.await dispense(sale);await printReceipt({ number: invoice.number, zki: invoice.zki, jir: invoice.jir, // null is normal — omit the line qr: invoice.qr_payload, occurredAt: invoice.occurred_at, total: invoice.total,});Reading an invoice back
An invoice that came back as retrying may be confirmed minutes later by the retry sweep. To
find out, read it:
curl -sS "https://api.fiskhub.com/v1/invoices/inv_01J9Z4T7M2K8Q6R3V5X1Y0B2C4" \ -H "Authorization: Bearer $FISKHUB_API_KEY"or list the day’s unconfirmed invoices for a reconciliation job:
curl -sS "https://api.fiskhub.com/v1/invoices?status=retrying&from=2026-08-27T00:00:00%2B02:00" \ -H "Authorization: Bearer $FISKHUB_API_KEY"There are no webhooks. Polling on your own schedule is the intended pattern, and it is not urgent — the sweep does the work whether or not anyone is watching.
Next
- Handling the no-JIR response — read before shipping.
- Idempotency — how to pick and reuse the key.
- Status lifecycle — what each status means and which are terminal.
- Invoices reference — every parameter and response.