concept

HUB-3A — the Croatian 2D payment barcode

created 2026-10-02 croatia · payments · invoicing · pdf417 · barcode · hub3 · fintech

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.

#FieldMax
1Zaglavlje — always HRVHUB308
2Valuta — EUR3
3Iznos — cents, right-aligned, zero-padded15
4Platitelj (naziv)30
5Adresa platitelja (ulica i broj)27
6Adresa platitelja (poštanski broj i mjesto)27
7Primatelj (naziv)25
8Adresa primatelja (ulica i broj)25
9Adresa primatelja (poštanski broj i mjesto)27
10IBAN primatelja21
11Model (e.g. HR00)4
12Poziv na broj22
13Šifra namjene — ISO 20022, OTHR is a safe default4
14Opis plaćanja35

Three things most writeups get wrong

  1. 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.
  2. 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.
  3. 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.