écrits/tutorial/2026/08
Tutorial10 août 2026·35 min

Intégration ZATCA Phase 2 en TypeScript : CSID, signature XAdES et clearance

Tous les guides sur Fatoora s'arrêtent à l'écran de connexion. Celui-ci va plus loin : générer une CSR conforme aux exigences ZATCA, obtenir un CSID de production, construire le XML UBL 2.1, le canonicaliser et le hacher correctement, y attacher une signature XAdES et un QR code TLV, puis soumettre la facture en clearance ou en reporting depuis TypeScript.

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 :

  1. Onboarding — paire de clés, CSR avec les extensions de certificat propres à ZATCA, CSID de conformité, factures de contrôle, puis CSID de production.
  2. Construction du document — un modèle de facture typé qui se rend en XML UBL 2.1.
  3. Cryptographie — canonicalisation, empreinte de facture, signature enveloppée XAdES-B-B, charge utile TLV du QR code.
  4. 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 InvoiceTypeCode01000000200000
FluxClearanceReporting
DélaiAvant remise de la facture à l'acheteurDans les 24 heures suivant l'émission
Ce que reçoit l'acheteurLe XML cleared renvoyé par ZATCALe XML signé par votre système
QR codeObligatoire, sans le cachet ZATCAObligatoire, 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 --strict

Les 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.pem

C'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 = Trading

Deux champs méritent une explication :

  • SN est le numéro de série de l'EGS (unité de génération de factures), dans la forme stricte 1-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.
  • title est 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. 1100 signifie que l'unité émet les deux. 0100 signifie 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>
  • ICV est le compteur de factures — un entier strictement croissant par unité EGS, jamais remis à zéro, jamais réutilisé.
  • PIH est 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ère 0. Seule votre toute première facture sur une unité donnée l'utilise.

Le piège du fuseau horaire. IssueDate et IssueTime s'expriment en heure locale saoudienne (UTC+3), tandis que le SigningTime à l'intérieur de la signature est un horodatage ISO en UTC. Un serveur en UTC qui formate les deux à partir du même objet Date produira 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 :

  1. ext:UBLExtensions — le conteneur de signature
  2. Le cac:AdditionalDocumentReference dont le cbc:ID vaut QR
  3. 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 parseInt perd de la précision au-delà de 15 chiffres — utilisez BigInt.
  • 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 :

  1. Sandbox — valide la structure et la mécanique de signature avec un certificat de test partagé. Retour rapide, sans identité réelle.
  2. 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.
  3. 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ômeCause la plus probable
invalid-hashCanonicalisation divergente — généralement l'élément QR ou Signature non retiré, ou C14N 1.0 utilisé au lieu de 1.1
invalid-digital-signatureNumé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 valideGabarit d'environnement incorrect — gabarit sandbox envoyé en simulation ou en production
Écart de PIH sur la deuxième factureL'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érenceArrondi par ligne puis sommé, au lieu de sommer puis arrondir une fois
QR décodé en arabe illisibleTag 1 tronqué à un décalage d'octet à l'intérieur d'un caractère multi-octets
Tout passe en sandbox, échoue en productionUtilisation persistante du certificat de test partagé au lieu du CSID de production

Pour aller plus loin

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.