A complete, developer-friendly abstraction for Saudi Arabia's ZATCA (Fatoora) E-Invoicing Phase 2.
zatca-lite handles the heavy lifting of ZATCA compliance, acting as the sole implementation layer for onboarding, CSR generation, CSID acquisition, cryptographic signing, UBL 2.1 XML generation, QR code generation, and API communication for both Clearance and Reporting. Your application only needs to provide persistence, source-data mapping, and background queueing.
Before writing code, you must understand how ZATCA processes invoices:
-
Environments: ZATCA provides three environments:
SANDBOX(developer testing),SIMULATION(pre-production testing), andPRODUCTION(live). -
Invoice Types:
-
Standard Invoices (Clearance): Used for B2B (Business to Business). The buyer must have a valid 15-digit Saudi VAT number. These must be sent to ZATCA before sharing with the customer.
-
Simplified Invoices (Reporting): Used for B2C (Business to Consumer / Walk-ins). These are generated, handed to the customer, and reported to ZATCA within 24 hours.
-
The PIH Chain (Previous Invoice Hash): ZATCA links invoices cryptographically. Every new invoice must contain the hash of the previously submitted invoice. This requires strict serialization (locking) across your application so two invoices aren't processed at the exact same millisecond.
Install the package via npm or yarn:
npm install zatca-lite
# or
yarn add zatca-lite
Don't forget to follow me on GitHub!
System Requirements:
- Node.js runtime.
- If using the official local SDK for offline validation (highly recommended for
SANDBOX), you must have OpenJDK 11 installed. Newer versions (like Java 17) cannot validate the SDK's secp256k1 simplified-invoice signatures properly.
To integrate zatca-lite into your backend, you must provide three interfaces. This keeps zatca-lite agnostic to your specific database.
Provides the seller's legal identity, national address, environment, and cryptographic credentials to the package.
Manages the PIH chain and invoice counters.
-
getPreviousHash(tenantId): Fetches the hash of the last successfullyREPORTEDorCLEAREDinvoice. -
getNextInvoiceCounter(tenantId, invoiceId): Returns a monotonically increasing, persistent integer (ICV) for the new invoice. -
saveSubmission(...): Saves the ZATCA response status, returned QR code, hash, and any API errors back to your database.
Prevents race conditions. Since invoices must be chained via PIH, you must lock the submission process per tenant. A Redis-based distributed lock is highly recommended.
import { createZatcaLite } from 'zatca-lite'
const zatca = createZatcaLite({
stores: {
configStore: new MyDbConfigStore(),
invoiceStateStore: new MyDbInvoiceStateStore(),
lockProvider: new RedisZatcaLockProvider(),
},
})Onboarding a seller device with ZATCA requires a strict 6-step flow. zatca-lite abstracts the API calls, but you must orchestrate the steps and securely save the credentials. Private keys and secrets should be encrypted at rest.
Collect the seller's legal name, 15-digit VAT Number, Registration Number (e.g., CRN), and physical Saudi address.
Generate the Certificate Signing Request. Generating a new CSR automatically invalidates any downstream credentials (CSIDs).
const result = await zatca.onboarding.generateCSR({
commonName: 'Device-01',
organizationName: 'Saudi Seller LLC',
organizationUnit: 'Riyadh Branch',
country: 'SA',
taxNumber: '300000000000003',
location: '1234 King Road Olaya Riyadh 12345',
envType: 'sandbox',
})
// Securely save result.csr and result.privateKey to your DBThe user must log into the ZATCA Fatoora Portal, generate a 6-digit OTP, and provide it to your app.
const result = await zatca.onboarding.requestComplianceCSID(
savedCsr,
'123456', // The Fatoora OTP
'sandbox'
)
// Securely save result.complianceCSID, result.complianceSecret, and result.requestIdBefore getting production credentials, ZATCA requires you to prove you can generate compliant invoices.
await zatca.onboarding.runComplianceCheck({
certificateId: 'cert-1',
privateKey: savedPrivateKey,
csid: savedComplianceCSID,
secret: savedComplianceSecret,
issuerInfo: sellerConfig,
envType: 'sandbox',
})
// Mark compliance as PASSED in your DBExchange the compliance credentials for live production credentials.
const result = await zatca.onboarding.requestProductionCSID(
savedComplianceCSID,
savedComplianceSecret,
savedComplianceRequestId,
'sandbox'
)
// Securely save result.productionCSID, result.productionSecret, and result.requestIdEnsure all credentials are saved and valid. The tenant is now ready to automatically submit invoices.
When bridging your system's invoices to zatca-lite's buildZatcaInvoice payload, adhere to ZATCA's strict UBL 2.1 mathematical rules:
-
Tax Limits: ZATCA only accepts Standard (
Sat 15%), Zero-rated (Z), Exempt (E), or Out-of-scope (O) taxes. Compound taxes are not supported and must be strictly rejected by your mapper before generation. -
Tax-Exclusive Unit Prices: Your line items must send the base, tax-exclusive price.
-
Discounts: Line-item discounts must be subtracted per-line before applying the tax rate.
-
UBL Root ID Sanitization: Standard XML signers (like
xml-crypto) inject anIdattribute on the root<Invoice>or<CreditNote>element. ZATCA's strict schema rejects this (Attribute 'Id' is not allowed to appear in element 'Invoice').zatca-liteautomatically sanitizes this for you before validation or submission.
import { buildZatcaInvoice } from 'zatca-lite'
const invoice = buildZatcaInvoice({
invoiceNumber: 'INV-2026-001',
uuid: '8d487816-70b8-4ade-a618-9d620b7381b0',
issueDate: new Date(),
invoiceType: '388', // 388 for Invoice, 381 for Credit Note
invoiceSubtype: isB2B ? '0100000' : '0200000', // Clearance vs Reporting
seller: sellerConfig,
buyer: buyerConfig, // Must include VAT Number and Address for Clearance
items: [
{
name: 'Software Development Services',
quantity: 1,
unitPrice: 1000.0,
taxRate: 15,
taxAmount: 150.0,
totalAmount: 1150.0,
totalIncludesTax: true,
},
],
})Never submit invoices synchronously during a user's web request. Network latency or gateway downtime will cause timeouts. Instead, push finalized invoices into an asynchronous background queue (e.g., BullMQ) with exponential retry.
In your queue worker, process the invoice:
// zatca-lite automatically uses your LockProvider to hold the PIH chain lock,
// fetches the previous hash, increments the ICV, signs the XML, submits it
// to the ZATCA gateway, and calls your saveSubmission method.
const result = await zatca.processInvoice({
tenantId: 'tenant-123',
invoiceId: 'inv-456',
invoice: mappedZatcaInvoice,
submissionType: 'clearance', // or 'reporting'
})
console.log(`Invoice status: ${result.zatcaStatus}`) // CLEARED, REPORTED, or FAILED-
Transient Errors (Retryable): Network timeouts, HTTP 503s, or lock timeouts. Keep these in your queue and retry.
-
Validation Errors (Permanent): E.g., Missing VAT numbers, mathematical mismatches (
BR-KSA-84), or preflight PIH mismatch (KSA-13). These cannot be solved by retrying. YourInvoiceStateStorewill mark them asFAILED. The user must fix the source data and re-trigger the process.
ZATCA's official Java SDK (fatoora CLI) is highly recommended for SANDBOX environments to catch XML/cryptographic errors locally before hitting rate-limited API endpoints.
To enable local pre-flight validation in zatca-lite:
-
Download the official ZATCA SDK Java 238 package.
-
Set your environment variables:
ZATCA_CLI_PATH: Absolute path to thefatooraexecutable.ZATCA_SDK_CONFIG: Absolute path to the SDK'sConfiguration/config.json.FATOORA_HOME: Absolute path to theAppsdirectory.JAVA_HOME: Absolute path to an OpenJDK 11 installation.
If configured, zatca-lite will route your signed XML through the local Java SDK. It dynamically overrides the pihPath to maintain PIH validation integrity without manual out-of-band coordination, throwing a descriptive LOCAL_SDK_VALIDATION_FAILED error if the invoice fails validation.