Search for how to connect to Fatoora and you get the same page twenty times: log into the ZATCA portal, open settings, click "link", paste an OTP. Those pages are written by accounting SaaS vendors, and they describe how to link their product — not how to build one.
Phase 2 is not a portal step. It is a cryptographic protocol. Your system generates a key pair, obtains a certificate from ZATCA, builds an invoice as UBL 2.1 XML, canonicalizes it, hashes it, signs the hash with XAdES, encodes a TLV QR code, and submits it to a gateway that either clears it or rejects it. Every one of those stages has a way to fail silently, and the failure surfaces as one of two maddeningly vague responses: invalid-hash or invalid-digital-signature.
This tutorial builds that pipeline in TypeScript, and pays particular attention to the three places where implementations actually break.
Scope note. This is an integration tutorial, not tax advice. Which wave you fall into, whether your invoices are standard or simplified, and what your VAT treatment should be are questions for your tax advisor. What is covered here is what happens once those answers are known.
What You'll Build
A ZatcaClient module with four responsibilities:
- Onboarding — key pair, CSR with ZATCA's custom certificate extensions, compliance CSID, compliance checks, production CSID.
- Document building — a typed invoice model that renders to UBL 2.1 XML.
- Cryptography — canonicalization, invoice hash, XAdES-B-B enveloped signature, TLV QR payload.
- Submission — clearance for standard invoices, reporting for simplified ones, with the PIH chain and invoice counter maintained across calls.
Prerequisites
- Node.js 20 or later, TypeScript 5.x
- OpenSSL 3.x on the command line
- A ZATCA Fatoora portal account for your VAT-registered entity, if you intend to go past sandbox
- Working knowledge of XML namespaces and asymmetric cryptography
- Your entity's VAT registration number, CR number and national address
Step 1: Decide Which Flow Each Invoice Takes
Before writing code, get this branch right, because it determines the endpoint, the timing obligation, and what you hand the buyer.
| Standard tax invoice (B2B, B2G) | Simplified tax invoice (B2C) | |
|---|---|---|
InvoiceTypeCode name attribute | 0100000 | 0200000 |
| Flow | Clearance | Reporting |
| Timing | Before the invoice is given to the buyer | Within 24 hours of issuance |
| What the buyer receives | The cleared XML returned by ZATCA | Your own signed XML |
| QR code | Required, without ZATCA's stamp | Required, including ZATCA's cryptographic stamp |
The consequence people miss: for a standard invoice, the document you issue is not the document you built. ZATCA returns a clearedInvoice field containing a re-signed XML with its own stamp attached. That is the legal invoice. If your system emails the buyer the XML it generated locally, you are compliant with nothing.
The five digits after the first two in the type code are flags — third party, nominal, exports, summary, self-billed — each 0 or 1. Most invoices are all zeros.
Step 2: Project Setup
mkdir zatca-integration && cd zatca-integration
npm init -y
npm install xml-crypto xmlbuilder2 node-forge axios zod
npm install -D typescript tsx @types/node
npx tsc --init --target es2022 --module nodenext --strictEnvironments, which you will move through in order:
// src/config.ts
export const ENVIRONMENTS = {
sandbox: {
base: "https://gw-fatoora.zatca.gov.sa/e-invoicing/developer-portal",
csrTemplate: "TSTZATCACode-Signing",
},
simulation: {
base: "https://gw-fatoora.zatca.gov.sa/e-invoicing/simulation",
csrTemplate: "PREZATCACode-Signing",
},
production: {
base: "https://gw-fatoora.zatca.gov.sa/e-invoicing/core",
csrTemplate: "ZATCACode-Signing",
},
} as const;
export type EnvName = keyof typeof ENVIRONMENTS;The csrTemplate value differs per environment and is embedded inside the CSR itself. Submitting a CSR built with the sandbox template to production is a common first-day error, and the rejection message does not tell you that is the cause.
Step 3: Generate the Key Pair and CSR
ZATCA requires ECDSA on the secp256k1 curve. Not P-256, not P-384. If you generate with the wrong curve, onboarding fails at the certificate stage with an error that reads like a formatting problem.
openssl ecparam -name secp256k1 -genkey -noout -out private-key.pemThe CSR is where most of the ZATCA-specific weight sits. It carries custom OID extensions that encode who you are and what kind of invoices your device issues.
# csr-config.cnf
oid_section = OIDs
[OIDs]
certificateTemplateName = 1.3.6.1.4.1.311.20.2
[req]
default_bits = 2048
distinguished_name = req_distinguished_name
prompt = no
req_extensions = req_ext
[req_distinguished_name]
C = SA
OU = Riyadh Branch
O = Noqta Trading Company
CN = EGS-886431145-101
[req_ext]
certificateTemplateName = ASN1:PRINTABLESTRING:TSTZATCACode-Signing
subjectAltName = dirName:alt_names
[alt_names]
SN = 1-Noqta|2-POS|3-1a2b3c4d-0000-0000-0000-9f8e7d6c5b4a
UID = 399999999900003
title = 1100
registeredAddress = King Fahd Road, Riyadh 12345
businessCategory = TradingTwo fields deserve explanation:
SNis the EGS (E-Invoice Generation Solution) serial, in the strict form1-SOLUTIONNAME|2-MODEL|3-UUID. The pipes and the numeric prefixes are part of the format, not a display convention.titleis a four-character bitmask of what this unit issues: position 1 is standard invoices, position 2 is simplified.1100means the unit issues both.0100means simplified only.
Generate the CSR:
openssl req -new -sha256 -key private-key.pem -config csr-config.cnf -out csr.pemStep 4: Onboarding — Compliance CSID to Production CSID
Onboarding is three API calls with a batch of test invoices in the middle. Get an OTP from the Fatoora portal first; it expires in about an hour.
// src/onboarding.ts
import axios from "axios";
import { ENVIRONMENTS, type EnvName } from "./config.js";
const headers = (extra: Record<string, string> = {}) => ({
"Accept-Version": "V2",
"Accept-Language": "en",
"Content-Type": "application/json",
...extra,
});
const basic = (token: string, secret: string) =>
"Basic " + Buffer.from(`${token}:${secret}`).toString("base64");
export interface Csid {
binarySecurityToken: string;
secret: string;
requestID: string;
}
/** Step 4a — exchange CSR + OTP for a Compliance CSID. */
export async function requestComplianceCsid(
env: EnvName,
csrPem: string,
otp: string
): Promise<Csid> {
const csrBase64 = Buffer.from(
csrPem.replace(/-----(BEGIN|END) CERTIFICATE REQUEST-----/g, "").replace(/\s/g, "")
).toString("base64");
const res = await axios.post(
`${ENVIRONMENTS[env].base}/compliance`,
{ csr: csrBase64 },
{ headers: headers({ OTP: otp }) }
);
return {
binarySecurityToken: res.data.binarySecurityToken,
secret: res.data.secret,
requestID: String(res.data.requestID),
};
}
/** Step 4b — every compliance check invoice goes through this. */
export async function submitComplianceInvoice(
env: EnvName,
ccsid: Csid,
payload: { invoiceHash: string; uuid: string; invoice: string }
) {
const res = await axios.post(
`${ENVIRONMENTS[env].base}/compliance/invoices`,
payload,
{
headers: headers({
Authorization: basic(ccsid.binarySecurityToken, ccsid.secret),
}),
validateStatus: () => true,
}
);
return res.data;
}
/** Step 4c — trade the compliance request ID for the Production CSID. */
export async function requestProductionCsid(
env: EnvName,
ccsid: Csid
): Promise<Csid> {
const res = await axios.post(
`${ENVIRONMENTS[env].base}/production/csids`,
{ compliance_request_id: ccsid.requestID },
{
headers: headers({
Authorization: basic(ccsid.binarySecurityToken, ccsid.secret),
}),
}
);
return {
binarySecurityToken: res.data.binarySecurityToken,
secret: res.data.secret,
requestID: String(res.data.requestID),
};
}Between 4a and 4c you must pass the compliance checks. Which documents you have to submit depends on the title bitmask in your CSR — a unit declared as issuing both types must pass all six:
// standard: invoice 388, debit note 383, credit note 381
// simplified: invoice 388, debit note 383, credit note 381
const COMPLIANCE_MATRIX = [
{ typeName: "0100000", typeCode: "388" },
{ typeName: "0100000", typeCode: "383" },
{ typeName: "0100000", typeCode: "381" },
{ typeName: "0200000", typeCode: "388" },
{ typeName: "0200000", typeCode: "383" },
{ typeName: "0200000", typeCode: "381" },
] as const;These six invoices form a PIH chain of their own. Each one's hash becomes the previous-invoice hash of the next. Submit them out of order and they fail.
The binarySecurityToken returned to you is a base64-encoded X.509 certificate. Decode it once and keep the decoded PEM — the signing stage needs the certificate body, its serial number and its issuer name.
Step 5: Build the UBL 2.1 Invoice
ZATCA's XSD is UBL 2.1 with a Saudi-specific profile. The parts that carry protocol meaning, rather than business meaning, are these:
<cbc:ProfileID>reporting:1.0</cbc:ProfileID>
<cbc:ID>INV-2026-000412</cbc:ID>
<cbc:UUID>9f2c8e1a-4b7d-4f3a-9c21-77b1a0e5d3f8</cbc:UUID>
<cbc:IssueDate>2026-08-10</cbc:IssueDate>
<cbc:IssueTime>14:32:07</cbc:IssueTime>
<cbc:InvoiceTypeCode name="0100000">388</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>SAR</cbc:DocumentCurrencyCode>
<cbc:TaxCurrencyCode>SAR</cbc:TaxCurrencyCode>
<cac:AdditionalDocumentReference>
<cbc:ID>ICV</cbc:ID>
<cbc:UUID>412</cbc:UUID>
</cac:AdditionalDocumentReference>
<cac:AdditionalDocumentReference>
<cbc:ID>PIH</cbc:ID>
<cac:Attachment>
<cbc:EmbeddedDocumentBinaryObject mimeCode="text/plain">
NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ==
</cbc:EmbeddedDocumentBinaryObject>
</cac:Attachment>
</cac:AdditionalDocumentReference>ICVis the invoice counter value — a strictly incrementing integer per EGS unit, never reset, never reused.PIHis the previous invoice hash. The literal value shown above is the well-known seed: base64 of the SHA-256 of the character0. Only your very first invoice on a given unit uses it.
The timezone trap.
IssueDateandIssueTimeare expressed in Saudi local time (UTC+3), while theSigningTimeinside the signature is a UTC ISO timestamp. A server running in UTC that formats both from the sameDatewill produce invoices three hours in the past, which passes validation quietly and then fails an audit years later. Format them separately and deliberately.
Model it as typed data and render once, rather than assembling strings across your codebase:
// src/invoice.ts
import { create } from "xmlbuilder2";
import { z } from "zod";
export const InvoiceInput = z.object({
id: z.string().min(1),
uuid: z.string().uuid(),
issuedAt: z.date(),
typeName: z.enum(["0100000", "0200000"]),
typeCode: z.enum(["388", "383", "381"]),
icv: z.number().int().positive(),
pih: z.string().min(1),
seller: z.object({ name: z.string(), vat: z.string().length(15), crn: z.string() }),
buyer: z.object({ name: z.string(), vat: z.string().optional() }).optional(),
lines: z.array(
z.object({
name: z.string(),
quantity: z.number().positive(),
unitPrice: z.number().nonnegative(),
vatRate: z.number().min(0).max(1),
})
).min(1),
});
export type InvoiceInput = z.infer<typeof InvoiceInput>;
/** Saudi local time, formatted as two separate fields. */
function riyadhParts(d: Date) {
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Riyadh",
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit",
hourCycle: "h23",
}).formatToParts(d);
const p = Object.fromEntries(fmt.map((x) => [x.type, x.value]));
return {
date: `${p.year}-${p.month}-${p.day}`,
time: `${p.hour}:${p.minute}:${p.second}`,
};
}
export function buildInvoiceXml(input: InvoiceInput): string {
const data = InvoiceInput.parse(input);
const { date, time } = riyadhParts(data.issuedAt);
const lineTotal = data.lines.reduce((s, l) => s + l.quantity * l.unitPrice, 0);
const vatTotal = data.lines.reduce(
(s, l) => s + l.quantity * l.unitPrice * l.vatRate, 0
);
const doc = create({ version: "1.0", encoding: "UTF-8" })
.ele("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",
"xmlns:ext":
"urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2",
});
// UBLExtensions is created empty here; the signer fills it in Step 7.
doc.ele("ext:UBLExtensions").up();
doc.ele("cbc:ProfileID").txt("reporting:1.0").up();
doc.ele("cbc:ID").txt(data.id).up();
doc.ele("cbc:UUID").txt(data.uuid).up();
doc.ele("cbc:IssueDate").txt(date).up();
doc.ele("cbc:IssueTime").txt(time).up();
doc.ele("cbc:InvoiceTypeCode", { name: data.typeName }).txt(data.typeCode).up();
doc.ele("cbc:DocumentCurrencyCode").txt("SAR").up();
doc.ele("cbc:TaxCurrencyCode").txt("SAR").up();
// ... AccountingSupplierParty, AccountingCustomerParty, TaxTotal,
// LegalMonetaryTotal and InvoiceLine follow the same pattern.
return doc.end({ prettyPrint: false });
}The totals shown are rounded to two decimals in the XML, and ZATCA cross-checks them: LegalMonetaryTotal/TaxInclusiveAmount must equal TaxExclusiveAmount plus the sum of the tax subtotals, to the halala. Rounding each line independently and then summing produces mismatches on large invoices. Sum first, round once.
Step 6: Canonicalize and Hash — Where Most Implementations Break
The invoice hash is not a SHA-256 of your XML string. It is a SHA-256 of the canonicalized XML with three elements removed:
ext:UBLExtensions— the signature container- The
cac:AdditionalDocumentReferencewhosecbc:IDisQR cac:Signature
Canonicalization is C14N 1.1 without comments. Whitespace, attribute order and namespace declarations all change the bytes being hashed, which is exactly why a hand-assembled string almost never matches.
// src/hash.ts
import { createHash } from "node:crypto";
import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
import * as xpath from "xpath";
import { SignedXml } from "xml-crypto";
const NS = {
cac: "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2",
cbc: "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2",
ext: "urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2",
};
export function canonicalizeForHash(xml: string): string {
const doc = new DOMParser().parseFromString(xml, "text/xml");
const select = xpath.useNamespaces(NS);
const toRemove = [
...select("//ext:UBLExtensions", doc),
...select("//cac:Signature", doc),
...select(
"//cac:AdditionalDocumentReference[cbc:ID='QR']",
doc
),
] as Node[];
for (const node of toRemove) node.parentNode?.removeChild(node);
const canon = new (SignedXml as any).CanonicalizationAlgorithms[
"http://www.w3.org/2006/12/xml-c14n11"
]();
return canon.process(doc.documentElement, {});
}
/** Base64 of the SHA-256 digest — this is what the API calls invoiceHash. */
export function invoiceHash(xml: string): string {
return createHash("sha256")
.update(canonicalizeForHash(xml), "utf8")
.digest("base64");
}Debugging tip that saves days. When the gateway returns
invalid-hash, dump the canonicalized bytes to a file and diff them against ZATCA's own SDK output for the same invoice. The SDK ships a command-line validator precisely for this comparison. A single trailing newline is enough to break the match, and no amount of re-reading your code will reveal it.
Step 7: The XAdES Signature
The signature is enveloped inside ext:UBLExtensions and follows XAdES-B-B. It contains a SignedInfo block referencing the invoice digest and a SignedProperties block referencing the certificate.
// src/sign.ts
import { createSign, createHash, createPrivateKey } from "node:crypto";
export interface SigningMaterial {
privateKeyPem: string;
certificatePem: string; // decoded from binarySecurityToken
certificateSerial: string; // DECIMAL, not hex
issuerName: string; // exactly as in the certificate
}
export function signedPropertiesDigest(
m: SigningMaterial,
signingTimeIso: string
): { xml: string; digest: string } {
const certDigest = createHash("sha256")
.update(m.certificatePem.replace(/-----[^-]+-----|\s/g, ""))
.digest("base64");
const xml =
`<xades:SignedProperties Id="xadesSignedProperties">` +
`<xades:SignedSignatureProperties>` +
`<xades:SigningTime>${signingTimeIso}</xades:SigningTime>` +
`<xades:SigningCertificate><xades:Cert>` +
`<xades:CertDigest>` +
`<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>` +
`<ds:DigestValue>${certDigest}</ds:DigestValue>` +
`</xades:CertDigest>` +
`<xades:IssuerSerial>` +
`<ds:X509IssuerName>${m.issuerName}</ds:X509IssuerName>` +
`<ds:X509SerialNumber>${m.certificateSerial}</ds:X509SerialNumber>` +
`</xades:IssuerSerial>` +
`</xades:Cert></xades:SigningCertificate>` +
`</xades:SignedSignatureProperties>` +
`</xades:SignedProperties>`;
const digest = createHash("sha256").update(xml, "utf8").digest("base64");
return { xml, digest };
}
/** ECDSA-SHA256 over the canonicalized SignedInfo block. */
export function signSignedInfo(canonicalSignedInfo: string, keyPem: string): string {
const signer = createSign("SHA256");
signer.update(canonicalSignedInfo, "utf8");
signer.end();
return signer.sign(createPrivateKey(keyPem)).toString("base64");
}Three details that generate support tickets:
- The certificate serial number must be decimal. OpenSSL prints it in hex by default. Converting with a naive
parseIntloses precision on serials longer than 15 digits — useBigInt. - The certificate digest is taken over the base64 body, with the PEM header, footer and all line breaks stripped.
- The issuer name must match byte-for-byte, including the order of the RDN components. Reconstructing it from parsed fields in a different order produces a valid-looking signature that ZATCA rejects.
Step 8: The TLV QR Code
The QR payload is Tag-Length-Value: one byte of tag, one byte of length, then the value. For a signed simplified invoice, nine tags are required.
// src/qr.ts
function tlv(tag: number, value: Buffer): Buffer {
if (value.length > 255) throw new Error(`TLV tag ${tag} exceeds 255 bytes`);
return Buffer.concat([Buffer.from([tag, value.length]), value]);
}
export interface QrInput {
sellerName: string;
vatNumber: string;
timestampIso: string; // UTC, ISO 8601 with Z
totalWithVat: string; // two decimals, as printed
vatTotal: string;
invoiceHashBase64: string;
signatureBase64: string;
publicKeyDer: Buffer;
zatcaStampSignature?: Buffer; // simplified invoices only
}
export function buildQr(q: QrInput): string {
const parts = [
tlv(1, Buffer.from(q.sellerName, "utf8")),
tlv(2, Buffer.from(q.vatNumber, "utf8")),
tlv(3, Buffer.from(q.timestampIso, "utf8")),
tlv(4, Buffer.from(q.totalWithVat, "utf8")),
tlv(5, Buffer.from(q.vatTotal, "utf8")),
tlv(6, Buffer.from(q.invoiceHashBase64, "utf8")),
tlv(7, Buffer.from(q.signatureBase64, "base64")),
tlv(8, q.publicKeyDer),
];
if (q.zatcaStampSignature) parts.push(tlv(9, q.zatcaStampSignature));
return Buffer.concat(parts).toString("base64");
}Tag 1 is the seller name in UTF-8, which for an Arabic trade name means the byte length is roughly double the character count. The 255-byte ceiling per tag is real and long Arabic company names do hit it — truncate on a character boundary, never mid-byte, or the QR decodes to mojibake.
The resulting base64 string goes back into the invoice as an AdditionalDocumentReference with cbc:ID of QR, after hashing, which is why the hash step removes it.
Step 9: Submit — Clearance and Reporting
// src/submit.ts
import axios from "axios";
import { ENVIRONMENTS, type EnvName } from "./config.js";
export type SubmitResult = {
ok: boolean;
status: "PASS" | "WARNING" | "ERROR" | "UNKNOWN";
clearedInvoiceXml?: string;
warnings: string[];
errors: string[];
};
export async function submitInvoice(
env: EnvName,
pcsid: { binarySecurityToken: string; secret: string },
payload: { invoiceHash: string; uuid: string; invoice: string },
mode: "clearance" | "reporting"
): Promise<SubmitResult> {
const path =
mode === "clearance" ? "/invoices/clearance/single" : "/invoices/reporting/single";
const res = await axios.post(`${ENVIRONMENTS[env].base}${path}`, payload, {
headers: {
"Accept-Version": "V2",
"Accept-Language": "en",
"Content-Type": "application/json",
"Clearance-Status": mode === "clearance" ? "1" : "0",
Authorization:
"Basic " +
Buffer.from(`${pcsid.binarySecurityToken}:${pcsid.secret}`).toString("base64"),
},
validateStatus: () => true,
timeout: 30_000,
});
const v = res.data?.validationResults ?? {};
const warnings = (v.warningMessages ?? []).map((m: any) => `${m.code}: ${m.message}`);
const errors = (v.errorMessages ?? []).map((m: any) => `${m.code}: ${m.message}`);
return {
ok: res.status === 200 && errors.length === 0,
status: v.status ?? "UNKNOWN",
clearedInvoiceXml: res.data?.clearedInvoice
? Buffer.from(res.data.clearedInvoice, "base64").toString("utf8")
: undefined,
warnings,
errors,
};
}A response of HTTP 200 with status: "WARNING" is a success. The invoice is cleared or reported and the warnings are advisory. Systems that treat any non-empty warningMessages as failure end up retrying invoices that were already accepted, which breaks the ICV sequence and cascades into hash errors on every subsequent document.
Step 10: Persist the Chain, Not Just the Invoice
Two pieces of state must survive process restarts, deployments and crashes, per EGS unit:
// src/state.ts — sketch; back this with a transactional store
export interface EgsState {
egsUuid: string;
lastIcv: number;
lastInvoiceHash: string; // becomes the next PIH
}
export async function nextDocument(
db: Db,
egsUuid: string,
build: (icv: number, pih: string) => Promise<{ hash: string; xml: string }>
) {
return db.transaction(async (tx) => {
const state = await tx.selectForUpdate("egs_state", { egsUuid });
const icv = state.lastIcv + 1;
const { hash, xml } = await build(icv, state.lastInvoiceHash);
await tx.update("egs_state", { egsUuid }, { lastIcv: icv, lastInvoiceHash: hash });
return { icv, hash, xml };
});
}The row lock matters. Two concurrent invoices reading the same lastIcv will both build a document claiming the same counter and the same previous hash. One will clear; the other will fail, and worse, the chain now has a fork you cannot repair without contacting ZATCA. If your invoicing runs across multiple workers, the counter must come from a single serialized source.
Keep the returned clearedInvoiceXml too. It is the legal document for standard invoices, and it is the only artifact that proves clearance if a submission record is ever disputed.
Testing Your Implementation
Work through the environments in order, and do not skip the middle one:
- Sandbox — validates structure and signature mechanics with a shared test certificate. Fast feedback, no real identity.
- Simulation — full onboarding with your real CSR and OTP, against non-production data. This is where environment-specific mistakes surface, particularly the CSR template string.
- Production — only after simulation passes end to end for all document types your
titlebitmask declares.
A minimal regression suite worth having before you touch production:
// tests/hash.test.ts
import { describe, it, expect } from "vitest";
import { invoiceHash } from "../src/hash.js";
import { readFileSync } from "node:fs";
describe("invoice hash", () => {
it("matches the ZATCA SDK output for the reference invoice", () => {
const xml = readFileSync("fixtures/standard-invoice.xml", "utf8");
// Value produced by the ZATCA SDK validator for the same fixture.
expect(invoiceHash(xml)).toBe(readFileSync("fixtures/standard-invoice.hash", "utf8").trim());
});
it("is unaffected by the QR reference being present", () => {
const withQr = readFileSync("fixtures/standard-invoice-with-qr.xml", "utf8");
const withoutQr = readFileSync("fixtures/standard-invoice.xml", "utf8");
expect(invoiceHash(withQr)).toBe(invoiceHash(withoutQr));
});
});That second test is the one that catches canonicalization regressions early — if adding the QR element changes the hash, your removal logic is wrong, and every invoice you submit afterwards will fail.
Troubleshooting
| Symptom | Most likely cause |
|---|---|
invalid-hash | Canonicalization differs — usually the QR or Signature element was not removed, or C14N 1.0 was used instead of 1.1 |
invalid-digital-signature | Certificate serial submitted as hex, issuer name reordered, or the wrong curve at key generation |
| Onboarding rejected after a valid CSR | Environment template mismatch — sandbox template sent to simulation or production |
PIH mismatch on the second invoice | The first invoice's hash was recorded before signing, or a failed submission still advanced the counter |
| Totals rejected as inconsistent | Per-line rounding summed instead of summing then rounding once |
| QR decodes to garbled Arabic | Tag 1 truncated at a byte offset inside a multi-byte character |
| Everything passes in sandbox, fails in production | Still using the sandbox shared certificate rather than your production CSID |
Next Steps
- If your invoices originate in an ERP rather than your own application, the integration surface changes: see Odoo and ZATCA Phase 2 for Wave 24 for how that mapping is usually done.
- For the compliance context around Fatoora — waves, thresholds and penalties rather than APIs — start with the ZATCA e-invoicing guide.
- The same "validate before the portal sees it" pattern applies to other Saudi platforms: the WPS file generator and validator and the Saber catalogue validator are built on the same idea.
- For import and customs clearance rejections rather than tax ones, see SFDA and Fasah shipment clearance.
Conclusion
Phase 2 compliance is usually presented as a procurement decision: pick a certified provider, click link, done. That framing works right up until your invoices originate somewhere a certified provider does not reach — a custom POS, a marketplace settlement engine, a booking system, a field-service app. At that point the protocol is yours to implement, and it is unforgiving in a specific way: the errors it returns describe symptoms, not causes.
The three stages worth over-engineering are canonicalization, the certificate material in the signature, and the persistence of the ICV and PIH chain. Get those right and the rest of the pipeline is ordinary REST work. Get any of them wrong and you will spend a week reading a message that just says invalid-hash.
If you are mid-integration and stuck on one of these — or deciding whether to build against the gateway directly rather than through a provider — tell us where it is failing and we will look at the actual request and response with you.