Skip to content

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

Terminal window
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

FieldTypeNotes
device_idUUIDThe 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_atISO 8601 with offsetThe moment of the sale, not of the request. Feeds the ZKI and starts the 30-day clock.
totaldecimal stringIznosUkupno — what the customer paid, including all taxes and fees.
payment_methodG, K, O or TCash, 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

FieldTypeNotes
currency3 lettersDefaults to EUR, which is the only accepted value.
vatarray of tax linesPdv — 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_taxarray of tax linesPnpporez na potrošnju. Rate between 0.00 and 3.00.
other_taxesarray of named tax linesOstaliPor. Each line may also carry a name (NazivPor, at most 100 characters).
vat_exempt_amountdecimal stringIznosOslobPdv.
margin_amountdecimal stringIznosMarza.
non_taxable_amountdecimal stringIznosNePodlOpor.
feesarray of { name, amount }Naknade — deposits and similar charges already included in total.
numberdigits, no leading zerosBrOznRac. Supply it if you keep the sequence yourself; omit it and FiskHub allocates the next one for that selling point. See below.
special_purposestring, max 1000SpecNamj. Free text carried through to the Tax Administration; useful for your own machine or route reference.
late_deliverybooleanNakDost. Set automatically on every re-send; send true to force it on the first attempt, which is what copying up a backlog wants.
vat_registeredbooleanUSustPdv. Defaults to the company’s own registration flag.
sale_typeunattended or attendedWhich 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 null and 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-up
amount = total - base

So 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 number and it is used exactly as sent.
  • We keep it. Omit number and 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.

RuleError if violated
occurred_at no more than 30 days oldhr.sale_older_than_30_days
occurred_at not in the futurehr.date_in_future
VAT rate is 0.00, 5.00, 13.00 or 25.00hr.invalid_vat_rate
Consumption tax rate between 0.00 and 3.00hr.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 EURhr.amount_out_of_range
Company OIB passes its checksumhr.invalid_oib
Receipt number is digits with no leading zeros, and not 0hr.invalid_receipt_number
Device has an ISU numberhr.device_missing_isu
Company has a valid certificatecompany_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 audit

Step 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"
}
FieldAlways presentMeaning
idyesThe FiskHub invoice id. The handle for retrieval and retry.
statusyesconfirmed, retrying or failed. This is the field to branch on.
jiryes, may be nullThe Tax Administration’s receipt identifier. null when the receipt is not confirmed yet.
zkiyesThe issuer protection code, 32 lowercase hex characters. Print it.
qr_payloadyesThe exact string to encode in the receipt’s QR code.
numberyesThe receipt number used — yours if you sent one, otherwise the allocated one. Print it.
occurred_at, total, currencyyesEchoed back as sent.
fiscal_erroronly when there is no JIR{ code, message, retry_scheduled }. Does not invalidate the sale.

What goes on the receipt

PrintedSourceWhen
Receipt numbernumberAlways
ZKIzkiAlways
JIRjirOnly when non-null — omit the line entirely otherwise
QR codeqr_payload, encoded verbatimAlways
Date and timeoccurred_atAlways
TotaltotalAlways

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:

Terminal window
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:

Terminal window
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