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.

اقرأ النسخة العربية ←

91.7%

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

https://zatca1.discourse.group/t/qr-code-rejected-when-tag-1-company-name-exceeds-127-characters/7202

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.

packagedownloads/moscore
@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

@axenda/zatca 17,376 downloads/mo

zatca-qr-tlv 123 downloads/mo

zatca-sdk 300 downloads/mo

zatca-qr-generator 104 downloads/mo

zatca-simplified-invoice-sdk 10 downloads/mo

@pioneersoft/zatca-einvoice 646 downloads/mo

@zatca/qr 503 downloads/mo

zatca 190 downloads/mo

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:

repositorylanguagethe length line
SallaApp/ZATCAPHP pack("H*", sprintf("%02X", $len))
Saleh7/php-zatca-xmlPHP pack("H*", sprintf("%02X", $len))
mrsool/zatcaRuby @value.bytesize.chr
Haraj-backend/zatca-sdk-goGo 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:

packageecosystemissue
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

idrule
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.

We count visits without cookies or identity — a count and a path, nothing more. بلا كوكيز. Your invoice never leaves your browser, and that has not changed.