ZuloOne
English
Sign in
Menu

🧾 ZATCA / Fatoora — sources and test stands

Updated

← Localizations

A workspace script cannot access the network — and that is the only thing left on the platform host. Everything Saudi lives in the LocalizationSaudiArabia model: the QR format, the certificate profile, the Fatoora headers, the channel addresses and the onboarding exchange itself. The kernel holds only what cannot live in a script: the transport (IOutboundGateway for documents, IOutboundCall for synchronous exchanges) and the key (ICredentialSigner).

User: how to turn the e-invoice on. Business: ZATCA, KSA localization.

The parties' master data (no HTTPS)#

Fatoora's UBL requires the seller's VAT or CRN (the VAT vs CRN scheme), a KSA national address (Country + District + a 4-digit Building) and a sequential ICV.

Field Where Layer
LegalEntity.TaxRegistrationNumber the seller's VAT (the same as on Customer) Organization (Global Core)
LegalEntity.CommercialRegistration CRN, not VAT LegalEntity.LocalizationSaudiArabia → class LegalEntity_LocalizationSaudiArabia (on the host LegalEntity has no such field; in tests — Db.UpdateAsync/GetAsync)
Address.Country, Address.District country and district Common (Global Core)
TaxDocument.InvoiceCounter ICV, the atomic IKeyedCounterService (zatca-icv / legal entity) Tax + host
TaxDocument.Payload UBL 2.1 XML (the QR is embedded; XAdES when there is a CertificateDer) Tax (Global Core)
TaxCategory.Treatment STANDARD / ZERO_RATED / EXEMPT / OUT_OF_SCOPE / REVERSE_CHARGE Tax (Global Core)
TaxCategory.ExemptionReasonCode / ExemptionReasonText VATEX-SA-* and the text for UBL E/O TaxCategory.LocalizationSaudiArabia

What lives where#

