Guide
Getting started
npm install @devix-labs/e-invoice-qr
import { encodeInvoice } from '@devix-labs/e-invoice-qr';
const payload = encodeInvoice({
seller: 'متجر ديفكس',
vatNumber: '300000000000003',
timestamp: new Date(),
total: 115.00,
vatTotal: 15.00,
});
// 'ARPZhdiq2KzYsSDYr9mK2YHZg9izAg8zMDAwMDAwMDAwMDAwMDMD…'
That base64 string is the QR code's content. Render it with any QR library — ours is two lines:
import { createQrCode } from '@devix-labs/qr-code';
createQrCode(document.querySelector('#qr'), { value: payload, level: 'M' });
The two packages are independent; neither depends on the other.
Reading one back
This is the half no other package in this space does, and it is the half you need when something has gone wrong:
import { decodeInvoice, check, describe } from '@devix-labs/e-invoice-qr';
decodeInvoice(payload);
// { seller: 'متجر ديفكس', vatNumber: '3000…', timestamp: '2025-03-01T10:00:00Z',
// total: '115.00', vatTotal: '15.00', phase: 1, unknown: [] }
const result = check(payload, { rate: 0.15 });
result.ok; // false
result.problems.map(describe); // ['vatTotal does not match the VAT rate (5.00, expected about 15.00)']
Scan a customer's invoice, paste the payload in, and find out what a tax authority's scanner would say about it.
The five tags
ZATCA phase one is five values, and all of them are required:
| Tag | ||
|---|---|---|
seller |
1 | The registered name, as printed on the invoice. |
vatNumber |
2 | Fifteen digits, beginning and ending with 3. |
timestamp |
3 | ISO 8601. A Date is formatted for you. |
total |
4 | Including VAT. |
vatTotal |
5 | The VAT alone. |
Phase two adds xmlHash, signature, publicKey and — for simplified
invoices — certificateSignature. Pass them and they are appended as tags 6 to
9; leave them out and you get a phase one payload.
Three things that quietly break payloads
Length is bytes, not characters. 'سلة'.length is 3; its UTF-8 length is 6.
A length byte of 3 produces a payload no scanner can read, and it is the
commonest bug in this space. Nothing here ever uses .length on a value.
Amounts must not be localised. The amount string is what gets hashed and
stamped, so it has to come out the same every time. toLocaleString on a Saudi
device gives you Arabic-Indic digits and a thousands separator. formatAmount
gives you 115.00, always, and throws on anything it cannot read rather than
guessing.
A signed timestamp must not be reformatted. If the ISO string was part of
what the invoice hash covers, rewriting it invalidates the stamp. A string
already in ISO 8601 is passed through byte for byte; only a Date is formatted.