Skip to main content

Croatia — Fiskalizacija (CIS)

Research doc for the Croatia fiscalization adapter. Consumed by the implementation bead (sibling of fi-g40 for Hungary). No code in this bead.

Status: Research — no adapter code written yet. Strategic context: Phase-1 Central/Adriatic cluster, paired with Hungary and Slovenia (fi-7en). The cluster's only country requiring a paid annual certificate (FINA), and the only one that performs real cryptographic signing on every fiscalization request. ~250K obligated taxpayers. Mandatory since 2013-01-01. See apps/docs-internal/docs/ops/country-prioritization.md and the Phase-1 thesis (fi-awx).

Cloud-OK caveat. Unlike Hungary (REST + tokens, no cert) Croatia requires a FINA application certificate per fiscal device for XML-DSig signing of every SOAP request plus computation of a per-receipt ZKI (issuer protection code). The cert is a pure software credential (PKCS#12) — no HSM, no hardware seal — so Cloud deployment remains viable, but key custody and rotation are real ops surface. See §3 and §9 ("What could force us off Cloud-OK?").


1. Regulatory scope

What must be reported

Croatia's Fiskalizacija system, operated by Porezna uprava (PU — the Tax Administration) through APIS-IT's CIS (Centralni Informacijski Sustav — Central Information System), requires real-time reporting of every cash receipt issued by a fiscal-obligated taxpayer. The legal basis is the Zakon o fiskalizaciji u prometu gotovinom (Fiscalization in Cash Transactions Act, NN 133/12; consolidated text in NN 115/16, 106/18, 121/19, 138/20, 114/22, 152/22). Cited from https://www.porezna-uprava.hr/HR_Fiskalizacija/.

Effective dateScope change
2013-01-01Stage 1 launch — mandatory for hospitality + freelance professionals issuing cash receipts (gotovinski računi).
2013-04-01Stage 2 — wholesale & retail trade.
2013-07-01Stage 3 — all other taxpayers obligated to report cash receipts.
2017Added OznSlijed (sequencing-marker selector) to the schema; Hrvatska Pošta integration milestone.
2019-01-01Schema v1.5 — added support for buyer OIB on receipts (KupacOib).
2023-01-01Croatia adopts EUR; receipts denominated in EUR (HRK no longer issued).
2025-09-01Fiskalizacija 2.0 — staged rollout begins. Adds tip reporting (NapojnicaZahtjev), supplemental fields for PrateciDokument, and groundwork for mandatory B2B/B2G eRačun by 2026-01-01. This adapter targets the v1.x cash-receipt envelope first; 2.0 fields are listed in §10 open questions.
2026-01-01Mandatory eRačun (electronic invoice) for all B2B and B2G — separate flow (see §1.4 below).

Cash-receipt threshold: none. Every cash, card, or other-non-bank-transfer receipt issued by an obligated taxpayer must be fiscalized in real-time (or within the 48-hour offline grace window — §6).

What is not in scope of RacunZahtjev

  • Bank-transfer-only invoices (transakcijski račun → transakcijski račun): not fiscalized through CIS as a cash receipt; from 2026-01-01 they fall under mandatory eRačun via the FINA Servis eRačun platform (see §1.4).
  • Receipts of non-fiscal-obligated entities (state organs in non-economic activity, certain agriculture exemptions, lottery, gambling — listed in Article 5 of the Act).
  • Pre-2013 historical receipts — no backfill obligation.
  • Internal documents (računi za vlastite potrebe) — not transmitted.

eRačun (separate track — out of scope for this adapter)

Croatia's mandatory B2B/B2G electronic invoicing system as of 2026-01-01 is eRačun, operated through FINA's Servis eRačun za državu and the e-poslovanje network. It uses UBL 2.1 / EN 16931 (CIUS HR-CEN), routing through the Peppol network (FINA is the Peppol Authority for Croatia). The endpoints and message format are entirely different from CIS Fiskalizacija:

Fiskalizacija (this adapter)eRačun (separate track)
PurposeReal-time cash-receipt reporting to PUStructured electronic B2B/B2G invoice exchange
FormatCustom XML (f73:RacunZahtjev)UBL 2.1 + CIUS HR-CEN
TransportSOAP 1.1 with WS-Security signingPeppol AS4 / HTTP POST to FINA gateway
AuthorityPorezna uprava / CISFINA + Ministry of Finance
Fiscal IDsJIR + ZKIUUID + Peppol message ID
Mandatory since2013-01-012026-01-01 (B2B/B2G)

This adapter implements Fiskalizacija only. A separate eRačun adapter is tracked under fi-fjm cluster validation. Many merchants will need both — the cash receipt fiscalized through CIS, and (for B2B sales) a structured eRačun submitted via FINA. The two systems do not share state.

Obligated parties

FormWhoOIB type
Pravna osobaLimited companies (d.o.o., j.d.o.o., d.d.) — corporateOIB of the legal entity
ObrtSole proprietors / craftspeopleOIB of the natural person
Slobodno zanimanjeFreelance professionals (lawyers, doctors, etc.)OIB of the natural person
Paušalni obrtLump-sum-tax sole proprietorsOIB of the natural person
Branch of foreign entityCroatian branch of foreign businessOIB issued to the branch

The taxpayer's OIB (Osobni identifikacijski broj — 11-digit personal/tax identification number) is the primary key throughout the schema.


2. API surface

Base URLs and ports

EnvironmentEndpoint (host:port/path)Notes
CIS DEMO (sandbox)https://cistest.apis-it.hr:8449/FiskalizacijaServiceTestAPIS-IT-hosted test environment. Requires FINA demo application certificate (free, see §7).
CIS Productionhttps://cis.porezna-uprava.hr:8449/FiskalizacijaServiceProduction. Requires FINA production application certificate (paid, see §3).

Cited from porezna-uprava.gov.hr → "Tehnička specifikacija za korisnike" v1.5+ (Technical specification for fiscalization users) and APIS-IT's public CIS service WSDL.

Port 8449. Both environments listen on TCP 8449, not 443. Outbound firewall rules in customer infrastructure must allow that port. (Common first-day onboarding miss — flag in the integration runbook.)

Protocol

SOAP 1.1 over HTTPS with mutual TLS (mTLS) using the FINA application certificate as the client cert, plus XML-DSig (W3C XML Digital Signature, enveloped form) signing the SOAP <Body>. Content-Type: text/xml; charset=UTF-8. SOAPAction header is empty ("") — CIS dispatches by body element name, not by SOAPAction.

The v1.x schema namespace is http://www.apis-it.hr/fin/2012/types/f73. A WSDL is published at the test endpoint https://cistest.apis-it.hr:8449/FiskalizacijaServiceTest?wsdl.

Operations

All operations are POST; the request and response top-level elements live in the f73 namespace.

OperationRequest elementResponse elementPurpose
Echo (health)EchoRequestEchoResponseBidirectional liveness probe; returns the request payload string. No signature required. Use as smoke test.
Fiscalize cash receiptRacunZahtjevRacunOdgovorThe hot path. Submit a single fiscal receipt. Returns Jir (or error).
Subsequent delivery (offline catch-up)RacunZahtjev with NakDost=trueRacunOdgovorSame envelope, flag bit set; used for receipts issued during an offline window.
Supporting documentPrateciDokumentZahtjev (PD)PrateciDokumentOdgovor"Prateci dokument" — the goods-with-document flow used when the receipt issued at delivery is not yet a fiscal receipt (e.g. delivery-then-pay scenarios). Returns a JIR for the PD.
Verify by JIR/ZKIProvjeraZahtjevProvjeraOdgovor(Test environment only.) Lookup an issued receipt by its Jir / ZKI. Production has the public web verifier instead.
Tip (Fiskalizacija 2.0)NapojnicaZahtjevNapojnicaOdgovorReport tip ("napojnica") attached to a previously fiscalized receipt. v2.0 schema (f95 namespace, see §10).

Adapter hot path (v1.x): RacunZahtjevRacunOdgovor.

XML structure (the RacunZahtjev SOAP envelope)

<soapenv:Envelope xmlns:f73="http://www.apis-it.hr/fin/2012/types/f73">
<soapenv:Header/>
<soapenv:Body>
<f73:RacunZahtjev Id="signXmlId">
<f73:Zaglavlje> <!-- header -->
<f73:IdPoruke>UUID</f73:IdPoruke>
<f73:DatumVrijeme>YYYY-MM-DDTHH:MM:SS</f73:DatumVrijeme>
</f73:Zaglavlje>
<f73:Racun> <!-- the fiscal receipt -->
<f73:Oib>11111111119</f73:Oib>
<f73:USustPdv>true</f73:USustPdv>
<f73:DatVrijeme>DD.MM.YYYYTHH:MM:SS</f73:DatVrijeme>
<f73:OznSlijed>P|N</f73:OznSlijed>
<f73:BrRac>
<f73:BrOznRac>1</f73:BrOznRac>
<f73:OznPosPr>POSL1</f73:OznPosPr>
<f73:OznNapUr>1</f73:OznNapUr>
</f73:BrRac>
<f73:Pdv>
<f73:Porez>
<f73:Stopa>25.00</f73:Stopa>
<f73:Osnovica>40.00</f73:Osnovica>
<f73:Iznos>10.00</f73:Iznos>
</f73:Porez>
</f73:Pdv>
<!-- optional: Pnp (sales tax), OstaliPor (other taxes), Naknade -->
<f73:IznosUkupno>50.00</f73:IznosUkupno>
<f73:NacinPlac>G</f73:NacinPlac> <!-- G/K/T/O/C -->
<f73:OibOper>11111111119</f73:OibOper> <!-- operator OIB -->
<f73:ZastKod>32-hex-MD5</f73:ZastKod> <!-- ZKI, computed locally -->
<f73:NakDost>false</f73:NakDost> <!-- subsequent-delivery flag -->
<!-- optional: ParagonBrRac, SpecNamj, KupacOib, NakDostId... -->
</f73:Racun>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<!-- enveloped XML-DSig over the RacunZahtjev element -->
</Signature>
</f73:RacunZahtjev>
</soapenv:Body>
</soapenv:Envelope>

The <Signature> is inside <f73:RacunZahtjev> — enveloped form per W3C XML-DSig — and references the parent by Id="signXmlId". The server expects exactly this layout; a detached or enveloping signature fails with SOAP fault s002.

Response shape

Happy path:

<f73:RacunOdgovor>
<f73:Zaglavlje>
<f73:IdPoruke>UUID-echoed</f73:IdPoruke>
<f73:DatumVrijeme>DD.MM.YYYYTHH:MM:SS</f73:DatumVrijeme>
</f73:Zaglavlje>
<f73:Jir>e2956e8a-1f63-43a5-86c6-9be0c6f0fdf8</f73:Jir>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#"></Signature>
</f73:RacunOdgovor>

The Jir (Jedinstveni identifikator računa — Unique Receipt Identifier) is a UUID-shaped 36-char string. CIS signs the response with the Porezna uprava certificate; the adapter must verify that signature against the embedded certificate's chain to a known PU root, otherwise we cannot trust the JIR.

Error path: SOAP <faultcode> + <faultstring> with structured <f73:PorukaGreske> containing <f73:SifraGreske> + text. See §8.


3. Authentication

The FINA application certificate

CIS authenticates and authorizes via a FINA-issued application certificate ("FINA RDC" — Razvoj digitalnih certifikata) for fiscalization. There are two cert types in operation:

TypePurposeCostValidity
PRODUKCIJSKA aplikacijska certifikat (production)Sign + mTLS to cis.porezna-uprava.hr~€60 first issuance, ~€53 renewal (2026 prices, single-cert FISKAL package; varies by FINA package)5 years
DEMO aplikacijska certifikat (test)Sign + mTLS to cistest.apis-it.hrFree5 years

Pricing — current. FINA price-list (2026-04, Naknade RDC): production "FISKAL 1" application certificate is €59.79 inclusive of VAT at first issuance, €53.16 at renewal. Earlier FiscalAPI internal estimates of "€50/yr" remain materially correct amortised over the 5-year validity (€59.79 ÷ 5 = €11.96/yr first cycle); the bead's "€50/yr" figure was the one-shot procurement cost, not the annualised cost. Document this distinction in customer onboarding — many merchants assume €50/yr ongoing when the actual TCO is closer to €11/yr after the initial purchase.

Per fiscal device or per legal entity? Per legal entity is the FINA default (one cert per OIB), and the cert may be installed on multiple fiscal devices belonging to that OIB. Per-device certs are an option but rarely worth the extra cost. Our adapter therefore models the cert as a per-tenant credential (one certificate object per merchant OIB), not per location.

Cited: FINA "Cjenik usluga" v2026-04-01, https://www.fina.hr/cjenik-usluga-rdc.

Cert lifecycle

  1. Procurement — merchant goes to a FINA office (or uses FINA online identification) with company registration documents. Receives a CD/USB stick or download link with a .pfx (PKCS#12) file plus a one-time-use activation password.
  2. Activation — within 30 days of issuance, the merchant logs into FINA's Mojcert portal and downloads the activated PKCS#12. After this point the password is set by the merchant.
  3. Upload to ZyntemPOST /v1/certificates with the PKCS#12 bytes and the merchant-set password. Stored AES-encrypted at rest, key in KMS. We track:
    • oib derived from the cert's serialNumber (extension OID 2.5.4.5) — must equal the merchant's tax OIB.
    • not_before / not_after from tbsCertificate.validity.
    • SHA-256 thumbprint for audit.
    • Issuer DN (we recognise: OU=RDC 2020, O=Financijska agencija, C=HR for v3 production CA; OU=Demo CA, O=Financijska agencija, C=HR for demo).
  4. Use — for every receipt:
    • Compute ZKI (§3 below), embedding the cert's RSA private key as the signing key.
    • Sign the SOAP envelope with XML-DSig (RSA-SHA1 in v1.x — yes, SHA-1, this is the spec. v2.0 will permit RSA-SHA256; details in §10).
    • mTLS handshake with the same cert.
  5. Renewal alerts — webhook fires 60 days, 30 days, 7 days before not_after. Customer must renew before then or fiscalization stops.
  6. Revocation — FINA publishes CRL; we check before each session-start (cached 4h). On revocation, mark cert disabled, alert merchant.

ZKI (Zaštitni kod izdavatelja — Issuer Protection Code)

ZKI is a locally computed 32-character lowercase hex digest that ties the receipt content to the issuer's certificate without the tax authority needing to verify the signature itself. It is printed on the customer-facing receipt alongside the JIR.

Pre-image (concatenation, no separators):

ZKI_input = OIB                        # 11 digits
|| DatVrijeme as DD.MM.YYYYTHH:MM:SS
|| BrOznRac # invoice number (string)
|| OznPosPr # business premise mark
|| OznNapUr # cash register mark
|| IznosUkupno # total, formatted with comma decimal, e.g. "50,00"

Algorithm (v1.x):

sig    = RSA-SHA1-sign(privkey_from_cert, ZKI_input)        # 256 bytes (for 2048-bit key)
ZKI = lowercase(hex(MD5(sig))) # 32 hex chars

The IznosUkupno formatting matters — Croatian locale uses a comma decimal separator, two decimals always, no thousands separator. 1234.50 is "1234,50". Mismatched formatting silently produces a different ZKI; CIS won't reject it, but the receipt won't verify against the public lookup tool.

Algorithm (v2.0, optional):

In Fiskalizacija 2.0 (rolling out from 2025-09-01) RacunZahtjev accepts a ZastKodv2 field with RSA-SHA256 + SHA-256 digest. v1.x ZKI is still accepted. We default to v1.x and feature-flag v2.0 in HungaryConfig's sister CroatiaConfig.

Reference XML-DSig parameters (signing the SOAP body)

ParameterValue
Canonicalizationhttp://www.w3.org/TR/2001/REC-xml-c14n-20010315 (Exclusive XML-C14N)
Signature algorithmhttp://www.w3.org/2000/09/xmldsig#rsa-sha1 (v1.x)
Digest algorithmhttp://www.w3.org/2000/09/xmldsig#sha1 (v1.x)
Reference URI#signXmlId (id-attr on the RacunZahtjev element)
Transformenveloped-signature + exclusive c14n
KeyInfo<X509Data><X509Certificate> (base64 DER of leaf cert)

The CIS server is strict: missing xmlns:soapenv on the canonicalized body, extra whitespace, or a wrong canonicalization mode all cause s002 ("Neispravan digitalni potpis" — Invalid digital signature).


4. Payload shape

Racun element fields (cited from FiskalizacijaSchema XSD v1.5)

ElementCardinalityType / valuesNotes
Oib1string, exactly 11 digitsIssuer's OIB. Must equal the OIB embedded in the signing cert.
USustPdv1bool (true / false)True if the issuer is in the VAT system. False suppresses Pdv block.
DatVrijeme1string dd.MM.yyyy'T'HH:mm:ssIssuance datetime in Croatian-locale format with literal T separator. Not ISO-8601. Local time (Europe/Zagreb), no timezone offset.
OznSlijed1enum P / NSequence selector. P = sequence per business premise (PoslovniProstor); N = sequence per cash register (NaplatniUredjaj). Set once for the location, immutable for the calendar year.
BrRac1compositeReceipt number triple (see below).
Pdv0..1array of PorezVAT rate breakdown — required when USustPdv=true.
Pnp0..1array of PorezPorez na potrošnju — local consumption tax (cities/municipalities, max 3%; mostly hospitality).
OstaliPor0..1array of OstPorezOther taxes (tobacco, fuel, alcohol excise reported as line items where applicable).
IznosOslobPdv0..1string decimalNet amount exempt from VAT (Article 39 / 41 / 44 / 45 of VAT Act).
IznosMarza0..1string decimalMargin-scheme net (used goods, travel agencies).
IznosNePodlOpor0..1string decimalAmount not subject to taxation.
Naknade0..1array of NaknadaSupplemental fees (e.g. packaging fee).
IznosUkupno1string decimal 0.00Total including all VAT/tax/fees. Comma-decimal format used in ZKI input but XML stays dot-decimal (the XSD type is decimal).
NacinPlac1enumPayment method (see below).
OibOper1string, 11 digitsOIB of the operator (cashier) — always a natural person's OIB.
ZastKod1string, 32 lowercase hex charsZKI computed per §3.
NakDost1booltrue = subsequent delivery (offline catch-up). False on hot path.
ParagonBrRac0..1stringParagon-block number when issuing from the manual paragon block during a CIS outage.
SpecNamj0..1stringSpecial-purpose code (rarely populated; e.g. internal special tariffs).
KupacOib0..1string, 11 digitsBuyer OIB — added in v1.5 (since 2019), permitted for B2B receipts.

Receipt number composite (BrRac)

BrRac
├── BrOznRac # numeric sequence number, monotonic — restarts at 1 each calendar year
├── OznPosPr # business-premise mark (string, 1-20 chars; e.g. "POSL1", "ZG-CENTAR")
└── OznNapUr # cash-register mark (string, 1-20 chars; e.g. "1", "BLAGA-A")

The legal "invoice number" printed on the receipt has the format BrOznRac/OznPosPr/OznNapUr — e.g. 42/POSL1/1. This is what the merchant prints; CIS stores all three components separately.

Per-business-premise vs per-cash-register sequencing. Set OznSlijed = P if your store wants one sequence per premise (numbers shared across registers); set OznSlijed = N for separate sequences per register. Once chosen, the value is immutable for the calendar year — switching mid-year invalidates the chain and triggers a tax-audit flag. Default recommendation for our customers: N (per register) — it's the more forgiving option for multi-register stores.

Payment methods (NacinPlac)

CodeCroatianEnglish
GGotovinaCash
KKarticaCard
CČekCheque (rarely used)
TTransakcijski računBank transfer
OOstaloOther

Pdv — VAT rate breakdown

Each Porez element has:

Stopa         # rate, decimal-as-string, e.g. "25.00", "13.00", "5.00", "0.00"
Osnovica # taxable base for that rate
Iznos # VAT amount for that rate

Croatian VAT rates as of 2026:

RateUse
25%Standard rate.
13%Reduced — accommodation, restaurant prepared food (not all), books (paper), newspapers/magazines, some agriculture, baby food, water supply, electricity for residential.
5%Super-reduced — bread, milk, certain medicines, scientific journals, cinema tickets, some hospital services.
0%Exports + intra-community supplies (recorded with IznosOslobPdv instead).

An invoice may carry multiple Porez lines — one per rate present. Multi-rate goldens cover this in golden/multi-vat-rates.input.json.

Naknade — fees

Naknada
├── NazivN # fee name, e.g. "Naknada za ambalažu"
└── IznosN # fee amount

Used most commonly for packaging-deposit fees (e.g. €0.07 / €0.50 returnable PET bottle deposits). Adds to IznosUkupno; not subject to VAT.


5. Ordering and sequencing

Per-(premise, register) gap-free counter

The BrOznRac field is a strictly monotonic, gap-free counter that restarts at 1 on January 1 each year. Scoping depends on OznSlijed:

OznSlijedCounter scope
PPer (OIB, year, OznPosPr) — one sequence per business premise
NPer (OIB, year, OznPosPr, OznNapUr) — one sequence per cash register

Tax-audit perspective: any gap in the per-scope sequence (number 41 used, 43 used, no record of 42) is a finding. The merchant must be able to produce the cancellation/error log explaining the gap. Our adapter must therefore guarantee gap-free issuance for every (OIB, OznPosPr, OznNapUr) key.

This is the same engineering shape as the Portugal ATCUD adapter (per-NIF gap-free counter via Postgres advisory locks) — we should reuse the SequenceStore trait introduced for Portugal. See apps/docs-internal/docs/architecture/portugal-adapter.md § "Sequence store".

Composite key for Croatia:

key = format!("{oib}:{year}:{poslovni_prostor}:{naplatni_uredjaj}")
// e.g. "11111111119:2026:POSL1:1"

Restart at year boundary

On January 1, every (oib, premise, register) counter resets to 1. The adapter must reset state automatically — a stale counter from December silently double-numbers and CIS will reject the second occurrence with s003 ("Račun već postoji" — receipt already exists).

Idempotency

ScenarioAdapter behaviour
Network timeout after submitRetry same IdPoruke (the request UUID we minted at prepare-time). CIS deduplicates by (OIB, IdPoruke) for a 24h window.
Duplicate detection by CISIf we accidentally re-issue with the same BrOznRac triple but a different IdPoruke, CIS returns s003. Treat as a hard error — DO NOT retry; surface to ops to investigate the local sequence store.
Network success, JIR missingShould not happen on success path; if a 200 is returned with no JIR and no fault, treat as PROVISIONAL — query later via the public web verifier (production) or ProvjeraZahtjev (sandbox only).

IdPoruke strategy

IdPoruke is a UUIDv4 we mint at prepare time and persist with the PreparedRecord. Retries reuse it. We do not re-mint on retry — that forfeits CIS's idempotency. Same rule as Hungary's requestId.


6. Submission window

Real-time (the normal case)

Cash receipts must be fiscalized at the moment of issuance, before the paper/electronic copy is handed to the customer. The legal text says "bez odgode" ("without delay"). CIS-side practice: a few seconds is fine, 30 seconds is borderline, minutes are flagged in audits.

48-hour offline grace

If CIS is unreachable (network, FINA cert outage, CIS planned/unplanned downtime) the merchant may continue issuing receipts in offline mode and must transmit them within 48 hours with NakDost = true. After 48 hours the merchant is required to switch to paragon block (paper-receipt block with a unique paragon number per book, declared to PU annually) until CIS is restored, then transmit those as PD with ParagonBrRac set.

Adapter behaviour:

  • Active submit() succeeds in real-time → NakDost=false.
  • Active submit() fails (network or CIS error) → record locally with NakDost=true flagged in the prepared record, retry on a backoff schedule.
  • Wall-clock 48h elapses without successful transmission → the adapter surfaces a CroatiaError::OfflineGraceExpired to the merchant requiring manual intervention (paragon block escalation).

SubmissionWindow mapping

SubmissionWindow::Immediate for the hot path (matches Hungary, Spain Verifactu). The 48h grace is implemented inside the adapter retry layer, not exposed to the framework's submission-window enum.


7. Sandbox access

Procuring a FINA demo certificate

  1. Visit the FINA Mojcert demo portal at https://demo-mojcert.fina.hr/finacms/ ("Pristup za fiskalizaciju — demo okruženje").
  2. Register the test legal entity. Pick a sandbox OIB; FINA's demo CA accepts any 11-digit OIB starting with 1 (e.g. 11111111119). No KYC.
  3. Generate a CSR online (the portal handles key generation in-browser) or upload one. Receive the demo cert as .pfx immediately. Free.
  4. The cert chains to: FinaDEMO RDC 2020 CA → FinaDEMO Root CA. We bundle both in the adapter test fixtures (schemas/ca/).

CIS DEMO endpoint

POST https://cistest.apis-it.hr:8449/FiskalizacijaServiceTest
Content-Type: text/xml; charset=UTF-8
SOAPAction: ""
mTLS client cert: <demo .pfx>

The DEMO endpoint accepts identical schema to production, returns DEMO- formatted JIRs (UUID v4 in lower-case), and signs responses with the FinaDEMO PU certificate. A successful call against CIS DEMO is the implementation-bead's exit criterion.

What we'll need

  • .pfx for the sandbox tenant + its password — checked into our secrets vault, never the repo.
  • An adapter .env template:
    • HR_CIS_BASE_URL=https://cistest.apis-it.hr:8449
    • HR_FINA_CERT_ID=<certificate-id-in-our-cert-store>
    • HR_FINA_CERT_PASSWORD=<from-vault>
    • HR_OIB=11111111119
    • HR_OZN_POS_PR=POSL1
    • HR_OZN_NAP_UR=1
    • HR_OPER_OIB=11111111119
    • HR_OZN_SLIJED=N
    • HR_USTRAJ_PDV=true
  • The PU public root CA (for verifying response signatures): bundle in adapters/croatia/src/ca.rs from the published PU CA pack.

Timeline: sandbox onboarding is fully self-service, ~1 day. Production cert procurement is 1–3 weeks (FINA office identification or notarised remote ID). Production-cert procurement is the gating activity for any new merchant — call this out clearly in onboarding docs.


8. Error model

SOAP fault structure

<soapenv:Fault>
<faultcode>soapenv:Server</faultcode>
<faultstring>Greška…</faultstring>
<detail>
<f73:PorukaGreske>
<f73:SifraGreske>s002</f73:SifraGreske>
<f73:PorukaGreske>Neispravan digitalni potpis</f73:PorukaGreske>
</f73:PorukaGreske>
</detail>
</soapenv:Fault>

SifraGreske is the machine-readable error code; PorukaGreske (inside PorukaGreske) is the human-readable description. Note the unfortunately-overloaded element name — the outer wrapper and the inner text-bearing field are both called PorukaGreske. The inner one is the string we log.

Error catalogue (CIS official s00x codes)

CodeCroatian textMeaningRetriable?
s001Sustav je u kvaruCIS is broken (server-side outage).✅ retry with backoff
s002Neispravan digitalni potpisBad XML-DSig — bad cert, wrong canonicalization, key mismatch❌ config bug
s003Račun već postojiDuplicate (OIB, year, BrOznRac/OznPosPr/OznNapUr)❌ data bug
s004Pogrešan oblik porukeSchema validation failed (bad XSD shape)❌ data bug
s005Certifikat ne postoji ili je istekaoCert revoked / expired / unknown to PU❌ config bug
s006Pogrešan OIBOIB in <Oib> differs from cert subject's OIB❌ config bug
s007Neispravan ZKIZKI doesn't validate against the cert / payload❌ adapter bug
s008Neispravan format porukeGeneric format error (e.g. wrong namespace, wrong element ordering)❌ adapter bug
s009Neispravna oznaka slijednostiInvalid OznSlijed value or mismatch with prior submissions❌ data bug
s100Neispravna sigurnosna konfiguracijamTLS handshake failed / wrong client cert chain❌ config bug
HTTP 5xx / timeout / DNSTransport-level✅ retry same IdPoruke

Result-code taxonomy (mapped to our framework)

Adapter outcomeTriggers
Success(Jir, Zki)200 OK + <Jir> present + response signature verifies
Pending200 OK, no fault, no JIR (anomalous; query later) — extremely rare
Failed(s00x)Any structured fault with a SifraGreske
RetryableNetwork error or s001
Unauthorizeds002, s005, s006, s007, s100

Retry policy matrix

ErrorRetry same IdPoruke?New IdPoruke?Human ticket
Network timeout / 5xx / s001✅ exponential backoff, up to 48h graceonly if grace exceeded
s002, s005, s006, s007, s100✅ config / adapter
s003 (already exists)❌ — query the public verifier with the receipt's expected (OIB, BrRac) to confirm whether prior submission stuck. If yes, treat as success and reconcile our state.
s004, s008, s009only after fixing input✅ surface to merchant

Distinguishing s003 (duplicate) from s007 (bad ZKI)

s003 is the second-most-common error in the wild — almost always caused by sequence-counter desync after a database restore or replica switch. s007 is rare in production but common during initial implementation (ZKI input formatting, especially the comma-decimal IznosUkupno, is the usual culprit). The implementation bead's first-day gold-vector tests should pin the ZKI algorithm down.


9. Implementation plan

Crate layout

rust/adapters/croatia/
├── Cargo.toml # crate name: croatia-fiskalizacija
└── src/
├── lib.rs # CroatiaAdapter (impl CountryAdapter)
├── config.rs # CroatiaConfig (oib, fina_cert_id, premises, ...)
├── auth.rs # mTLS reqwest setup, cert loading
├── zki.rs # ZKI computation (RSA-SHA1 + MD5)
├── xmldsig.rs # XML-DSig enveloped signer (c14n + RSA-SHA1)
├── racun.rs # Transaction -> Racun XML builder (quick-xml)
├── envelope.rs # SOAP envelope wrap + sign + serialize
├── client.rs # POST to CIS, parse RacunOdgovor or fault
├── verify.rs # Verify response signature against PU CA roots
├── ca.rs # Embedded PU + FINA CA bundles (PEM)
├── sequence.rs # SequenceStore key formatting (per-premise/register)
├── types.rs # NacinPlac, OznSlijed, error code enums
├── errors.rs # CroatiaError -> AdapterError mapping
└── tests/
├── zki_vectors.rs # Known ZKI test vectors from PU spec appendix
├── xsd_conformance.rs # Validate goldens against FiskalizacijaSchema.xsd
└── e2e_demo.rs # Gated on HR_CIS_DEMO_CREDS env

Trait fit (CountryAdapter)

  • prepare() — build Racun XML, claim a gap-free BrOznRac from the sequence store (Postgres advisory lock keyed by oib:year:premise:register), compute ZKI locally using the cert's RSA private key, persist the PreparedRecord with the prepared (IdPoruke, BrRac, ZKI) triple. Synchronous, no network call, but the cert must be loadable — we hot-cache decrypted certs per tenant.
  • submit() — wrap Racun in a SOAP envelope, sign it with XML-DSig, POST to CIS with mTLS using the same cert, parse RacunOdgovor, verify the response signature against PU CA roots, return SubmitResult { fiscal_id: Jir, secondary_id: ZKI, qr_url: ... }.
  • generate_qr() — build the public-verification URL (see below).

DeploymentMode::Cloud is viable but adds key-custody surface; Embedded is the lower-risk option for merchants holding their own cert (we don't need to custody the FINA cert at all for embedded). SubmissionWindow::Immediate.

QR code URL

The customer-facing receipt prints a QR encoding the public verification URL:

https://porezna.gov.hr/rn?jir={JIR}&datv={DD.MM.YYYYTHH:MM:SS}&izn={total-with-comma}

If JIR isn't yet available (offline grace), encode ZKI instead:

https://porezna.gov.hr/rn?zki={ZKI}&datv={DD.MM.YYYYTHH:MM:SS}&izn={total-with-comma}

Config shape

pub struct CroatiaConfig {
/// Issuer 11-digit OIB. Must equal the cert's subject OIB.
pub oib: String,
/// Reference to the uploaded FINA application certificate.
pub fina_cert_id: String,
/// Business premise mark (1-20 chars). Per location.
pub poslovni_prostor: String,
/// Cash-register mark (1-20 chars). Per terminal.
pub naplatni_uredjaj: String,
/// Sequence selector — `P` per-premise, `N` per-register.
pub ozn_slijed: SequencingScope,
/// True if the issuer is in the VAT system (controls `Pdv` block).
pub u_sust_pdv: bool,
/// Operator (cashier) OIB. May be the same as `oib` for owner-operators.
pub operator_oib: String,
/// Default payment method when the input is unspecified.
pub default_nacin_plac: NacinPlac,
/// true => CIS DEMO (cistest.apis-it.hr:8449)
/// false => CIS production (cis.porezna-uprava.hr:8449)
pub sandbox: bool,
/// Optional: opt into Fiskalizacija 2.0 endpoints when ready.
pub use_v2: bool,
}

pub enum SequencingScope { PerPremise, PerRegister }

Dependencies (expected)

  • quick-xml — XML build + parse (workspace).
  • xmlsec (or hand-rolled in Rust) — XML-DSig enveloped signing. There is no production-ready Rust XML-DSig crate today (xml-dsig crate is abandoned). We'll either:
    1. Bind to libxmlsec1 via FFI (proven, native), or
    2. Hand-roll using xml-c14n + rsa + sha1 crates (smaller dep, but correctness risk on canonicalization edge cases). Open question — see §10. Recommend (1) for first cut; (2) when we have a comprehensive interop test corpus.
  • rsa + sha1 (RustCrypto) — ZKI signing primitive.
  • md-5 — ZKI MD5 step.
  • reqwest with rustls-tls-native-roots + client-cert features for mTLS.
  • x509-parser — extract OIB from cert subject, expiry checks.
  • pkcs12 — load .pfx to get private key + cert chain.
  • base64, chrono, uuid — already in workspace.

Open questions (for implementation bead)

  1. XML-DSig crate choice (libxmlsec1 FFI vs pure-Rust). Decision needs a ~2-day spike: build both, run the c14n test corpus from W3C, pick winner. FFI ships first, pure-Rust is the long-term goal (cross-compile clean for wasm32-unknown-unknown — we lose that with libxmlsec1).
  2. Time zone for DatVrijeme. Spec uses local Croatian time with no offset. For Cloud deployment from a UTC server, we must convert to Europe/Zagreb before formatting. DST transitions (Oct/Mar) need a regression test.
  3. Currency Croatia adopted EUR 2023-01-01. Schema doesn't carry a currency code — receipts are implicitly EUR. Multi-currency POS transactions (e.g. tourist paying in USD on POS) must be converted to EUR at the time of issuance using the central bank reference rate of the day. How does the upstream Transaction.currency interact? Recommend: reject non-EUR transactions for Croatia at prepare() with a clear error message; let upstream do the conversion. (Same approach Portugal takes.)
  4. Operator OIB on Cloud deployments. A cashier-level OIB doesn't make sense for headless e-commerce. Practice: use the merchant's OIB as OibOper for non-physical-store merchants. Confirm this is acceptable to PU during sandbox onboarding — it has been historically.
  5. Buyer OIB (KupacOib). For B2B receipts, KupacOib is permitted but not required. Auto-populate from Transaction.counterparty_tax_id when it's an HR-format OIB? Recommend: yes, with a per-location feature flag to disable (privacy-conscious B2C merchants).
  6. Fiskalizacija 2.0 timing. PU is rolling 2.0 from 2025-09-01 with full eRačun B2B mandate from 2026-01-01. Our v1.x adapter remains valid for cash receipts indefinitely — there is no v1 deprecation date announced — but we should plan a v2.0 follow-up bead before 2026-Q1.
  7. CRL caching. Per-request CRL fetch is too slow; we cache for 4h. Is that acceptable per PU policy? FINA publishes CRLs every 12h for FISKAL certs. Confirm 4h cache during sandbox.
  8. Paragon block escalation. When 48h grace expires, our adapter currently escalates to ops. Should we provide a self-service "I've issued paper receipts" flow for the merchant, capturing ParagonBrRac retroactively? Defer to a later iteration.

What could force us off Cloud-OK?

  • The FINA cert is a software credential — no HSM, no smart card required. PKCS#12 file. OK for cloud key custody (KMS-encrypted at rest, decrypted in adapter memory only).
  • mTLS with client cert — handled by reqwest + rustls. OK.
  • No physical fiscal printer or RT-device required — Croatia is software-only.
  • Risks remaining:
    • Holding customer FINA private keys server-side is a real liability surface. Per-merchant cert separation, KMS isolation, audit logs are must-haves. (Compare to Hungary's bare API key — Croatia is materially more sensitive.)
    • Customer demand for "we hold our own cert" → Embedded SDK is the right answer; the same croatia-fiskalizacija crate compiles into the Embedded engine, with the cert path provided locally.

Verdict: Cloud-OK with KMS-backed cert store. Embedded is the lower-risk default for security-sensitive merchants.

Testing strategy

  • Unit (in-adapter):
    • ZKI test vectors — at least 5 known (OIB, datetime, BrRac, premise, register, total)ZKI triplets, sourced from the FiskalizacijaSchema appendix and the FINA developer samples. Pin the comma-decimal formatting.
    • XML-DSig round-trip — sign, then verify using a separate code path; both must round-trip.
    • XSD validation against FiskalizacijaSchema.xsd for all golden inputs.
  • Integration (sandbox-gated):
    • Full prepare → sign → POST → parse round-trip against cistest.apis-it.hr. Gated on HR_CIS_DEMO_CREDENTIALS env. Not run in PR CI (slow + flaky); run nightly + on-demand.
  • Conformance:
    • All 6 golden triplets in testdata/conformance/croatia/fiskalizacija/golden XSD-validate and ZKI-recompute deterministically.

Rollout sequence (for the implementation bead)

  1. Day 1–2zki.rs + test vectors. Pin algorithm.
  2. Day 3racun.rs builder + XSD validation against the 6 goldens.
  3. Day 4xmldsig.rs (or libxmlsec1 binding) + envelope. Round-trip.
  4. Day 5client.rs + mTLS, smoke test against EchoRequest to CIS DEMO.
  5. Day 6 — End-to-end CREATE against CIS DEMO. Confirm JIR returned.
  6. Day 7 — Error path coverage, response-signature verification.
  7. Day 8NakDost=true retry path; subsequent-delivery flow.
  8. Day 9 — Sequence store + advisory locks under concurrency.
  9. Day 10 — Soak / chaos testing, observe rate limits.

10. Citations

Per-fixture citations live in each meta.json under testdata/conformance/croatia/fiskalizacija/golden/.