zatbridge_zatca
ZATCA Phase 1 QR generation and Phase 2 offline signing for simplified-subtype (B2C) documents.
This package is the client SDK for the commercial ZatBridge service. Using the package does not grant access to ZatBridge, ZATCA credentials, device enrollment, or hosted invoice services. A valid ZatBridge vendor account and one-time device enrollment are required for Phase 2 device signing only. Phase 1 QR generation is a local POS utility and requires neither enrollment nor network access.
Install
flutter pub add zatbridge_zatca
A simplified invoice must carry a valid QR at the point of sale whether or not the till
has connectivity.
QR tags 6-9 are the invoice hash, the signature value, the certificate public key and the
certificate signature, so producing them offline means running the whole signing chain on
the device.
That is what this package does; the document is then forwarded to ZatBridge for reporting
when the network returns. Ordinary invoices, credit notes, debit notes, and prepayments
are supported when their subtype is explicitly simplified.
Every standard-subtype document is out of scope: ZATCA must clear it before the QR exists, so standard invoices, credit notes, debit notes, and prepayments stay cloud-signed.
Status
Implemented and verified against the shared corpus:
- QR TLV encoding, including the per-tag codecs and UTF-8 byte lengths
- invoice hash (
base64(sha256(canonical form))) - W3C Canonical XML 1.1 with ZATCA's three digest exclusions
- the XAdES SignedProperties digest encoding
- the UBL 2.1 builder, verified by a full round trip against the Go builder's output
- fixed-point monetary arithmetic (money is never a double here)
- ECDSA over secp256k1, with deterministic RFC 6979 nonces
- X.509 certificate field extraction straight from DER
- XAdES SignedProperties, SignedInfo and the enveloped-signature UBLExtensions block
signInvoice, the full chain from unsigned UBL to a signed document plus its QR- one-time enrollment against ZatBridge with credentials sealed in platform secure storage
- a SQLite-backed, single-writer ICV/PIH chain and durable invoice outbox
- pre-signing structural, tax-category and fixed-point monetary validation
- ordered forwarding to ZatBridge with idempotent retries and exponential backoff
The offline device workflow is complete. The complete invoice, credit note, debit note, and prepayment flow has been verified against ZATCA's developer portal with continuous ICV/PIH chaining. Issuing and revoking one-time enrollment codes remains an operator workflow in ZatBridge, not a responsibility of this package.
Usage
Phase 1 QR
Generate the Base64 TLV payload from the authoritative values on the printed invoice, then give that text to the QR widget or renderer already used by the POS application.
final qrPayload = generatePhase1QrCodeTlv(
Phase1QrCodeInput(
sellerName: 'Example Store',
vatRegistration: '300000000000003',
invoiceTimestamp: DateTime.now(),
invoiceTotal: '115.00',
vatTotal: '15.00',
),
);
The Phase 1 payload contains ZATCA QR tags 1-5 only: seller name, seller VAT
registration number, issue timestamp, total including VAT, and total VAT.
Amounts are decimal strings rather than double values so the QR carries the
same legally rounded totals as the invoice. The timestamp is normalized to UTC
and emitted with second precision. The package returns QR content, not a QR
image or widget.
Phase 2 device signing
final zatbridge = await ZatBridge.open(
baseUrl: 'https://api.zatbridge.com',
);
// Once per device, while online.
await zatbridge.enroll(
code: enrollmentCode,
appFingerprint: installationFingerprint,
);
// Works with no network. The kind and subtype are independent and explicit.
final issued = await zatbridge.issueSimplified(InvoiceRequest.deviceSimplified(
documentKind: DocumentKind.creditNote,
billingReference: originalInvoiceNumber,
instructionNote: 'Customer return',
// ...the remaining invoice fields...
));
print(issued.qr);
// Optional explicit drain. A foreground timer also drains while the app runs.
final forwarded = await zatbridge.flush();
final status = await zatbridge.status();
issueSimplified accepts DocumentKind.invoice, creditNote, debitNote, and
prepayment. InvoiceRequest.deviceSimplified fixes the subtype to simplified and leaves
the UUID, ICV, PIH, and enrolled supplier identity for the device to assign. It validates, signs, advances
ICV/PIH and inserts the signed document plus its explicit kind/subtype into the outbox in
one SQLite write transaction. Credit and debit notes require a billing reference; an
omitted instruction note receives the same default correction reason as the cloud UBL
builder. Every standard-subtype combination is rejected before it consumes an ICV.
It returns only after that transaction commits and never waits for the network.
Concurrent callers are serialized by SQLite, including callers from different isolates or processes using the same database.
The forwarder submits in ICV order.
Transport errors, rate limits and server failures stay queued with exponential backoff.
A permanent validation or authentication rejection blocks later chain positions and is surfaced by status().permanentFailures, because skipping a rejected link would make the later PIH chain invalid.
The timer only runs while the host application is alive.
The package does not claim that iOS or Android will keep arbitrary Dart work alive in the background.
Durability is the guarantee: the outbox resumes on the next foreground start, and the host app can integrate flush() with its own platform-approved background task if needed.
The package fails closed when platform credentials survive an uninstall but the SQLite chain does not.
It never restarts at ICV 1 or reuses the original enrollment seed.
SQLite uses the native sqflite driver on Android, iOS and macOS, and the FFI driver on Windows and Linux.
Web is intentionally unsupported because browser storage cannot provide the private-key custody and crash-safe chain guarantees this workflow requires.
Signing does not disturb what it signed
The signature and the QR are added after the invoice digest is taken, and all three added subtrees are digest exclusions. Canonicalizing the signed document therefore has to reproduce the unsigned canonical form exactly, and a test asserts it. If that ever broke, every invoice the device issued would be self-inconsistent: the QR would carry a hash the document no longer has.
The SignedProperties digest is over a literal, not canonical XML
ZATCA specifies the exact bytes, indentation included, and self-closes ds:DigestMethod,
which C14N never emits.
So it cannot be derived by canonicalizing the element; it is reproduced verbatim.
The form embedded in the document differs from the digested form -- inside the document
the ds prefix is already in scope, so it is not redeclared -- and using one where the
other belongs produces a rejected document.
Signing is verified in both directions
test/fixtures holds a throwaway secp256k1 key, a self-signed certificate, a fixed
payload, and a signature over it produced by the platform's own vendored OpenSSL.
The tests check that Dart verifies what OpenSSL signed, and a manual check confirms
OpenSSL verifies what Dart signs.
Certificate fields are pinned from signing.ExtractCertificateInfo itself via
go test ./internal/signing -run TestFlutterCertificateFixture -update, so the two
extractions cannot drift.
Nonces are deterministic (RFC 6979) rather than random. A till is exactly the environment where entropy is worth not depending on, a repeated nonce under ECDSA discloses the private key, and determinism makes a signature reproducible when diagnosing a rejected invoice.
The generated signatures have been accepted by ZATCA's developer portal from the production egress address for every supported simplified document kind.
The corpus is the contract
In the source repository, test/corpus_test.dart reads the same signing corpus that the
Go chain pins its values from and byte-compares against the canonical XML checked in
beside it.
Reimplementing a compliance-critical signing chain in a second language is the risk offline signing accepts. This corpus is the control on it: if the two implementations ever disagree, the device would produce invoices ZATCA rejects, and without the corpus that would only surface in production.
test/ubl_builder_test.dart closes the loop: it builds each fixture from the corpus
inputs, canonicalizes, and byte-compares against the canonical XML the Go builder
produced from those same inputs. Element order is part of that contract, not a cosmetic
detail -- the hash is a digest over these bytes, so swapping two siblings changes it.
Run both with flutter test from this directory. Both halves must stay green, and
regenerating the corpus is a behaviour change that has to be justified in review.
Two things that are easy to get wrong
The SignedProperties digest is double-encoded. It is
base64(lowercase_hex(sha256(template))), not base64(sha256(template)). The decoded
value is 64 ASCII characters, not 32 raw bytes.
The certificate digest hashes the base64 text, not the DER. It is
base64(hex(sha256(base64(DER)))). Hashing the certificate bytes directly is the
intuitive reading and is wrong.
The QR TLV codecs differ per tag. Tags 1-5 are raw UTF-8 text. Tags 6 and 7 carry the base64 characters themselves. Tag 8 is base64 decoded to bytes, and tag 9 is hex decoded to bytes. Mixing these up produces a QR that scans but fails ZATCA validation. Lengths are byte counts, not character counts, which matters for every Arabic name.
Canonicalization
lib/src/core/ubl/c14n.dart is a real canonicalizer.
Published ZATCA packages commonly approximate this by trimming whitespace and splicing fixed indentation into the serialised string. That only yields correct hashes for XML that generator emitted at that exact indentation, and breaks as soon as the document shape shifts. The invoice hash is a digest over these bytes, so an approximation there means a document ZATCA rejects.
Libraries
- zatbridge_zatca
- ZATCA Phase 1 QR generation and Phase 2 offline signing.