Cherchez comment se connecter à Fatoora et vous obtiendrez vingt fois la même page : connectez-vous au portail ZATCA, ouvrez les paramètres, cliquez sur "lier", collez un code OTP. Ces pages sont écrites par des éditeurs de logiciels comptables, et elles décrivent comment lier leur produit — pas comment en construire un.
La Phase 2 n'est pas une étape dans un portail. C'est un protocole cryptographique. Votre système génère une paire de clés, obtient un certificat auprès de ZATCA, construit la facture en XML UBL 2.1, la canonicalise, la hache, signe l'empreinte en XAdES, encode un QR code au format TLV, puis la soumet à une passerelle qui l'accepte ou la rejette. Chacune de ces étapes a sa manière d'échouer silencieusement, et l'échec se manifeste par deux réponses d'un vague exaspérant : invalid-hash ou invalid-digital-signature.
Ce tutoriel construit ce pipeline en TypeScript, avec une attention particulière aux trois endroits où les implémentations cassent réellement.
Périmètre. Ceci est un tutoriel d'intégration, pas un conseil fiscal. Savoir dans quelle vague vous tombez, si vos factures sont standards ou simplifiées, et quel traitement TVA appliquer sont des questions pour votre conseil fiscal. Ce qui est couvert ici, c'est ce qui se passe une fois ces réponses connues.
Ce que vous allez construire
Un module ZatcaClient avec quatre responsabilités :
- Onboarding — paire de clés, CSR avec les extensions de certificat propres à ZATCA, CSID de conformité, factures de contrôle, puis CSID de production.
- Construction du document — un modèle de facture typé qui se rend en XML UBL 2.1.
- Cryptographie — canonicalisation, empreinte de facture, signature enveloppée XAdES-B-B, charge utile TLV du QR code.
- Soumission — clearance pour les factures standards, reporting pour les simplifiées, avec la chaîne PIH et le compteur de factures maintenus entre les appels.
Prérequis
- Node.js 20 ou plus, TypeScript 5.x
- OpenSSL 3.x en ligne de commande
- Un compte sur le portail Fatoora pour votre entité assujettie à la TVA, si vous comptez dépasser le bac à sable
- Une bonne connaissance des espaces de noms XML et de la cryptographie asymétrique
- Le numéro de TVA de l'entité, son numéro de registre de commerce et son adresse nationale
Étape 1 : déterminer le flux de chaque facture
Avant d'écrire du code, réglez correctement ce branchement : il détermine le point de terminaison, l'obligation de délai, et ce que vous remettez à l'acheteur.
| Facture standard (B2B, B2G) | Facture simplifiée (B2C) | |
|---|---|---|
| Attribut name de InvoiceTypeCode | 0100000 | 0200000 |
| Flux | Clearance | Reporting |
| Délai | Avant remise de la facture à l'acheteur | Dans les 24 heures suivant l'émission |
| Ce que reçoit l'acheteur | Le XML cleared renvoyé par ZATCA | Le XML signé par votre système |
| QR code | Obligatoire, sans le cachet ZATCA | Obligatoire, avec le cachet cryptographique ZATCA |
La conséquence que l'on oublie : pour une facture standard, le document que vous émettez n'est pas celui que vous avez construit. ZATCA renvoie un champ clearedInvoice contenant un XML resigné portant son propre cachet. C'est lui, la facture légale. Si votre système envoie à l'acheteur le XML généré localement, vous n'êtes conforme à rien.
Les cinq chiffres qui suivent les deux premiers dans le code de type sont des indicateurs — tiers, nominal, export, récapitulatif, autofacturation — chacun à 0 ou 1. La plupart des factures sont à zéro partout.
Étape 2 : mise en place du projet
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 --strictLes environnements, que vous traverserez dans cet ordre :
// 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;La valeur csrTemplate diffère selon l'environnement et elle est intégrée dans la CSR elle-même. Soumettre une CSR construite avec le gabarit du bac à sable à la production est une erreur classique du premier jour, et le message de rejet ne vous dit pas que c'est la cause.
Étape 3 : générer la paire de clés et la CSR
ZATCA impose ECDSA sur la courbe secp256k1. Ni P-256, ni P-384. Avec la mauvaise courbe, l'onboarding échoue à l'étape du certificat avec une erreur qui ressemble à un problème de formatage.
openssl ecparam -name secp256k1 -genkey -noout -out private-key.pemC'est dans la CSR que se concentre l'essentiel des spécificités ZATCA. Elle porte des extensions OID personnalisées qui encodent votre identité et le type de factures que votre unité émet.
# 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 = TradingDeux champs méritent une explication :
SNest le numéro de série de l'EGS (unité de génération de factures), dans la forme stricte1-NOMSOLUTION|2-MODELE|3-UUID. Les barres verticales et les préfixes numériques font partie du format, ce n'est pas une convention d'affichage.titleest un masque binaire de quatre caractères décrivant ce que l'unité émet : la première position pour les factures standards, la deuxième pour les simplifiées.1100signifie que l'unité émet les deux.0100signifie simplifiées seulement.
Génération de la CSR :
openssl req -new -sha256 -key private-key.pem -config csr-config.cnf -out csr.pemÉtape 4 : onboarding — du CSID de conformité au CSID de production
L'onboarding, ce sont trois appels API avec un lot de factures de test entre les deux. Récupérez d'abord un OTP sur le portail Fatoora ; il expire au bout d'une heure environ.
// 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;
}
/** 4a — échanger CSR + OTP contre un CSID de conformité. */
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),
};
}
/** 4b — chaque facture de contrôle de conformité passe par ici. */
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;
}
/** 4c — échanger l'identifiant de demande de conformité contre le CSID de production. */
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),
};
}Entre 4a et 4c, il faut passer les contrôles de conformité. Les documents exigés dépendent du masque title de votre CSR — une unité déclarée comme émettant les deux types doit passer les six :
// standard : facture 388, note de débit 383, note de crédit 381
// simplifiée : facture 388, note de débit 383, note de crédit 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;Ces six factures forment leur propre chaîne PIH. L'empreinte de chacune devient l'empreinte de facture précédente de la suivante. Soumettez-les dans le désordre et elles échouent.
Le binarySecurityToken qui vous est renvoyé est un certificat X.509 encodé en base64. Décodez-le une fois et conservez le PEM obtenu — l'étape de signature a besoin du corps du certificat, de son numéro de série et du nom de son émetteur.
Étape 5 : construire la facture UBL 2.1
Le XSD de ZATCA est de l'UBL 2.1 avec un profil saoudien. Les éléments qui portent un sens protocolaire, et non métier, sont les suivants :
<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>ICVest le compteur de factures — un entier strictement croissant par unité EGS, jamais remis à zéro, jamais réutilisé.PIHest l'empreinte de la facture précédente. La valeur littérale ci-dessus est la graine bien connue : le base64 du SHA-256 du caractère0. Seule votre toute première facture sur une unité donnée l'utilise.
Le piège du fuseau horaire.
IssueDateetIssueTimes'expriment en heure locale saoudienne (UTC+3), tandis que leSigningTimeà l'intérieur de la signature est un horodatage ISO en UTC. Un serveur en UTC qui formate les deux à partir du même objetDateproduira des factures antidatées de trois heures — ce qui passe la validation en silence et ne se révèle qu'à un contrôle, des années plus tard. Formatez-les séparément et délibérément.
Modélisez la facture comme une donnée typée rendue une seule fois, plutôt que d'assembler des chaînes un peu partout :
// 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>;
/** Heure locale saoudienne, formatée en deux champs distincts. */
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 est créé vide ici ; le signataire le remplit à l'étape 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 et InvoiceLine suivent le même schéma.
return doc.end({ prettyPrint: false });
}Les totaux sont arrondis à deux décimales dans le XML, et ZATCA les recoupe : LegalMonetaryTotal/TaxInclusiveAmount doit être égal à TaxExclusiveAmount plus la somme des sous-totaux de taxe, au halala près. Arrondir chaque ligne puis sommer produit des écarts sur les grosses factures. Sommez d'abord, arrondissez une seule fois.
Étape 6 : canonicalisation et empreinte — là où la plupart des implémentations cassent
L'empreinte de facture n'est pas un SHA-256 de votre chaîne XML. C'est un SHA-256 du XML canonicalisé dont trois éléments ont été retirés :
ext:UBLExtensions— le conteneur de signature- Le
cac:AdditionalDocumentReferencedont lecbc:IDvautQR cac:Signature
La canonicalisation est C14N 1.1 sans commentaires. Les espaces, l'ordre des attributs et les déclarations d'espaces de noms modifient tous les octets effectivement hachés — c'est précisément pour cela qu'une chaîne assemblée à la main ne correspond presque jamais.
// 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 de l'empreinte SHA-256 — c'est ce que l'API appelle invoiceHash. */
export function invoiceHash(xml: string): string {
return createHash("sha256")
.update(canonicalizeForHash(xml), "utf8")
.digest("base64");
}L'astuce de débogage qui fait gagner des jours. Quand la passerelle renvoie
invalid-hash, écrivez les octets canonicalisés dans un fichier et comparez-les à la sortie du SDK officiel ZATCA pour la même facture. Le SDK fournit un validateur en ligne de commande précisément pour cette comparaison. Un seul saut de ligne en trop suffit à casser la correspondance, et aucune relecture de votre code ne vous le révélera.
Étape 7 : la signature XAdES
La signature est enveloppée dans ext:UBLExtensions et suit XAdES-B-B. Elle contient un bloc SignedInfo référençant l'empreinte de la facture et un bloc SignedProperties référençant le certificat.
// src/sign.ts
import { createSign, createHash, createPrivateKey } from "node:crypto";
export interface SigningMaterial {
privateKeyPem: string;
certificatePem: string; // décodé depuis binarySecurityToken
certificateSerial: string; // DÉCIMAL, pas hexadécimal
issuerName: string; // exactement comme dans le certificat
}
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 sur le bloc SignedInfo canonicalisé. */
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");
}Trois détails qui génèrent des tickets de support :
- Le numéro de série du certificat doit être en décimal. OpenSSL l'affiche en hexadécimal par défaut. Une conversion naïve avec
parseIntperd de la précision au-delà de 15 chiffres — utilisezBigInt. - L'empreinte du certificat se calcule sur le corps base64, en-tête PEM, pied de page et sauts de ligne retirés.
- Le nom de l'émetteur doit correspondre octet pour octet, ordre des composants du nom distinctif compris. Le reconstruire à partir de champs analysés dans un ordre différent produit une signature d'apparence valide que ZATCA rejette.
Étape 8 : le QR code TLV
La charge utile du QR est en Tag-Length-Value : un octet de tag, un octet de longueur, puis la valeur. Pour une facture simplifiée signée, neuf tags sont requis.
// 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 terminé par Z
totalWithVat: string; // deux décimales, tel qu'imprimé
vatTotal: string;
invoiceHashBase64: string;
signatureBase64: string;
publicKeyDer: Buffer;
zatcaStampSignature?: Buffer; // factures simplifiées uniquement
}
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");
}Le tag 1 est la raison sociale du vendeur en UTF-8 : pour une raison sociale en arabe, la longueur en octets vaut à peu près le double du nombre de caractères. Le plafond de 255 octets par tag est bien réel et les noms d'entreprises arabes longs l'atteignent — tronquez sur une frontière de caractère, jamais au milieu d'un octet, sinon le QR se décode en charabia.
La chaîne base64 obtenue retourne dans la facture sous forme d'un AdditionalDocumentReference dont le cbc:ID vaut QR, et ce après le calcul de l'empreinte — d'où sa suppression à l'étape de hachage.
Étape 9 : soumission — clearance et 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,
};
}Une réponse HTTP 200 avec status: "WARNING" est un succès. La facture est cleared ou reportée, et les avertissements sont indicatifs. Les systèmes qui traitent tout warningMessages non vide comme un échec finissent par renvoyer des factures déjà acceptées, ce qui casse la séquence ICV et se propage en erreurs d'empreinte sur tous les documents suivants.
Étape 10 : persister la chaîne, pas seulement la facture
Deux éléments d'état doivent survivre aux redémarrages, aux déploiements et aux plantages, par unité EGS :
// src/state.ts — esquisse ; à adosser à un stockage transactionnel
export interface EgsState {
egsUuid: string;
lastIcv: number;
lastInvoiceHash: string; // devient le PIH suivant
}
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 };
});
}Le verrou de ligne compte. Deux factures concurrentes qui lisent le même lastIcv construiront chacune un document revendiquant le même compteur et la même empreinte précédente. L'une passera, l'autre échouera — et pire, la chaîne comporte désormais une bifurcation que vous ne pouvez pas réparer sans passer par ZATCA. Si votre facturation tourne sur plusieurs workers, le compteur doit provenir d'une source unique sérialisée.
Conservez également le clearedInvoiceXml renvoyé. C'est le document légal pour les factures standards, et c'est le seul artefact qui prouve la clearance si un enregistrement de soumission venait à être contesté.
Tester votre implémentation
Parcourez les environnements dans l'ordre, sans sauter celui du milieu :
- Sandbox — valide la structure et la mécanique de signature avec un certificat de test partagé. Retour rapide, sans identité réelle.
- Simulation — onboarding complet avec votre vraie CSR et un vrai OTP, sur des données non productives. C'est là qu'apparaissent les erreurs propres à l'environnement, notamment la chaîne de gabarit de la CSR.
- Production — seulement après une simulation réussie de bout en bout pour tous les types de documents déclarés par votre masque
title.
Une petite suite de non-régression qui vaut la peine d'exister avant de toucher à la 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("correspond à la sortie du SDK ZATCA pour la facture de référence", () => {
const xml = readFileSync("fixtures/standard-invoice.xml", "utf8");
// Valeur produite par le validateur du SDK ZATCA pour le même fichier.
expect(invoiceHash(xml)).toBe(readFileSync("fixtures/standard-invoice.hash", "utf8").trim());
});
it("n'est pas affecté par la présence de la référence QR", () => {
const withQr = readFileSync("fixtures/standard-invoice-with-qr.xml", "utf8");
const withoutQr = readFileSync("fixtures/standard-invoice.xml", "utf8");
expect(invoiceHash(withQr)).toBe(invoiceHash(withoutQr));
});
});Ce second test est celui qui attrape tôt les régressions de canonicalisation : si ajouter l'élément QR change l'empreinte, votre logique de suppression est fausse, et toutes les factures soumises ensuite échoueront.
Dépannage
| Symptôme | Cause la plus probable |
|---|---|
invalid-hash | Canonicalisation divergente — généralement l'élément QR ou Signature non retiré, ou C14N 1.0 utilisé au lieu de 1.1 |
invalid-digital-signature | Numéro de série envoyé en hexadécimal, nom d'émetteur réordonné, ou mauvaise courbe à la génération de clé |
| Onboarding rejeté malgré une CSR valide | Gabarit d'environnement incorrect — gabarit sandbox envoyé en simulation ou en production |
Écart de PIH sur la deuxième facture | L'empreinte de la première a été enregistrée avant signature, ou une soumission en échec a quand même incrémenté le compteur |
| Totaux rejetés pour incohérence | Arrondi par ligne puis sommé, au lieu de sommer puis arrondir une fois |
| QR décodé en arabe illisible | Tag 1 tronqué à un décalage d'octet à l'intérieur d'un caractère multi-octets |
| Tout passe en sandbox, échoue en production | Utilisation persistante du certificat de test partagé au lieu du CSID de production |
Pour aller plus loin
- Si vos factures naissent dans un ERP plutôt que dans votre application, la surface d'intégration change : voir Odoo et ZATCA Phase 2 pour la vague 24 pour la façon dont ce mapping se fait habituellement.
- Pour le contexte de conformité autour de Fatoora — vagues, seuils et pénalités plutôt qu'API — commencez par le guide de la facturation électronique ZATCA.
- Le même principe de "valider avant que le portail ne voie le fichier" s'applique à d'autres plateformes saoudiennes : le générateur et validateur de fichier WPS et le validateur de catalogue Saber reposent sur la même idée.
- Pour les rejets de dédouanement plutôt que fiscaux, voir SFDA, Fasah et le dédouanement des expéditions.
Conclusion
La conformité Phase 2 est habituellement présentée comme une décision d'achat : choisissez un prestataire agréé, cliquez sur "lier", c'est terminé. Ce cadrage tient jusqu'au moment où vos factures naissent quelque part qu'aucun prestataire agréé n'atteint — une caisse sur mesure, un moteur de règlement de marketplace, un système de réservation, une application de service terrain. À ce moment-là, le protocole vous revient, et il est intransigeant d'une manière très particulière : les erreurs qu'il renvoie décrivent des symptômes, pas des causes.
Les trois étapes qui méritent d'être sur-travaillées sont la canonicalisation, le matériel de certificat dans la signature, et la persistance de la chaîne ICV et PIH. Réussissez-les et le reste du pipeline redevient du REST ordinaire. Ratez-en une et vous passerez une semaine à relire un message qui dit seulement invalid-hash.
Si vous êtes en pleine intégration et bloqué sur l'un de ces points — ou en train d'arbitrer entre attaquer la passerelle directement et passer par un prestataire — dites-nous où ça échoue et nous examinerons avec vous la requête et la réponse réelles.