If you issue invoices in Saudi Arabia, you will meet the ZATCA (Zakat, Tax and Customs Authority) e-invoicing rules. One visible part of them is the QR code printed on the invoice. It looks like a normal QR code, but what is inside it is very specific, and a small mistake in the encoding makes it fail validation.
What the QR code is for
The QR code lets a customer, an inspector or a scanning app read the key facts of an invoice instantly and check that they match the printed document. It was required on simplified tax invoices, the invoices a business issues to consumers, from the first phase of the e-invoicing rollout (the “generation” phase), and the later “integration” phase adds further requirements.
The five fields in the basic QR code
- 1Seller name, exactly as registered.
- 2VAT registration number of the seller.
- 3Invoice date and time, in ISO 8601 format, for example
2026-10-15T09:30:00Z. - 4Invoice total including VAT.
- 5VAT amount on the invoice.
The later phase adds more data, such as a hash of the invoice and a cryptographic stamp, so check the current technical specification if your business has reached that phase.
What TLV means
TLV stands for Tag, Length, Value. Each field is stored as three parts, one after the other:
- Tag: one byte that says which field this is (1 for seller name, 2 for VAT number, and so on up to 5).
- Length: one byte that says how many bytes the value takes.
- Value: the field itself, written as UTF-8 bytes.
You join the five TLV blocks into one sequence of bytes. Then you turn that whole sequence into Base64 text. That Base64 string is what you put into the QR code.
A working example
function tlv(tag: number, value: string) {
const bytes = new TextEncoder().encode(value); // UTF-8, so Arabic names work
return Uint8Array.from([tag, bytes.length, ...bytes]);
}
export function zatcaBase64(f: { seller: string; vat: string; time: string; total: string; vatAmount: string }) {
const parts = [tlv(1, f.seller), tlv(2, f.vat), tlv(3, f.time), tlv(4, f.total), tlv(5, f.vatAmount)];
const all = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let offset = 0;
for (const p of parts) { all.set(p, offset); offset += p.length; }
return btoa(String.fromCharCode(...all)); // put this string in the QR code
}Notice that the length is measured in bytes, not characters. An Arabic seller name uses more bytes than letters, which is the most common cause of a wrong length byte.
Common mistakes
- Counting characters instead of bytes for the length.
- Leaving the VAT amount out, or using the total before VAT.
- A timestamp in the wrong format.
- Putting the TLV text in the QR code instead of the Base64 version of it.
- A QR code printed too small to scan reliably.
Skip the manual work
You can build this into your own invoicing system, or use our ZATCA QR code generator to create and download a correctly encoded code from your invoice details. If you need e-invoicing built into your software, talk to our team.