concept
HUB-3A — the Croatian 2D payment barcode
HUB-3A — the Croatian 2D payment barcode
The PDF417 symbol printed on Croatian invoices and payment slips. A payer scans it in their banking app and the transfer is prefilled: amount, IBAN, reference, description. It is the difference between an invoice that gets paid and one that needs an IBAN retyped from a PDF.
Standardised by HUB (Hrvatska udruga banaka). The format marker is
HRVHUB30, which is why it is often called “HUB3” or “HUB-3A” interchangeably.
First implemented for invoicing|the sikod invoicing app.
Get the spec from the source
https://hub.hr/sites/default/files/inline-files/2DBK_EUR_Uputa_0.pdf
— “HUB 3A obrazac - specifikacija PDF417 barkoda”, version 6.
A fetch tool will not read it; pdftotext -layout will. It contains a
complete worked example, which makes the strongest possible test: assert your
generated payload reproduces the spec’s own example byte for byte.
The payload
14 fields, separated by a bare LF (0x0A), including after the last one.
| # | Field | Max |
|---|---|---|
| 1 | Zaglavlje — always HRVHUB30 | 8 |
| 2 | Valuta — EUR | 3 |
| 3 | Iznos — cents, right-aligned, zero-padded | 15 |
| 4 | Platitelj (naziv) | 30 |
| 5 | Adresa platitelja (ulica i broj) | 27 |
| 6 | Adresa platitelja (poštanski broj i mjesto) | 27 |
| 7 | Primatelj (naziv) | 25 |
| 8 | Adresa primatelja (ulica i broj) | 25 |
| 9 | Adresa primatelja (poštanski broj i mjesto) | 27 |
| 10 | IBAN primatelja | 21 |
| 11 | Model (e.g. HR00) | 4 |
| 12 | Poziv na broj | 22 |
| 13 | Šifra namjene — ISO 20022, OTHR is a safe default | 4 |
| 14 | Opis plaćanja | 35 |
Three things most writeups get wrong
- The encoding is UTF-8. Plenty of third-party articles and code samples claim ISO-8859-2 or windows-1250. The spec says UTF-8 explicitly.
- Lengths count characters, not bytes.
č ć đ š žare two bytes each in UTF-8 but one character. Truncating on bytes halves a name of diacritics and can split a character mid-sequence. - Only the amount is padded. Every other field is written as-is and simply truncated if too long.
The one that silently sends money to the wrong place
“Platitelj” is the PAYER — your client. “Primatelj” is you. Swapping them produces a perfectly scannable barcode that pays the client. Nothing catches this but reading the decoded payload.
Allowed characters
Digits, the 27 letters of the Croatian alphabet, Q W X Y, space, and
, . : - + ? ' / ( ). Nothing else — an & in a company name has to go.
Convert whitespace to a space before stripping illegal characters, or a newline is deleted outright and glues the words either side together. A literal newline has to go regardless: fields are LF-delimited, so one left in place shifts every field after it.
Symbol settings, fixed by the spec
Error correction level 4, 9 data columns, binary encoding, neither compact nor macro, module width 0.254 mm (10 mil), row height-to-width 3:1, symbol ≤58 mm wide and ≤26 mm tall.
Generating it
bwip-js — pure JavaScript, no native dependencies, synchronous
toSVG(). The alternative pdf417-generator draws to a canvas and wants
node-canvas, which is a native build.
bwip-js’s TypeScript types only model options common to every symbology, so
columns / eclevel / rowmult / binarydata need a cast. Prove they
actually reach BWIPP rather than trusting the cast: assert the symbol width
changes with the column count. A cast hides a typo, and a wrong column count
still yields a valid-looking barcode that a bank’s scanner may reject.
Verify by decoding, never by looking
A plausible-looking PDF417 proves nothing. Rasterise the rendered page at around 400 DPI and read it back:
pip install zxing-cpp pillow
python -c "import zxingcpp; from PIL import Image; print(zxingcpp.read_barcodes(Image.open('page.png'))[0].text)"
Then check the decoded amount against the invoice total, and that the payer and recipient are the right way round.
Related: fiskalizacija-2-0, invoicing|sikod invoicing app.