Object Where Why not somewhere else
ZatcaXades (a model service) workspace the C14N hash, embedding the QR and XAdES-BES — the Saudi UBL envelope
ZatcaQr (a model service) workspace the TLV tags and the ceiling of 500 — a Saudi tax format
ZatcaCsr (a model service) workspace C=SA, the ZATCA templates and the CN 1-VAT|2-…|3-… — one country's certificate profile
FatooraSigner (a model service) workspace four headers from the Fatoora manual; implements the platform seam IOutboundSigner
ZatcaOnboarding (a model service) workspace the channel names, the OTP header, the JSON shape and the response fields
the 5 fatoora-* channels workspace (OutboundChannels/*.channel.json) gw-fatoora addresses must not be compiled into the platform
ICredentialSigner kernel hands out the signature, the SPKI, the certificate's signature and its DER; not the key; ICredentialResolver is forbidden to scripts
IOutboundCall / IOutboundGateway / the transport kernel HttpClient is outside the scripts' reference set, and that is deliberate
IXmlCanonicalizer kernel C14N 1.0; scripts have no System.Xml / Cryptography.Xml

SaudiEInvoice computes the invoice hash through IZatcaXades.InvoiceHash (C14N 1.0 minus UBLExtensions / cac:Signature / the QR) and assembles the QR through IZatcaQr. The ICV and the previous hash come from the host: IKeyedCounterService.TakeAsync("zatca-icv", legalEntity) atomically increments the (name, scope) row through ISqlDialect.TakeKeyedCounterSql (OUTPUT / RETURNING), and then SetTokenAsync stores the hash that the next Take will return as the PIH. The kernel signs: ICredentialSigner.SignHash(credentialRef, hashBase64) returns the signature, the SPKI, the certificate's signature and the certificate's DER — or null, and then the QR carries tags 1–6, which is still valid for an invoice. After the hash, the QR is embedded into Payload (AdditionalDocumentReference / ID=QR); if there is a CertificateDer, the same service adds XAdES-BES (ds:Signature). SignatureValue is a second signature: ECDSA over SHA-256(C14N(SignedInfo)), not over the invoice hash (tag 7). The queue: {uuid, invoiceHash, invoice: base64} onto fatoora-clearance (Standard — the buyer has a VAT) or fatoora-reporting (Simplified — no VAT). After release the envelope is Issued. Cleared/Reported is set by ApplyChannelOutcomesAsync: the ApplyFatooraOutcomes job every minute and a piggyback on the next posting. IsMockReject on the connection still rejects immediately. A Standard invoice (InvoiceType) must not be handed to the buyer until the envelope is Cleared: ISaudiEInvoice.BuyerReleaseBlockAsync. The print forms ZatcaInvoiceZr / ZatcaCreditNoteZr / ZatcaDebitNoteZr call it directly. InvoiceZr / CreditNoteZr / DebitNoteZr call the country-blind IEInvoiceRelease (Tax; SA wraps it and delegates to ISaudiEInvoice) — Sales does not depend on SA. A Simplified one is handed over at once.

The 500-character limit and the cryptographic stamp collide in practice. Tags 2–9 are all but fixed and leave about 30 bytes for the seller's name, while Arabic takes two bytes per character. ZatcaQr truncates tag 1: tags 7–9 cannot be cut (the signature would stop verifying), and throwing an exception is even less acceptable — the caller is in the middle of the posting, the envelope is already saved and the ICV is spent. Whether a truncated seller name is permitted by the specification is not recorded in our sources; the other two options are wrong for certain.

Onboarding#

Four web services of the model plus the bundle of the six kinds, all isDevOnly; CSR / Compliance CSID / sample invoices / Production CSID also carry requireExplicitRoles — the first two and the last hand out a key or a secret, and the sample invoices give access to Fatoora:

  • POST /api/rest/ZatcaCsr — PKCS#10 + PKCS#8. Sandbox: PREZATCA-Code-Signing; simulation/production: ZATCA-Code-Signing.
  • POST /api/rest/ZatcaCompliance — the OTP as a header on this call. It deliberately does not go into the signing profile: a signed and logged OTP would be replayed, and it is single-use. Channel fatoora-compliance.
  • POST /api/rest/ZatcaComplianceInvoices — one sample invoice (uuid + invoiceHash + XML) onto the fatoora-compliance-invoices channel.
  • POST /api/rest/ZatcaComplianceSamples — all six kinds (standard/simplified × invoice/credit/debit) in one call. Synthetic UBL: the QR is embedded, XAdES when there is a PEM CSID; it does not touch the live ICV. The live Fatoora service will not accept them without a real CSID; the stub on the stand accepts all six. The ERP document SalesDebitNote (UBL 383) is a separate path, not these samples.
  • POST /api/rest/ZatcaProductionCsid — Basic credentials from the Compliance CSID (fatoora-csid), channel fatoora-production-csid. On live Fatoora the compliance sample invoices have to come first.

The exchange does not go through the queue: the answer carries a single-use secret, an outbox row would log it, and a retry an hour later would present a spent OTP. So onboarding calls IOutboundCall — a synchronous call by CHANNEL NAME, with the URL and the credential resolved by the host.

The secret from the answer is carried over by the operator into Integration__Credentials__fatoora-csid or fatoora-pcsid as token:secret. It does not go into git.

On the docker stand the fatoora channels are overridden by the configuration and point at api/rest/FatooraStub*. That is by design: the configuration wins over the model's metadata, and it is the only way to redirect a stand without editing someone else's model. Hence the rule — the platform's appsettings.json must not hold a single business channel, or it will shadow the model for ever.

There is no live HTTPS to gw-fatoora.zatca.gov.sa from CI.

Known defects#

Checked against the code, not against comments.

Open#

# Defect How it shows up
1 XAdES C14N is 1.0, not 1.1; the live Fatoora schematron has not been run The ZATCA SDK writes Algorithm C14N 1.1. Our IXmlCanonicalizer is XmlDsigC14NTransform (1.0); SignedInfo names 1.0 so that the URI and the digest agree. gw-fatoora acceptance still needs a live schematron run

The submission outcome is written by Tax, not by the country package#

ApplyChannelOutcomesAsync does not set the subtype and does not write the journal itself — it calls ITaxDocumentOutcome.ApplyAsync. What is left with Saudi is exactly what belongs to it: which channel means Cleared and which means Reported.

If every package applied the outcome itself, nobody would fill in TaxDocument.Authority and TaxDocument.RejectionReason — they would stay declared and dead. The reason for a refusal would live only in TaxSubmission and in the queue row, and an accountant who opened a rejected envelope would see a subtype without a single word of why.

The write order is essential. Cleared and Reported are isReadOnly, and the guard lets through only Subtype and StatusValue. So the fields are written BEFORE the transition, while the envelope is still Issued. Reverse this order and no exception is thrown — Authority simply stays silently empty on every accepted envelope.

Idempotency rests on the subtype, not on a flag: Cleared/Reported/Rejected can be entered only from Issued, and the queue delivers at least once. An over-long reason is truncated to the column — losing the whole outcome because of it would be worse; the full text stays in the queue row.

InDoubt is NOT acknowledged: the request went onto the wire, no answer came back, and acknowledging such a message would mean hiding it instead of investigating it.

Test: TaxDocumentOutcomeTest.

XML, posting and print rules#

Do not do the left-hand thing. The right-hand column is the live contract.

Trap What the code does
invoice date as DateTime.UtcNow DocumentDate; the day is taken from the document and the time from the moment of release, because DocumentDate has no time of day and ZATCA requires IssueTime
cbc:CityName from Address.Name (the address line) the city from the City dictionary
the buyer given the seller's country code the country is resolved for each party
header VAT ≠ the sum of the lines' VAT the total is accumulated FROM THE LINES — both in the XML and on the print form
precision 2 and currency SAR hardcoded AmountScale; quantity and percentage keep two decimals
envelope built in OnAfterSave — tax in every XML is 0.00 the envelope is built in OnAfterPost. TaxRateApplied is stamped by the Sales handler in OnBeforePost; build earlier and the rate is still zero
credit note with no QR/UUID/hash, handler on OnAfterSave the same five fields as on an invoice; the stamp in OnAfterPost; a draft creates no envelope
Cleared/Reported subtype from MOCK-OK the envelope stays Issued; ApplyChannelOutcomesAsync reads TakeCompletedAsync on fatoora-clearance/fatoora-reporting. IsMockReject still rejects without a channel
empty catch on the enqueue IZuloOneLogger<SaudiEInvoice>.Warn
Standard/Simplified by CustomerType by whether Customer.TaxRegistrationNumber is present
tax category hardcoded to S S when the rate > 0, Z at zero without a tax computation; TaxCategory.Treatment STANDARD→S, ZERO_RATED→Z, EXEMPT→E, OUT_OF_SCOPE→O, REVERSE_CHARGE→S. E/O carry TaxExemptionReasonCode/TaxExemptionReason from TaxCategory.LocalizationSaudiArabia
no job for ApplyChannelOutcomesAsync ApplyFatooraOutcomes (* * * * *, executeSingle); the piggyback on the next posting remains
no compliance sample invoices the fatoora-compliance-invoices channel; the six kinds — IZatcaComplianceSamples + POST /api/rest/ZatcaComplianceSamples; debit only as onboarding UBL
a Standard invoice handed to the buyer while Issued BuyerReleaseBlockAsync: Standard waits for Cleared; Simplified goes at once; ZatcaInvoiceZr throws until the block is lifted
InvoiceZr bypasses the block (Sales does not call ISaudiEInvoice) InvoiceZr / CreditNoteZr / DebitNoteZr call IEInvoiceRelease; the SA wrapper delegates to BuyerReleaseBlockAsync
no ERP debit-note document SalesDebitNote Draft→Posted: additional VAT (Receivable+, VatPayable+), UBL 383 / DebitedQuantity; the lines are not copied from the invoice; EnsureForDebitNoteAsync
credit/debit note with no BillingReference cac:BillingReference on the ID and the UUID of the original invoice; PaymentMeans/InstructionNote CANCELLATION / ADDITIONAL_CHARGES — as in the compliance samples
credit/debit note with no ZATCA tax print form ZatcaCreditNoteZr / ZatcaDebitNoteZr: the QR and Annex 2, the same BuyerReleaseBlockAsync
original invoice missing from the notes' commercial print form CreditNoteZr / DebitNoteZr put OriginalInvoice into Notes; the layout has an Original invoice line

Specifications (official ZATCA PDFs / pages)#

Document Link
The Data Dictionary + XML Standard hub (page updated 12 Jan 2026) E-Invoice specifications
XML Implementation Standard (19 May 2023) PDF
QR TLV (tags 1–9, Base64, ≤500 characters) QRCodeCreation.pdf
The resolution / the requirements for a solution Implementation Resolution
The technical guideline Detailed Technical Guideline
Developer Portal manual
Fatoora portal (the API list) manual
Offline SDK (the fatoora CLI, sample XML) Download SDK
The developers' forum zatca1.discourse.group

Test and production servers#

Environment UI
Developer / Integration Sandbox sandbox.zatca.gov.sa
Simulation fatoora.zatca.gov.sa
Production the same Fatoora, ERAD login

ZATCA documents different API base addresses for each environment (.../developer-portal, .../simulation, .../core), but on a stand all five Saudi OutboundChannel objects (fatoora-clearance, fatoora-reporting, fatoora-compliance, fatoora-compliance-invoices, fatoora-production-csid) declare the same baseUrl (https://gw-fatoora.zatca.gov.sa/e-invoicing/developer-portal) and differ only by path. The environment is not part of the URL. It is a logical field, TaxAuthorityConnection.Environment (Sandbox/Simulation/Production). Switching the environment in production means editing the channel baseUrl/path for the specific stand, not choosing a row from this table. The paths from the portal manual are compliance, compliance/invoices, production/csids, invoices/reporting/single, invoices/clearance/single.

A CSID from one environment does not carry over to another. The OTP and the certificate do not go into git — only Integration__Credentials__<name>.

Checking the XML/QR offline, without a network: the SDK. We do not call live HTTPS from our CI.

Same topic for other readers: Analyst · User

Where next#

Was this page helpful?