Why ZATCA rejects your QR code
The spec sentence everyone copies is “The length shall be stored in one byte.” It stops being true at 128 bytes — and Arabic letters are two bytes each in UTF-8, so a trade name of roughly 64 Arabic letters crosses 128 bytes. That is an ordinary Saudi company name, not an edge case.
of the 44,915 monthly npm downloads measured here go to packages that fail at least one rule. Of 11 third-party packages, 2 pass every case.
The rule
The TLV length is a BER length, not a plain byte:
length < 128 one byte
128 ≤ length ≤ 255 0x81, then the length byte
256 ≤ length 0x82, then two bytes, big-endian
This is not our reading. It is settled on ZATCA’s own forum, by a developer whose QR code was rejected and who traced it to exactly this:
our code assumed that the maximum length of the value is 1 byte and therefore when the value was bigger than 127, we were not properly convert it to TLV value
Check your own QR
Paste the Base64 your encoder produces. Nothing is uploaded — this runs the same decoder that scored the table below, in your browser.
Results
Measured 2026-09-02. Downloads are npm’s last-month figure, fetched 2026-09-01.
| package | downloads/mo | score |
|---|---|---|
@talha7k/zatca-qr |
1,991 | 10/10 |
@talha7k/zatca |
1,729 | 10/10 |
| fatura-zatca (ours) ours | — | 10/10 |
| arabic-invoice-mcp (ours, shipped build) ours | — | 10/10 |
zatca-xml-js |
21,943 | 6/10 |
@axenda/zatca |
17,376 | 6/10 |
@zatca/qr |
503 | 6/10 |
zatca-sdk |
300 | 6/10 |
zatca-qr-tlv |
123 | 6/10 |
zatca-qr-generator |
104 | 6/10 |
zatca-simplified-invoice-sdk |
10 | 6/10 |
@pioneersoft/zatca-einvoice |
646 | 5/10 |
zatca |
190 | 2/10 |
What fails, and where
zatca-xml-js 21,943 downloads/mo
- Q2
arabic-64-chars - Q2
arabic-100-chars - Q3
arabic-128-chars
@axenda/zatca 17,376 downloads/mo
- Q2
arabic-64-chars - Q2
arabic-100-chars - Q3
arabic-128-chars
zatca-qr-tlv 123 downloads/mo
- Q2
arabic-64-chars - Q2
arabic-100-chars - Q3
arabic-128-chars
zatca-sdk 300 downloads/mo
- Q2
arabic-64-chars - Q2
arabic-100-chars - Q3
arabic-128-chars
zatca-qr-generator 104 downloads/mo
- Q2
arabic-64-chars - Q2
arabic-100-chars - Q3
arabic-128-chars
zatca-simplified-invoice-sdk 10 downloads/mo
- Q2
arabic-64-chars - Q2
arabic-100-chars - Q3
arabic-128-chars
@pioneersoft/zatca-einvoice 646 downloads/mo
- Q2
arabic-64-chars - Q2
arabic-100-chars - Q3
arabic-128-chars
@zatca/qr 503 downloads/mo
- Q2
arabic-64-chars - Q2
arabic-100-chars - Q3
arabic-128-chars
zatca 190 downloads/mo
- Q1
arabic-byte-length - Q1
arabic-63-chars - Q2
arabic-64-chars
The fix
const berLength = (n) =>
n < 0x80 ? [n] :
n <= 0xFF ? [0x81, n] :
[0x82, n >> 8, n & 0xFF];
const tlv = (tag, value) => {
const bytes = Buffer.from(value, "utf-8");
return Buffer.concat([Buffer.from([tag, ...berLength(bytes.length)]), bytes]);
};
A decoder must read the same forms. A single-byte reader sees 0x81 as a
length of 129 and silently truncates the value — the same bug from the other side.
Or install one that already does it
The four lines above are enough if you only need the QR. If you also need Arabic text that survives a PDF — amounts that do not reverse, names that do not truncate — these two libraries do both. Not on npm yet; installable today from a release:
npm install https://github.com/opusstudiohq-max/arabic-invoice-mcp/releases/download/libs-v0.2.0/fatura-zatca-0.2.0.tgz
import { encodeZatcaQr, computeTotals, formatMinor } from "fatura-zatca";
const qr = encodeZatcaQr({
sellerName: "مؤسسة عبد الرحمن العتيبي للتجارة والمقاولات",
vatNumber: "310122393500003",
timestamp: "2026-09-01T14:30:00Z",
totalWithVat: "1150.00",
vatAmount: "150.00",
});
// a 140-byte name emits 0x81 0x8C — the form ZATCA's validator accepts
fatura-zatca pulls nasq with it. Verified end to end from that
URL in a clean project: correct length form, name intact after decoding, totals in
halalas.
We found it in our own code first
We went to measure other packages, noticed one writing BER lengths, went to check which reading was right — and found our own code was wrong, in three places including our published checker tool. All were fixed before we measured anyone, and the runner refuses to write results at all if one of our engines fails.
It is not a JavaScript problem
The scores above cover npm, because that is what this harness can execute. But the same defect is in the most-starred ZATCA repositories on GitHub, in three other languages — including libraries published by major Saudi platforms:
| repository | language | the length line |
|---|---|---|
SallaApp/ZATCA | PHP | pack("H*", sprintf("%02X", $len)) |
Saleh7/php-zatca-xml | PHP | pack("H*", sprintf("%02X", $len)) |
mrsool/zatca | Ruby | @value.bytesize.chr |
Haraj-backend/zatca-sdk-go | Go | buf.WriteByte(byte(len(val))) |
All four measure the value in bytes — they clear the trap that catches most implementations — and then write that count in a single byte. The Go library states the premise outright:
// since the length could only be 1 byte, that means the maximum length for
// every field values is 255.
const maxValueLength = 255
So the error is not a slip in one ecosystem. It is a common reading of a spec sentence that says "one byte" — which is why it reaches 91.7% of measured npm downloads and the top of GitHub alike.
These four were read, not executed — there is no PHP, Ruby or Go runtime on the machine that produced this page. The quotes are literal from each repository's default branch. The npm scores above are executed.
Reported to the maintainers
A benchmark that finds a defect and does not tell its author is just gossip. Every finding was filed with a runnable reproduction and the fix:
| package | ecosystem | issue |
|---|---|---|
zatca-xml-js |
npm | wes4m/zatca-xml-js/issues/61 |
@axenda/zatca |
npm | axenda/zatca/issues/19 |
zatca |
npm | abdo-host/ZATCA-JS/issues/1 |
zatca-qr-generator |
npm | Wasim-Zaman/zatca-qr-generator/issues/1 |
zatca-sdk |
npm | aashahin/zatca-sdk/issues/1 |
zatca-simplified-invoice-sdk |
npm | sharahsa0-creator/zatca-simplified-invoice-sdk/issues/6 |
SallaApp/ZATCA |
PHP | SallaApp/ZATCA/issues/74 |
Saleh7/php-zatca-xml |
PHP | Saleh7/php-zatca-xml/issues/38 |
mrsool/zatca |
Ruby | mrsool/zatca/issues/41 |
Haraj-backend/zatca-sdk-go |
Go | Haraj-backend/zatca-sdk-go/issues/7 |
Rules measured
| id | rule |
|---|---|
| Q1 | Length counts UTF-8 bytes, not characters
The declared length is the byte count of the UTF-8 encoding, not the character count. "شركة" is four characters and eight bytes; counting characters produces a code that fails at the reader with no diagnostic. |
| Q2 | BER length form at 128 bytes
The spec sentence "The length shall be stored in one byte" breaks at 128: one byte carries up to 127, and beyond that 0x81 must precede the length byte. Settled on ZATCA's own forum. A 64-character Arabic name reaches 128 bytes — Arabic is two bytes per character. |
| Q3 | BER length form above 255 bytes
Beyond 255 bytes the length needs 0x82 followed by two big-endian bytes. A 128-character Arabic name is 256 bytes. |
| Q4 | Tag order 1, 2, 3, 4, 5
Table 3 orders the tags: seller name, VAT number, timestamp, total with VAT, VAT amount. Order is part of the structure, not decoration. |
| Q5 | The value survives the round trip
The name that went in must come out of the decoder unchanged — no truncation, no mangling. This catches the length defect from the value side. |
| Q6 | The amount as printed on the invoice
Tags 4 and 5 carry the amount as text exactly as printed. Reformatting it ("1000.00" to "1000") makes the code disagree with the face of the invoice. |
| Q7 | The timestamp as given
Tag 3 is an ISO 8601 timestamp with a timezone. Silently rewriting it changes a value that takes part in matching. |
Rerun it yourself
git clone https://github.com/opusstudiohq-max/arabic-invoice-mcp
cd arabic-invoice-mcp/zatca-qr-benchmark
npm install
node run.mjs --fetch && node build.mjs
Full method, the cases, and the nine adapters we got wrong on the first run: the English write-up.