Skip to content
Farhad Nazir
  • ZATCA
  • Saudi Arabia
  • Flutter
  • POS

Implementing ZATCA Phase 2 e-invoicing in a Flutter POS

How I built ZATCA Phase 2 (Fatoora) e-invoicing in Dart for a Flutter POS: UBL 2.1 XML, hash chaining, ECDSA signing, CSID onboarding and Saudi VAT receipts.

Farhad Nazir

8 min read

Saudi Arabia’s e-invoicing rules changed what point-of-sale software has to do. Under Phase 2 of ZATCA’s e-invoicing program (the Fatoora platform), the software that issues invoices has to produce structured XML, sign it with a key registered to that device, link each invoice to the one before it, and send it to ZATCA.

When I extended Asaan Optics, the optical retail software I build, to shops in Saudi Arabia, I implemented this support in Dart, as a package used by the Flutter Windows app (the same package is also in the mobile codebase). Below is what Phase 2 involves and how I approached each part.

A note on scope: this describes support I implemented and tested against ZATCA’s sandbox. It is not a claim of ZATCA certification or of production onboarding, and every business should confirm its own obligations and onboarding date with ZATCA.

What Phase 2 asks of a POS

Phase 1 (generation), in force since December 2021, required invoices to be issued electronically, with a QR code on simplified invoices carrying five fields: seller name, VAT number, timestamp, invoice total and VAT amount. Phase 2 (integration) started in January 2023 and is applied to taxpayers in waves, each notified by ZATCA in advance.

Phase 2 divides documents into two kinds, and they follow different paths:

  • Standard tax invoices (B2B) are sent to ZATCA for clearance before the buyer receives them. ZATCA validates the invoice, adds its own stamp and QR, and returns the cleared document.
  • Simplified tax invoices (B2C, the ordinary counter sale) are signed by the seller’s device, handed to the customer straight away, and reported to ZATCA within 24 hours.
  • Credit notes and debit notes follow the path of the invoice they adjust.

UBL 2.1 XML, the hash chain and the signature

For a shop counter, the simplified path is the one that matters most. Signing happens on the cashier’s PC or phone at the moment of sale, and it has to work even when the internet does not. Every document starts as a UBL 2.1 XML invoice with Saudi-specific fields: a UUID, the type code (388 for an invoice, 381 for a credit note, 383 for a debit note), seller details including the national address, line items, VAT categories and totals.

Two fields turn separate invoices into a chain. The invoice counter value (ICV) goes up by one for every document a device issues. The previous invoice hash (PIH) is the SHA-256 hash of that device’s last document, and the very first document uses a fixed starting value defined by ZATCA. If an invoice in the middle is altered or removed, the chain no longer matches.

The hash is where most of my early time went. ZATCA hashes the invoice after XML canonicalization (C14N 1.1), with the signature block and QR excluded. One extra space or a self-closing tag changes the hash, and the document is rejected. So I made the builder write the invoice directly in canonical form: no XML declaration in the hashed text, explicit closing tags, fixed attribute order, only the required characters escaped, and LF line endings. The aim is that the text I hash is byte for byte the canonical form ZATCA’s validator hashes.

Money needed the same care. Every amount is kept in integer halalas, VAT is rounded half-up per category, and the line amounts are worked out backwards from the price the cashier entered, so the XML totals equal exactly what the customer paid. When one bill mixes standard-rated and zero-rated lines, a bill discount is split across them with a largest-remainder method, so no halala appears or disappears.

Phase 2 uses ECDSA on the secp256k1 curve with SHA-256. Each device generates its own key pair, the public key goes into a certificate issued by ZATCA, and the private key signs the invoice hash. The signature sits in an XAdES block inside the UBL extensions, together with the certificate and a hash of the signed properties.

Many integrations hand this work to ZATCA’s Java SDK or to a server. I wanted the POS to sign locally and offline on Windows and on phones, so I used pointycastle, a pure-Dart cryptography library, and wrote small DER encoders for the signature, keys and certificate signing request. A simplified version of the signing step:

import 'dart:typed_data';
import 'package:pointycastle/export.dart';

final secp256k1 = ECDomainParameters('secp256k1');

/// Signs an invoice hash with the device's private key.
/// Passing an HMac gives deterministic nonces (RFC 6979).
ECSignature signInvoiceHash(BigInt d, Uint8List invoiceHash) {
  final key = ECPrivateKey(d, secp256k1);
  final nonceMac = HMac(SHA256Digest(), 64);
  final signer = ECDSASigner(SHA256Digest(), nonceMac)
    ..init(true, PrivateKeyParameter(key));
  return signer.generateSignature(invoiceHash) as ECSignature;
}

// The (r, s) pair is then DER-encoded as SEQUENCE { INTEGER r, INTEGER s }
// and base64-encoded for the XML signature value and QR tag 7.

Onboarding a device: CSR, compliance CSID, production CSID

