🧾 ZATCA / Fatoora — sources and test stands
Updated
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. Channelfatoora-compliance.POST /api/rest/ZatcaComplianceInvoices— one sample invoice (uuid+invoiceHash+ XML) onto thefatoora-compliance-invoiceschannel.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 documentSalesDebitNote(UBL 383) is a separate path, not these samples.POST /api/rest/ZatcaProductionCsid— Basic credentials from the Compliance CSID (fatoora-csid), channelfatoora-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) | |
| 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