Skip to content
webkoding

Why ZATCA Phase 2 rejects an invoice, and how to stop it

A rejected payload is not a bug report. It is a sale that cannot legally close, and the merchant is standing at the till while you read the log.

August 29, 202611 min read

Under ZATCA Phase 2 — the integration phase — a standard tax invoice has to reach the authority and come back cleared before it is valid. That is the part teams underestimate. In Phase 1 an invalid invoice was a compliance problem you fixed later. In Phase 2 it is a transaction that does not complete.

We build and maintain this in commercial WooCommerce products, which means we do not hand over at go-live and move on; we live with every specification revision the authority publishes. The failures below are the ones that actually recur.

ZATCA revises its specifications and validation rules regularly. Treat this as the shape of the problem rather than a substitute for the current published spec — always check the version in force for your onboarding wave.

Failure zero: sending the invoice down the wrong road

Phase 2 has two flows and they are not interchangeable. Standard invoices — business to business and business to government — go through clearance: submitted in real time, returned by ZATCA with a cryptographic stamp and QR before they can be given to the buyer. Simplified invoices — business to consumer — are reported, and may be sent after the fact within the reporting window.

Route a simplified invoice into clearance, or a standard one into reporting, and everything downstream fails in ways that look like data problems. The invoice type and its subtype decide the road, and that decision belongs at the top of the flow, not inside an exception handler.

1. The hash chain is broken

Every invoice carries the hash of the one before it. That chain is what makes the sequence tamper-evident, and it is the single most common thing to get wrong, because it is the only field whose correctness depends on something outside the current invoice.

  • A previous invoice failed to submit but was still written locally, so the chain advanced on your side and not on theirs.
  • Two orders completed at the same moment and both read the same previous hash. One of them is now wrong, and the error surfaces on the next invoice, not on either of those two.
  • A test invoice was issued against production credentials and quietly entered the chain.
  • The chain was reset by a deployment, a database restore, or someone clearing a table.

The fix is not in the payload. The previous hash and the counter have to be produced by a single serialised step — a row lock, a queue with one worker per device, something that makes concurrent issuance impossible rather than unlikely. If two checkouts can ever read the same previous hash, the chain will break; it is a matter of traffic, not of luck.

2. The invoice counter skipped

The counter increments by one per invoice per device. Not per order, not per customer — per issuing device. Gaps and repeats are both rejected.

The usual cause is treating the counter as a derived value: counting rows, using the order number, or restarting it after a failure. It has to be stored, incremented under the same lock that produces the hash, and never rolled back when a submission fails. A failed submission does not un-issue an invoice.

3. The hash does not match the bytes you signed

This one produces the most confusing tickets, because the invoice looks correct in every viewer. The hash is taken over the canonicalised XML, and canonicalisation is unforgiving: attribute order, namespace declarations, whitespace between elements, encoding, line endings. Serialise the document once, hash and sign those exact bytes, and transmit those exact bytes.

Teams break this by re-serialising between hashing and sending — logging the XML through a pretty-printer, passing it through a framework that normalises namespaces, or rebuilding the document from a parsed object. The document that leaves your system must be byte-identical to the one you hashed.

4. Signature and certificate problems

Onboarding produces a certificate tied to a specific taxpayer and device. Rejections here are less about cryptography and more about bookkeeping.

  • Compliance credentials from onboarding are still in use where production credentials belong.
  • The certificate has expired, or was renewed on one node of a multi-server deployment and not the others.
  • The seller VAT registration in the document does not match the one the certificate was issued against.
  • The signing timestamp is outside the accepted window because a server clock drifted.

Clock drift deserves particular suspicion. It fails intermittently, it fails on one node out of several, and it looks exactly like a random authority error until someone compares system time across the fleet.

5. The QR code is structurally wrong

The QR payload is TLV — tag, length, value — encoded to base64, not a URL and not free text. Simplified invoices carry more tags than standard ones, including the stamp and the public key. Order matters, and lengths are byte lengths.

Arabic seller names are where this quietly breaks: the length has to count UTF-8 bytes, not characters. Code that measures string length in characters will produce a QR that decodes to nonsense for every Arabic-named seller and passes every test written in English.

6. Totals that do not reconcile

Line amounts, tax subtotals and document totals have to agree to two decimals under the rounding rules, and rounding has to happen at the right level rather than wherever the framework put it. Mixed-rate baskets, line discounts, shipping treated as a line versus as a charge, and prices stored inclusive of tax are the four situations that generate most mismatches.

The tell is a rejection that moves: it appears on some orders and not others, and the ones it appears on share a characteristic nobody thought was relevant.

What actually prevents this

None of the above is fixed by better field mapping. They are prevented by four architectural decisions taken before the first invoice is issued.

  • Serialise issuance. One writer produces the counter and the previous hash together, under a lock. Concurrency here is not an optimisation problem; it is a correctness problem.
  • Validate before you submit. Schema and business rules run locally first. An invoice that cannot pass should never consume a submission attempt or a counter value.
  • Make retries idempotent. Network timeouts are not rejections. Resubmitting the same UUID must be safe, and a timeout must never trigger re-issuance with a new counter.
  • Translate errors at the boundary. A merchant should see what to do, not a validation code. Every rejection needs a readable reason, the invoice it belongs to, and a retry path that does not require a developer.

The last one is the difference between a compliance integration that works and one that generates a support queue. The authority tells you which rule failed. Only your system knows which order that was, which merchant is standing at the till, and what they should do next.

One more thing: the specification will change

ZATCA publishes revisions, and so do the UAE and the EU. If a compliance integration is treated as a project that ends at go-live, the first revision turns into an emergency. Treated as a maintained surface, it is a scheduled piece of work. That is the whole difference, and it is decided by how the engagement is structured rather than by how the code is written.

We run ZATCA Phase 2, UAE EIS and the EU Digital Product Passport in products that are live in merchant environments we cannot inspect. If you are integrating one of these, or your current integration produces rejections nobody can explain, send us the system.

← All insights

Building something like this?

Tell us what you are building and who it has to talk to.