Saudi Arabia's ZATCA Phase 2 e-invoicing mandate is one of the most technically demanding compliance requirements I've encountered in WooCommerce development. It's not just generating a PDF — it's UBL 2.1 XML, XAdES-BES digital signatures, a cryptographic hash chain, TLV-encoded QR codes, and real-time API submission to government servers.
Here's how I built FatooraPro, a WooCommerce plugin that handles the full ZATCA Phase 2 flow.
What ZATCA Phase 2 Actually Requires
Every invoice must:
- Be generated as UBL 2.1 compliant XML
- Be digitally signed with XAdES-BES using a ZATCA-issued certificate
- Include a TLV-encoded QR code with 8 specific fields
- Maintain a hash chain (each invoice's hash references the previous one)
- Be submitted to ZATCA's API — either for clearance (B2B) before delivery, or reporting (B2C) within 24 hours
Miss any of these and the invoice is legally invalid.
The Two Invoice Types
Standard Tax Invoice (B2B — Clearance)
Submitted to ZATCA before the invoice is delivered to the buyer. ZATCA must approve it. This means your checkout flow has to wait for an API response, or queue it and hold the invoice.
Simplified Tax Invoice (B2C — Reporting)
Submitted to ZATCA within 24 hours. More lenient — you can generate and deliver the invoice immediately, then report asynchronously.
In WooCommerce, I detect the invoice type based on whether the buyer has a VAT number (B2B) or not (B2C).
UBL 2.1 XML Structure
ZATCA requires a specific XML namespace and field order. Here's a simplified version of what the XML looks like:
<?xml version="1.0" encoding="UTF-8"?>
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
<cbc:ProfileID>reporting:1.0</cbc:ProfileID>
<cbc:ID>INV-2026-001</cbc:ID>
<cbc:UUID>6f4d3885-0e99-4f13-9c8b-...</cbc:UUID>
<cbc:IssueDate>2026-05-06</cbc:IssueDate>
<cbc:IssueTime>14:30:00</cbc:IssueTime>
<cbc:InvoiceTypeCode name="0200000">388</cbc:InvoiceTypeCode>
<!-- Seller info, buyer info, line items, tax totals... -->
</Invoice>
The InvoiceTypeCode name attribute is a 7-digit bitmask that encodes invoice properties (standard/simplified, credit/debit, etc.).
XAdES-BES Digital Signing
This is where it gets complex. ZATCA requires XAdES-BES (XML Advanced Electronic Signatures, Basic Electronic Signature profile). In PHP:
function sign_invoice( string $xml, string $private_key_pem, string $certificate_pem ): string {
// 1. Canonicalize the XML (C14N)
$dom = new DOMDocument();
$dom->loadXML( $xml );
$canonical = $dom->C14N( false, false );
// 2. Hash the canonicalized XML
$digest = base64_encode( hash( 'sha256', $canonical, true ) );
// 3. Build SignedInfo element with digest
$signed_info = $this->build_signed_info( $digest );
// 4. Sign the SignedInfo with the private key
openssl_sign( $signed_info, $signature_raw, $private_key_pem, OPENSSL_ALGO_SHA256 );
$signature_value = base64_encode( $signature_raw );
// 5. Embed certificate hash and signature into XML
return $this->embed_signature( $xml, $signature_value, $certificate_pem, $digest );
}
The certificate itself is issued by ZATCA through a 4-step onboarding process: CSR generation → Compliance CSID → Simulation testing → Production CSID.
The Hash Chain (PIH/ICV)
ZATCA requires sequential invoice integrity. Each invoice must contain:
- ICV (Invoice Counter Value): sequential integer starting at 1
- PIH (Previous Invoice Hash): SHA-256 hash of the previous invoice's XML
This means invoices are cryptographically linked — you can't insert, delete, or modify an old invoice without breaking the chain. In WooCommerce, I store the last hash in a WordPress option and update it atomically after each successful submission:
function get_and_increment_icv(): array {
// Use DB transaction to prevent race conditions
global $wpdb;
$wpdb->query( 'START TRANSACTION' );
$current_icv = (int) get_option( 'fatoorapro_icv', 0 );
$previous_hash = get_option( 'fatoorapro_pih', hash( 'sha256', 'NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2YzNDkyMQ==' ) );
$new_icv = $current_icv + 1;
update_option( 'fatoorapro_icv', $new_icv );
$wpdb->query( 'COMMIT' );
return [ 'icv' => $new_icv, 'pih' => $previous_hash ];
}
The default PIH for the first invoice is a SHA-256 hash of a specific base64 string defined in ZATCA's spec.
TLV-Encoded QR Code
ZATCA's QR code uses Tag-Length-Value encoding, not a simple URL. It must contain 8 fields:
function generate_tlv_qr( array $data ): string {
$tlv = '';
$fields = [
1 => $data['seller_name'], // Seller name
2 => $data['vat_number'], // VAT registration number
3 => $data['timestamp'], // Invoice timestamp
4 => $data['total_with_vat'], // Invoice total with VAT
5 => $data['vat_amount'], // VAT amount
6 => $data['xml_hash'], // Invoice XML hash (B2B only)
7 => $data['ecdsa_signature'], // ECDSA signature (B2B only)
8 => $data['public_key'], // Public key (B2B only)
];
foreach ( $fields as $tag => $value ) {
$encoded = mb_convert_encoding( $value, 'UTF-8' );
$length = strlen( $encoded );
$tlv .= chr( $tag ) . chr( $length ) . $encoded;
}
return base64_encode( $tlv );
}
Async Submission with WP-Cron
B2B clearance requires synchronous submission (blocking). But B2C reporting can be async. I use WP-Cron for retry logic on failures:
function schedule_submission( int $invoice_id ): void {
wp_schedule_single_event(
time() + 30, // 30 second delay
'fatoorapro_submit_invoice',
[ $invoice_id ]
);
}
add_action( 'fatoorapro_submit_invoice', function( int $invoice_id ): void {
$result = submit_to_zatca( $invoice_id );
if ( ! $result['success'] ) {
$attempts = (int) get_post_meta( $invoice_id, '_zatca_attempts', true );
if ( $attempts < 3 ) {
update_post_meta( $invoice_id, '_zatca_attempts', $attempts + 1 );
wp_schedule_single_event( time() + 300, 'fatoorapro_submit_invoice', [ $invoice_id ] ); // retry in 5 min
}
}
} );
HPOS Compatibility
ZATCA data is stored per-order, so it needs to work with both classic order posts and HPOS. I use WooCommerce's order meta abstraction:
// Works with both post meta and HPOS order tables
$order->update_meta_data( '_zatca_status', 'cleared' );
$order->update_meta_data( '_zatca_uuid', $uuid );
$order->update_meta_data( '_zatca_qr', $qr_code );
$order->save();
The Onboarding Flow
Getting a production certificate from ZATCA requires 4 steps:
- CSR generation — create a private key + certificate signing request with your VAT number embedded
- Compliance CSID — submit CSR to ZATCA, get a compliance certificate back
- Compliance tests — run 6 predefined test invoices against ZATCA's sandbox
- Production CSID — exchange compliance certificate for production certificate
I built a 4-step wizard in WP Admin that walks store owners through this. Most of them aren't developers, so the UI has to handle the complexity invisibly.
I packaged all of this into FatooraPro — a complete ZATCA Phase 2 solution for WooCommerce stores in Saudi Arabia: FatooraPro on CodeCanyon
Happy to go deeper on any part — the XAdES signing and hash chain were the most challenging pieces.
Top comments (0)