In ZATCA’s terms, each invoicing device is an EGS unit (e-invoice generation solution), and it has to be onboarded before it can issue Phase 2 invoices. In Asaan Optics this is a short wizard in shop settings: the owner enters the seller details and an OTP, and the app runs every step, showing progress and listing ZATCA’s error messages if a check fails. The steps behind it:

  • The business owner signs in to the Fatoora portal and generates a one-time password (OTP).
  • The app creates a new key pair and a certificate signing request (CSR) with ZATCA’s required fields: VAT number, organization and branch names, a serial number identifying the solution, model and device, the invoice types the device will issue, location and industry.
  • ZATCA receives the CSR and OTP and returns a compliance CSID (cryptographic stamp identifier), a certificate for testing.
  • The device signs and submits sample invoices, credit notes and debit notes for each kind it will issue, as compliance checks. These samples use a throwaway chain, so the real chain starts cleanly at counter 1.
  • Once those pass, ZATCA issues the production CSID used for real invoices.
  • Certificates expire, so I also built renewal: a new key and CSR with a renewal OTP from the portal, while the device keeps its identity and continues its existing chain.

The QR code and the Saudi VAT receipt

The Phase 2 QR is a base64 string of TLV (tag, length, value) fields. Tags 1 to 5 are the Phase 1 fields. Phase 2 adds the invoice hash (6), the ECDSA signature (7), the public key (8) and, for simplified invoices, the signature of ZATCA’s certificate authority on the device certificate (9). Each length is one byte, so no value may exceed 255 bytes, which is worth checking for long Arabic seller names.

import 'dart:convert';
import 'dart:typed_data';

String encodeQr(List<(int, List<int>)> fields) {
  final bytes = BytesBuilder();
  for (final (tag, value) in fields) {
    if (value.length > 255) {
      throw ArgumentError('QR tag $tag exceeds 255 bytes');
    }
    bytes
      ..addByte(tag)
      ..addByte(value.length)
      ..add(value);
  }
  return base64Encode(bytes.toBytes());
}

// A simplified invoice (illustrative values):
// encodeQr([
//   (1, utf8.encode(sellerName)), (2, utf8.encode(vatNumber)),
//   (3, utf8.encode('2026-11-11T14:05:00')),
//   (4, utf8.encode('115.00')), (5, utf8.encode('15.00')),
//   (6, utf8.encode(invoiceHash)), (7, utf8.encode(signature)),
//   (8, publicKeyDer), (9, certificateSignature),
// ]);

Arabic text and the printed receipt

For standard invoices the app does not build a QR of its own. It prints the one embedded in the cleared invoice ZATCA returns. On the printed receipt, I made a few deliberate choices:

  • The title follows the document: “فاتورة ضريبية مبسطة | Simplified Tax Invoice” for simplified documents and “فاتورة ضريبية | Tax Invoice” for cleared ones.
  • A Phase 2 shop only ever prints the QR stored with the signed document. If it is missing or invalid, the operator sees a warning and no substitute is generated, because a QR that does not match the signed XML is worse than none.
  • Shops not yet on Phase 2 keep the Phase 1 QR, and receipts issued before a shop switched over can still be reprinted with their original QR.
  • Arabic text is reshaped with arabic_reshaper and rendered in Noto Naskh Arabic, with mixed Arabic and Latin runs (product names, prescription values, prices) reordered so they read correctly right to left. I had worked through the same problem on Tabarak POS, a restaurant POS that prints Arabic and Urdu text on its receipts.

Keeping the key on the device and working offline

The private key never leaves the device. On Windows it is kept in secure storage backed by the Windows Credential Manager; in the mobile codebase, flutter_secure_storage uses the iOS Keychain and the Android Keystore. The backend stores the shop’s ZATCA profile, the list of registered devices and an archive copy of every signed document, but it cannot sign anything. For a business owner, this means a server breach would not expose the keys that sign invoices in the shop’s name. The trade-off is that each PC or phone is onboarded separately and keeps its own chain.

The order of operations on a sale is strict: take the next ICV and PIH, build, hash and sign the XML, build the QR, save the document to a local SQLite database (kept apart from the POS data), and only then print the receipt. Reporting happens immediately when online; otherwise the document stays pending in the local database and is sent when the connection returns, since simplified documents have to reach ZATCA within 24 hours. A few rules came out of testing failure cases:

  • The chain position is stored twice, in the local database and in secure storage, so losing one does not break the chain.
  • Issuing runs behind a lock, so two quick sales can never claim the same counter value.
  • Reporting goes in chain order and stops at the first server or authentication error, then retries later. A 409 response means ZATCA already has the document, so it is marked as reported.
  • Timestamps use Saudi time (UTC+3) whatever the PC’s time zone setting is.
  • A B2B invoice cannot be handed over before clearance, so if ZATCA is unreachable the sale is not saved, the chain stays unchanged and the cashier is asked to try again.
  • A rejected document is never edited. The app issues a corrected document with a new number, and cancelled sales are handled with credit notes.

Practical takeaways

  • For business owners: ask your POS provider where the signing key lives, what happens to invoices when the internet drops, and how 24-hour reporting is monitored. Software support alone does not make a business compliant; onboarding with ZATCA is your step.
  • For developers: treat the hashed XML as an exact byte format and generate it in canonical form from the start. Keep money in integer halalas.
  • Write every signed document locally before printing, and report in chain order.
  • Pure Dart was enough here: pointycastle and a small DER encoder covered keys, CSRs and signatures without a Java SDK or a signing server.
  • Test against ZATCA’s sandbox early. I wrote scripts that run the whole onboarding there, plus unit tests for rounding, the initial hash, signatures, CSR contents and QR encoding. The sandbox rejects precisely and explains why.

Have a software idea or a business process that needs a proper system?

Tell me what you are trying to fix. I will reply with honest questions, a practical plan and a realistic estimate.