Invoices
Fiscalize a sale
POST /v1/invoicesSynchronous. Validates locally, computes the ZKI, signs the XML, sends it to the Tax Administration and waits. Typically 1–3 s; worst case about 9 s.
Idempotency-Key is required. See idempotency.
The walkthrough is in fiscalizing a sale.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
device_id | UUID | yes | Resolves company, premises code, OIB, certificate and ISU number. |
occurred_at | ISO 8601 with offset | yes | The sale moment. Starts the 30-day clock. |
total | decimal string | yes | IznosUkupno, including all taxes and fees. |
payment_method | G | K | O | T | yes | Cash, card, other, bank transfer. O covers mixed. T is attended-only — see payment methods. |
sale_type | unattended | attended | no | Which rules apply. Defaults to unattended. See attended receipts. |
number | digits, no leading zeros | no | BrOznRac. Send it if you keep the sequence; omit it and FiskHub allocates the next one for that selling point. See below. |
currency | 3 letters | no | Defaults to EUR, the only accepted value. |
vat | tax line[] | no | Pdv. Rates must be 0.00, 5.00, 13.00 or 25.00. Omitted: derived from total using the company’s default_vat_rate, which can only describe one rate. |
consumption_tax | tax line[] | no | Pnp. Rate 0.00–3.00. |
other_taxes | named tax line[] | no | OstaliPor. Each line may carry a name (NazivPor, ≤ 100). |
vat_exempt_amount | decimal string | no | IznosOslobPdv. |
margin_amount | decimal string | no | IznosMarza. |
non_taxable_amount | decimal string | no | IznosNePodlOpor. |
fees | { name, amount }[] | no | Naknade, already included in total. name ≤ 100. |
special_purpose | string ≤ 1000 | no | SpecNamj. Free text. |
late_delivery | boolean | no | NakDost. Set automatically on every re-send; sending true forces it on the first attempt too. |
vat_registered | boolean | no | USustPdv. Defaults to the company’s own registration flag. |
Attended receipts (sale_type: "attended") accept five more. Each is rejected on an
unattended sale, not ignored:
| Field | Type | Notes |
|---|---|---|
sequence_mark | P | N | OznSlijed. Per premises, or per device. |
register_mark | digits, no leading zeros | OznNapUr — the cash register’s mark. Not an ISU number. |
operator_oib | 11 digits | OibOper — the operator who issued the receipt. |
recipient_oib | 11 digits | OibPrimateljaRacuna — the buyer, for a B2B receipt. |
paragon_number | string ≤ 100 | ParagonBrRac — a hand-written receipt being copied up. |
A tax line is { "rate": "25.00", "base": "3.60", "amount": "0.90" }. A named tax line adds
"name": "Porez na luksuz".
Every field except device_id, occurred_at, total and payment_method is optional, and
Oib and OznPosPr are deliberately not accepted at all: the OIB must equal the signing
certificate’s or the Tax Administration answers s005, and the premises code is part of the
invoice’s uniqueness key. Both come from the device you named.
Omitted is not the same as null
Optional fields accept null, and the two mean different things:
| You send | What happens |
|---|---|
| nothing | Resolve down the chain: request → the device’s device_config → the company’s fiscal_config → the specification default. |
null | Explicitly none. The element is omitted from the message and no configured default is consulted. |
The distinction is the point of the chain. A device whose device_config sets
special_purpose must not have that text reappear on the one sale that deliberately cleared it:
{ "device_id": "…", "occurred_at": "…", "total": "4.50", "payment_method": "K", "special_purpose": null }Configuration keys use the same snake_case spelling as these fields, so what an operator types
into a device’s configuration is literally the field name they would otherwise have sent. A
configured value of the wrong type is ignored rather than coerced — a JSON number where a decimal
string belongs must never reach a signed fiscal message.
null inside device_config masks fiscal_config in exactly the same way.
Payment methods
G cash | K card | O other / mixed | T bank transfer | |
|---|---|---|---|---|
| Unattended (§2.2) | yes | yes | yes | no |
| Attended (§2.1) | yes | yes | yes | yes |
There is nobody at a self-service device to take a bank transfer, so T on an unattended sale is
hr.invalid_payment_method. On an attended receipt T is
valid, but it may not be combined with recipient_oib — restrictive error 181.
The receipt number
number is BrOznRac, and you may either supply it or let FiskHub allocate it. Both are
supported so that the decision belongs to whoever already owns the sequence:
- Supply
numberand it is used exactly as sent. Use this when your own system is the book of record for receipt numbering. - Omit
numberand FiskHub allocates the next one for that selling point and calendar year, inside the same transaction that stores the invoice. Allocating and inserting in one transaction is what makes it safe when several devices at the same premises sell at once.
Either way the number that was used comes back in the response, and it is the number that must be printed on the receipt.
A supplied number must be digits with no leading zeros. 0 and 007 are rejected with
hr.invalid_receipt_number before anything is sent.
Sending the same number twice for the same selling point, device and year is refused with
409 invoice_already_exists rather than silently accepted. That is the business key doing its
job: a sale is never fiscalized twice. It is a different thing from replaying an
Idempotency-Key, which returns the original response — see
idempotency.
Attended receipts
FiskHub is built for unattended sales — a self-service device, specification §2.2 — and
that is what sale_type defaults to. An integration that has never sent the field behaves
exactly as it always did.
sale_type: "attended" selects the ordinary receipt rules of §2.1: a receipt issued by a
person, at a till, to a customer standing there. The message is a different object, and these
are the differences that matter:
unattended (§2.2) | attended (§2.1) | |
|---|---|---|
OznSlijed | P only — per business premises | N or P |
OznNapUr | the device’s ISU number, issued through ePorezna | the cash register’s mark, which you assign |
NacinPlac | G, K, O | G, K, O, T |
OibOper | the company’s own OIB — there is no cashier | the operator’s OIB |
OibPrimateljaRacuna | not in the message at all | permitted, for B2B |
ParagonBrRac | explicitly unused | permitted |
| Restrictive errors | 176–179, 182–184, v185 | those, plus 180 and 181 |
# An attended B2B receipt: numbered per device, issued by a named operator,# attributed to the buyer, and paid by card because a recipient OIB may not# be combined with T.curl -sS https://api.fiskhub.com/v1/invoices \ -H "Authorization: Bearer $FISKHUB_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "device_id": "6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21", "occurred_at": "2026-08-27T14:07:33+02:00", "total": "150.00", "payment_method": "K", "sale_type": "attended", "sequence_mark": "N", "register_mark": "1", "operator_oib": "69435151530", "recipient_oib": "25089460814", "vat": [{ "rate": "25.00", "base": "120.00", "amount": "30.00" }] }'The attended fields also participate in the configuration chain. A till that always reports the
same register mark can set register_mark once in the device’s configuration and stop sending
it; sale_type: "attended" itself can be configured the same way, so an attended till never has
to send the discriminator at all.
Two restrictive errors that are dormant for unattended sales become live here:
| Rule | Error |
|---|---|
recipient_oib passes the ISO 7064 check digit (180) | hr.invalid_oib |
recipient_oib is not combined with payment_method: "T" (181) | hr.invalid_payment_method |
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-27T21:15:02+02:00", "total": "28.85", "currency": "EUR", "payment_method": "O", "vat": [{ "rate": "25.00", "base": "18.00", "amount": "4.50" }], "consumption_tax": [{ "rate": "3.00", "base": "18.00", "amount": "0.54" }], "non_taxable_amount": "3.31", "fees": [{ "name": "Povratna naknada", "amount": "2.50" }], "special_purpose": "Automat 12 — kiosk" }'Response — 200
| Field | Always present | Notes |
|---|---|---|
id | yes | The handle for retrieval and retry. |
status | yes | confirmed, retrying or failed. Branch on this. |
jir | yes, may be null | null means not confirmed yet, not an error. |
zki | yes | 32 lowercase hex. Print it. |
qr_payload | yes | Encode verbatim. jir= or zki= form. |
number | yes | The receipt number used — yours if you sent one, otherwise the allocated one. Print it. |
occurred_at, total, currency | yes | Echoed back. |
fiscal_error | only without a JIR | { code, message, retry_scheduled }. Does not invalidate the sale. |
{ "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=2885", "number": "1041", "occurred_at": "2026-08-27T21:15:02+02:00", "total": "28.85", "currency": "EUR"}The Tax Administration was unreachable — still HTTP 200, still a valid sale:
{ "id": "inv_01J9Z4T7M2K8Q6R3V5X1Y0B2C4", "status": "retrying", "jir": null, "zki": "e4d909c290d0fb1ca068ffaddf22cbd0", "qr_payload": "https://porezna.gov.hr/rn?zki=e4d909c290d0fb1ca068ffaddf22cbd0&datv=20260827_1032&izn=2885", "number": "1041", "occurred_at": "2026-08-27T21:15:02+02:00", "total": "28.85", "currency": "EUR", "fiscal_error": { "code": "upstream_unavailable", "message": "Tax Administration did not respond. Will retry automatically.", "retry_scheduled": true }}Errors
| Status | Codes |
|---|---|
400 | invalid_request, missing_idempotency_key, hr.device_missing_isu, device_inactive, company_missing_certificate, certificate_expired, hr.certificate_oib_mismatch, hr.sale_older_than_30_days, hr.date_in_future, hr.invalid_vat_rate, hr.invalid_pnp_rate, hr.invalid_payment_method, hr.amount_out_of_range, hr.invalid_oib, hr.invalid_receipt_number |
401 | invalid_api_key, api_key_revoked |
403 | mode_mismatch, insufficient_scope |
404 | invalid_request — no such device |
409 | idempotency_key_reused, invoice_already_exists — a supplied number is already used at this selling point |
429 | rate_limited. See rate limits. |
500 | internal_error — retry with the same key. |
Retrieve an invoice
GET /v1/invoices/{id}Returns the stored invoice, including any change made by the retry sweep since the original
POST. This is how you learn that a retrying invoice has since been confirmed. There are no
webhooks.
The response is the POST shape plus the record’s metadata, and it uses the full five-value
status vocabulary:
{ "id": "inv_01J9Z4V2P8N5H1D7S9F3G6J0K2", "status": "confirmed", "jir": "8b31c705-1f4a-49de-bc0a-2f6e5d7a9c14", "zki": "7b1de0a44f9c3e5820a6cd1477bb92ef", "qr_payload": "https://porezna.gov.hr/rn?jir=8b31c705-1f4a-49de-bc0a-2f6e5d7a9c14&datv=20260827_1041&izn=1230", "number": "1042", "occurred_at": "2026-08-27T10:41:07+02:00", "total": "12.30", "currency": "EUR", "company_id": "3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57", "device_id": "6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21", "payment_method": "G", "mode": "live", "attempts": 6, "late_delivery": true, "deadline_at": "2026-09-26T10:41:07+02:00", "confirmed_at": "2026-08-27T13:15:44+02:00", "created_at": "2026-08-27T10:41:08+02:00", "last_error_class": null}| Extra field | Notes |
|---|---|
company_id, device_id | Where the sale came from. The premises code is on the device. |
payment_method, mode | As fiscalized. |
attempts | How many times it has been sent. |
late_delivery | NakDost — true once submitted after the sale moment. |
deadline_at | occurred_at + 30 days. Past this it becomes expired. |
confirmed_at | When the JIR arrived, or null. |
last_error_class | transient, validation, certificate, or null. Only transient is swept. |
The raw XML
GET /v1/invoices/{id}?include_messages=trueAdds a messages array to the response: the request and response XML of every attempt made
on this invoice, oldest first. This is what the dashboard’s invoice browser shows when you open
a sale and ask what was actually sent.
{ "id": "inv_01J9Z4V2P8N5H1D7S9F3G6J0K2", "status": "confirmed", "jir": "8b31c705-1f4a-49de-bc0a-2f6e5d7a9c14", "number": "1042", "messages": [ { "id": "5e1b7d90-3c42-4a08-8f61-0d29b4e7a135", "message_uuid": "c0a8f3d2-71b4-4e6a-9d38-5f2e1b7c0a94", "attempt_no": 1, "http_status": null, "error_code": "upstream_unavailable", "error_message": "Tax Administration did not respond within 4000 ms.", "duration_ms": 4002, "created_at": "2026-08-27T10:41:08+02:00", "request_xml": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><tns:RacunZahtjev …>", "response_xml": null }, { "id": "9d4c2a71-0f38-4b52-a6e7-1c8b3d5f0e26", "message_uuid": "7f31c0b5-9a2e-4d17-8c60-3b5a1e9d4f82", "attempt_no": 2, "http_status": 200, "error_code": null, "error_message": null, "duration_ms": 611, "created_at": "2026-08-27T13:15:44+02:00", "request_xml": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><tns:RacunZahtjev …>", "response_xml": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><tns:RacunOdgovor …>" } ]}| Field | Notes |
|---|---|
message_uuid | <IdPoruke> for that attempt. A fresh UUID every attempt, including retries of the same sale — the spec requires it, and it is how an exchange is identified upstream. |
attempt_no | 1 for the original send, incrementing for each retry. |
http_status | The transport status, or null when the request never got an answer. |
error_code, error_message | The classified failure for that attempt, or null. |
duration_ms | Wall-clock time of that single exchange. |
request_xml, response_xml | Verbatim. response_xml is null on a timeout. |
Omit the parameter and the response is unchanged — no messages key at all.
On the fiscalization call itself
The same flag works on POST /v1/invoices and POST /v1/invoices/{id}/retry, so you can see
exactly what went to the Tax Administration without a second round trip:
POST /v1/invoices?include_messages=trueOn a retry the array carries every attempt, not just the latest — the sends that failed alongside the one that worked, which is usually the comparison you actually want.
Two things to know about the POST form:
- It is a query parameter or the
X-Include-Messagesheader — never a body field. The request body is hashed for idempotency, so a debug flag carried in the body would turn an otherwise identical resend into a409 idempotency_key_reused. Toggling the header changes nothing about the sale, so it can never burn a key. - The stored idempotent response never contains the XML. Replaying a key returns the canonical
response whether or not the original call asked for
messages; ask again on the replay and the trail is re-read fromfiscal_messages.
List invoices
GET /v1/invoicesNewest first by occurred_at. Filters combine with AND, and results are always scoped to the API
key’s tenant and mode.
| Parameter | Notes |
|---|---|
company_id, device_id | UUID |
premises_code | string, OznPosPr |
status | pending, confirmed, retrying, failed, expired |
from, to | ISO 8601 with offset. from inclusive, to exclusive. |
limit | 1–200, default 50 |
cursor | From next_cursor |
# Today's unconfirmed invoices — the reconciliation query.curl -sS -G "https://api.fiskhub.com/v1/invoices" \ -H "Authorization: Bearer $FISKHUB_API_KEY" \ --data-urlencode "status=retrying" \ --data-urlencode "from=2026-08-27T00:00:00+02:00" \ --data-urlencode "to=2026-08-28T00:00:00+02:00"{ "object": "list", "data": [ { "id": "inv_01J9Z4V2P8N5H1D7S9F3G6J0K2", "status": "retrying", "jir": null, "zki": "7b1de0a44f9c3e5820a6cd1477bb92ef", "qr_payload": "https://porezna.gov.hr/rn?zki=7b1de0a44f9c3e5820a6cd1477bb92ef&datv=20260827_1041&izn=1230", "number": "1042", "occurred_at": "2026-08-27T10:41:07+02:00", "total": "12.30", "currency": "EUR", "company_id": "3a5f2b18-7c4d-4e91-9a2b-6d8e0f1c3a57", "device_id": "6f1c0f9e-2b7a-4a56-9d3e-1f0b8a4c7d21", "payment_method": "G", "mode": "live", "attempts": 4, "late_delivery": true, "deadline_at": "2026-09-26T10:41:07+02:00", "confirmed_at": null, "created_at": "2026-08-27T10:41:08+02:00", "last_error_class": "transient", "fiscal_error": { "code": "upstream_unavailable", "message": "Tax Administration did not respond. Will retry automatically.", "retry_scheduled": true } } ], "has_more": false, "next_cursor": null}One filtered list call is far cheaper than N single reads — see rate limits.
Retry an invoice
POST /v1/invoices/{id}/retryResubmits the invoice with NakDost = true and a fresh message id. The business data is
unchanged, so the ZKI is unchanged.
Call it after fixing whatever made an invoice failed — the right certificate, an assigned ISU
number, a replaced expired certificate. Invoices in retrying are already picked up
automatically every five minutes; calling this simply retries one sooner.
Returns the same shape as POST /v1/invoices. The outcome may still be retrying if the Tax
Administration is still unreachable.
curl -sS -X POST \ "https://api.fiskhub.com/v1/invoices/inv_01J9Z4V2P8N5H1D7S9F3G6J0K2/retry" \ -H "Authorization: Bearer $FISKHUB_API_KEY"Terminal invoices cannot be retried
Both return 409:
confirmedalready has a JIR. A second one would break the rule that a sale is never fiscalized twice.expiredis past its 30-day deadline; the Tax Administration rejects it permanently.
{ "error": { "type": "conflict", "code": "invalid_request", "message": "Invoice inv_01J9Z4T7M2K8Q6R3V5X1Y0B2C4 is already confirmed with JIR 2cf55235-9470-4b5c-a539-463f52b109d2. A sale is never fiscalized twice.", "param": "id", "upstream": null, "doc_url": "https://docs.fiskhub.com/errors/invalid_request" }}