Skip to content
Devix Open Source

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.

Where to go next

Updated 15 Sep 2026