Software Development
Article

Invoicing in Saudi Arabia: ZATCA E-Invoicing for Developers

AzizWares· ·8 min read

What Is ZATCA E-Invoicing?

Saudi Arabia’s Zakat, Tax and Customs Authority (ZATCA) mandates electronic invoicing for all VAT-registered businesses. The rollout has two phases:

Phase 1 — Generation (Dec 2021): Invoices must be generated and stored electronically in a structured format. No more handwritten or image-based invoices. A QR code is required on simplified (B2C) invoices.

Phase 2 — Integration (Jan 2023+): Invoices must be reported to (standard/B2B) or cleared by (simplified/B2C) ZATCA’s Fatoora platform in near real-time via API. Cryptographic stamping and XML submission become mandatory in waves.

If you’re building invoicing software for the Saudi market, you need to handle both phases.

Official reference: ZATCA E-Invoicing Developer Portal

Technical Requirements

Every ZATCA-compliant invoice must satisfy:

RequirementDetail
FormatUBL 2.1 XML (Universal Business Language)
UUIDEach invoice needs a unique UUID v4
Sequential numberingInvoice Counter Value (ICV), monotonically increasing
Previous invoice hashSHA-256 hash chain linking each invoice to the previous one
QR CodeTLV-encoded, Base64 string (Phase 1: 5 fields, Phase 2: 8 fields)
Digital signatureECDSA with secp256k1 curve, X.509 certificate from ZATCA
Cryptographic stampZATCA returns a signed stamp on clearance

QR Code Structure (TLV Encoding)

The QR code is a Base64-encoded byte array using Tag-Length-Value format:

TagFieldExample
1Seller name"AzizWares"
2VAT registration number"300000000000003"
3Timestamp (ISO 8601)"2026-02-27T12:00:00Z"
4Invoice total (with VAT)"115.00"
5VAT amount"15.00"
6XML hash (SHA-256)(hex bytes)
7ECDSA signature(DER bytes)
8Public key(DER bytes)

Tags 1–5 are required for Phase 1. Tags 6–8 are added in Phase 2.

Code Example: TLV Encoding in Python

import base64
from datetime import datetime

def tlv_encode(tag: int, value: str | bytes) -> bytes:
    if isinstance(value, str):
        value = value.encode('utf-8')
    # Note: single-byte length works for tags 1-5 (all < 256 bytes).
    # For Phase 2 tags (signature/key), use proper ASN.1-style length encoding.
    if len(value) > 127:
        length_bytes = len(value).to_bytes(2, 'big')
        return bytes([tag, 0x82]) + length_bytes + value
    return bytes([tag, len(value)]) + value

def generate_qr_base64(
    seller: str,
    vat_number: str,
    timestamp: datetime,
    total: str,
    vat_amount: str,
) -> str:
    tlv = b''
    tlv += tlv_encode(1, seller)
    tlv += tlv_encode(2, vat_number)
    tlv += tlv_encode(3, timestamp.isoformat() + 'Z')
    tlv += tlv_encode(4, total)
    tlv += tlv_encode(5, vat_amount)
    return base64.b64encode(tlv).decode('ascii')

qr = generate_qr_base64(
    seller="AzizWares",
    vat_number="300000000000003",
    timestamp=datetime(2026, 2, 27, 12, 0, 0),
    total="115.00",
    vat_amount="15.00",
)
print(qr)

Dart Equivalent (for Flutter Apps)

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

Uint8List tlvEncode(int tag, String value) {
  final bytes = utf8.encode(value);
  return Uint8List.fromList([tag, bytes.length, ...bytes]);
}

String generateZatcaQr({
  required String seller,
  required String vatNumber,
  required String timestamp,
  required String total,
  required String vatAmount,
}) {
  final tlv = BytesBuilder();
  tlv.add(tlvEncode(1, seller));
  tlv.add(tlvEncode(2, vatNumber));
  tlv.add(tlvEncode(3, timestamp));
  tlv.add(tlvEncode(4, total));
  tlv.add(tlvEncode(5, vatAmount));
  return base64Encode(tlv.toBytes());
}

Common Developer Pitfalls

1. Timestamp format: ZATCA requires ISO 8601 with timezone. Using 2026-02-27 12:00:00 instead of 2026-02-27T12:00:00Z will cause rejection in Phase 2.

2. Arabic text in TLV: The seller name must be UTF-8 encoded. If your seller name is "شركة عزيز", the byte length in the TLV L field must reflect the UTF-8 byte count (which is longer than the character count). Off-by-one here corrupts the entire QR payload.

3. Sandbox vs production: ZATCA’s sandbox (gw-fatoora.zatca.gov.sa) uses different compliance certificates than production. Your sandbox CSR won’t work in production — you must onboard with a real Hardware Security Module (HSM) or ZATCA-approved solution.

4. Hash chain breaks: Each invoice’s hash depends on the previous invoice’s hash. If you regenerate or skip an invoice, the chain breaks and the entire batch fails validation.

5. Certificate renewal: ZATCA compliance certificates expire. Build renewal logic into your system — don’t discover this in production.

How Mufawtir Handles This

Mufawtir is AzizWares’ invoicing app built specifically for ZATCA compliance. It handles TLV-encoded QR generation, sequential numbering, and ZATCA-compliant invoice formatting out of the box — so you don’t have to build the plumbing yourself. If you need a ready-made solution instead of building from scratch, check it out.

FAQ

Q: Do I need Phase 2 compliance right now? A: ZATCA is rolling out Phase 2 in waves based on revenue thresholds. Check ZATCA’s official wave schedule — but building for Phase 2 from the start saves you a painful migration.

Q: Can I use JSON instead of XML? A: No. ZATCA mandates UBL 2.1 XML. The Fatoora API only accepts XML payloads. You can use JSON internally, but the submission must be XML.

Q: What QR code library should I use? A: Any library that generates standard QR codes works — qrcode in Python, qr_flutter in Dart. The ZATCA-specific part is the payload (TLV-encoded Base64 string), not the QR rendering itself.

Q: Is the ZATCA sandbox reliable for testing? A: It’s functional but can be slow and occasionally down. Always build retry logic and don’t rely on it for CI/CD pipelines. Cache valid responses for local development.

Q: Do freelancers need ZATCA e-invoicing? A: If you’re VAT-registered in Saudi Arabia (revenue above SAR 375,000), yes. The threshold applies regardless of business size.

Tags ZATCA E-Invoicing Saudi Arabia QR Code TLV UBL XML Python Dart Flutter VAT Fatoora Mufawtir