MR
← All posts
ZATCA e-invoicing in NestJS

ZATCA e-invoicing in NestJS

2026-02-12

The research phase almost broke us

Before writing a single line of integration code, we spent weeks just understanding what ZATCA Phase 2 actually demands. The official documentation spans multiple PDFs, a Java SDK, a compliance portal, a simulation environment, and a set of UBL 2.1 XML specs that reference other specs. There's no "Getting Started" guide. You piece it together.
The spec that matters most is the ZATCA Technical Reference Manual — specifically Section 3.2 on invoice hash calculation. Miss a detail there and your entire invoice chain breaks silently.

The SDK is a Java JAR. Yes, really.

The official ZATCA tooling ships as a Java JAR (zatca-einvoicing-sdk-238-R3.4.6.jar). Our backend is NestJS. So the first architectural decision was how to bridge them.
We wrapped the SDK behind a thin NestJS service layer with five responsibilities:
  • SdkCsrService — CSR and private key generation (-csr command)
  • SdkSigningService — XML signing (-sign), with per-org credential caching
  • SdkValidationService — UBL validation (-validate), used during compliance testing only
  • SdkApiRequestService — building the ZATCA API payload (later replaced, see below)
  • SdkFileManagerService — managing temp dirs, SDK paths, and cert file lifecycle
Every SDK call spawns a Java process via execFile. Temp XML files are written before signing and cleaned up after — success or failure. It works, but it's not elegant.

We replaced one SDK call with Node.js

The SDK's -invoiceRequest command builds the JSON payload sent to ZATCA's API (invoice hash + UUID + base64 signed XML). After studying the spec, we rebuilt this in pure Node as ZatcaApiRequestBuilderService. The hash calculation follows TRM Section 3.2 exactly: strip UBLExtensions, the QR AdditionalDocumentReference, and the Signature node — then SHA-256 and base64.
This eliminated one Java spawn per invoice submission. The comparison tests run against real SDK output to verify parity.

Onboarding is a separate script, not an API call

ZATCA onboarding can't be a live API endpoint. It requires a one-hour OTP from the Fatoora portal, a freshly generated CSR, and a sequence of compliance invoice submissions before you can get a production CSID. We built two standalone Node scripts — one for simulation, one for production.
Both scripts:
  1. Generate a CSR via the SDK
  2. Request a Compliance CSID from ZATCA
  3. Sign and submit 6 invoice types (Standard Invoice, Credit Note, Debit Note × B2B and B2C)
  4. Wait, then request the Production CSID
  5. POST everything to /api/zatca/onboarding/import as the final step
The simulation script uses -sim flag on the SDK and a different serial number format (1-TST|2-TST|3-<uuid>). Production uses no flag, real identifiers, and an OTP from the live Fatoora portal. The scripts auto-clean generated files on exit.

Hash chaining is where bugs hide

Every invoice includes a previousInvoiceHash (PIH). The chain must be unbroken — ZATCA rejects invoices with an invalid PIH. We track the PIH from the last generated invoice, not just cleared ones. A REJECTED invoice still has a hash and still advances the chain.
This also means the ICV (Invoice Counter Value) is assigned only after successful signing. A signing failure doesn't consume an ICV. The counter lives in ZatcaConfig.invoiceCounter and is written only after the SDK signs successfully.

Failures are not all equal

Two invoice failure states behave very differently:
REJECTED — ZATCA received and validated the invoice, then rejected it. The ICV is consumed. The hash is in the chain. Retry must create a new invoice with a new ICV and UUID.
SUBMISSION_FAILED — Something broke before or during the HTTP call (network, timeout, backend error). No ICV consumed. If no later invoice was generated, you can resubmit the same signed XML.
Getting this wrong corrupts the invoice chain. We classify errors by HTTP status and ZATCA validationResults to set the correct status and retry hint automatically.

PDF generation inside Docker is its own adventure

We use Puppeteer for PDF generation with embedded UBL XML (PDF-A3). Alpine Linux Docker images don't ship Chromium — you install it separately and point PUPPETEER_EXECUTABLE_PATH at /usr/bin/chromium. The PDF also embeds the QR code as a base64 TLV image decoded from the SDK output.

What we'd do differently

Run the compliance test suite earlier. We ran it too late and had to revisit XML structure details after the onboarding pipeline was already half-built. The 6 compliance checks are the spec made concrete — treat them as your integration test suite.
ZATCA e-invoicing in NestJS | Mohannad Ragab