Who this is for
If you can run Docker, you can deploy this yourself in 15 minutes.
ERP integration teams
You have an ERP that produces invoices and need to add ZATCA compliance without licensing a third-party platform.
SME developers
You're building an app for a Saudi business and don't want to spend weeks implementing ZATCA from scratch.
Accounting firms
You handle e-invoicing for multiple clients and want a self-hostable solution that keeps each client's data separate.
Self-hosters and tinkerers
You have basic computer knowledge โ Docker, a terminal, a YAML file โ and prefer to run things yourself rather than depend on a vendor.
What you get
A complete ZATCA Phase 2 pipeline that you can run yourself in 15 minutes.
JSON โ UBL XML
Take a JSON invoice, get ZATCA-compliant UBL 2.1 XML back. Schema-driven, no hand-coding.
Full Onboarding Over HTTP
CSR generation, Compliance CSID, all 6 compliance checks, Production CSID โ as HTTP routes.
XAdES-BES Signing
Sign with your ZATCA-issued CSID using the official SDK. Canonicalisation and hashing handled.
Submission to Fatoora
Sandbox, Simulation, and Production endpoints. Clearance and Reporting APIs both supported.
Credit & Debit Notes
Types 381 and 383 handled with the required BillingReference and InstructionNote elements.
Local Audit Trail
Every submission logged with full request and response in a MySQL table. Query it any time.
One URL, whole pipeline
Every step โ onboarding through submission โ runs through a single base URL.
http://localhost:5000
| Step | Call |
|---|---|
| Generate CSR and private key | POST /zatca/onboard/csr |
| Exchange OTP for Compliance CSID | POST /zatca/onboard/compliance |
| Run one compliance check | POST /zatca/onboard/check |
| Request Production CSID | POST /zatca/onboard/production |
| Submit an invoice | POST /zatca/submit |
The problem this solves
ZATCA requires every B2B and B2C invoice in Saudi Arabia to be issued as signed UBL XML, submitted through their gateway. Building this from scratch means weeks of work: XAdES-BES signing, C14N canonicalisation, invoice hash chains, QR code TLV encoding, and hundreds of validation rules most developers never see until they fail.
Paid SaaS platforms charge per invoice or per month. Existing libraries cover only parts of the problem. Most teams just need a working pipeline that handles the whole flow and stays under their control.
This is that pipeline. Free, open source, and deployed on your own hardware.
Architecture โ two parts
The project is split into two halves. They can run separately or together. Together they form the complete solution.
Proof it works
The pipeline has been run end-to-end against ZATCA's Sandbox and
Simulation gateways, using a real Saudi Taxpayer TIN. All six
compliance checks passed, a Production CSID was issued, and the production
clearance endpoint returned CLEARED.
{
"ok": true,
"invoiceid": "INV001",
"environment": "simulation",
"http_status": 200,
"outcome": "SUBMITTED",
"summary": {
"status": "PASS",
"clearance": "CLEARED",
"reporting": null,
"errors": [],
"warnings": [],
"duplicate": false
},
"response": {
"validationResults": {
"infoMessages": [{
"category": "XSD validation",
"code": "XSD_ZATCA_VALID",
"message": "Complied with UBL 2.1 standards in line with ZATCA specifications",
"status": "PASS"
}],
"warningMessages": [],
"errorMessages": [],
"status": "PASS"
},
"clearanceStatus": "CLEARED"
}
}
Not yet tested: ZATCA Production. Same code paths as Simulation;
requires onboarding against /e-invoicing/core with a Production
Taxpayer TIN.
| Environment | Status |
|---|---|
| Sandbox | โ Full pipeline works. CLEARED. |
| Simulation | โ Full onboarding verified. CCSID, all 6 checks, PCSID, production clearance endpoint CLEARED. |
| Production | โฌ Not yet tested โ needs a real Taxpayer TIN. |
Quick Start
Option A โ Hosted endpoint only
If all you need is JSON โ XML conversion, no setup is required:
curl -X POST https://ubl.keytouse.com/zatca/xml \
-H "Content-Type: application/json" \
-d @your_invoice.json \
-o invoice.xml
Option B โ Full local stack
For onboarding, signing, submission, and invoice history:
Prerequisites
- Docker or Podman
- ~2 GB free disk space
- Outbound HTTPS to
gw-fatoora.zatca.gov.saandubl.keytouse.com
1. Extract the application files
mkdir zatca-local && cd zatca-local
docker run --rm --entrypoint tar \
keytouse/ubl_zatca:ktuzatca-flask-app-beta-1.1 \
-cf - -C /app \
--exclude=jdk11 --exclude=ZatcaSDK --exclude=__pycache__ \
. | tar -xf -
2. Create docker-compose.yml
The complete compose file is on the
GitHub repo
and the
Docker Hub page.
Change MYSQL_ROOT_PASSWORD before deploying.
3. Start the stack
docker compose up -d
4. Verify
curl http://localhost:5000/hi
# -> Hello there!
The full pipeline, step by step
From "nothing" to "cleared invoice at ZATCA." The examples use Simulation.
Sandbox uses the same flow with the fixed OTP 123456.
Stage 1 โ Generate a CSR and private key
curl -X POST http://localhost:5000/zatca/onboard/csr \
-H "Content-Type: application/json" \
-d '{"env": "simulation", "config": "/app/credentials/csr-config-simulation.properties"}'
Each environment uses a different certificate template:
Sandbox TSTZATCA-Code-Signing,
Simulation PREZATCA-Code-Signing,
Production ZATCA-Code-Signing.
Stage 2 โ Exchange CSR + OTP for a Compliance CSID
Requires an OTP from the Fatoora portal's Onboarding section. Valid for 24 hours.
curl -X POST http://localhost:5000/zatca/onboard/compliance \
-H "Content-Type: application/json" \
-d '{"otp": "<YOUR_OTP>", "env": "simulation"}'
Stage 3 โ Extract the certificate
ZATCA returns the certificate double-encoded. Decode once and save to the mounted
cert.pem:
python3 << 'EOF'
import json, base64
d = json.load(open('credentials/compliance_csid.json'))
inner = base64.b64decode(d['binarySecurityToken']).decode('utf-8')
with open('credentials/cert.pem', 'w') as f:
f.write(inner)
print("cert.pem:", len(inner), "bytes")
EOF
Stage 4 โ Run the six compliance checks
| # | Scenario | Code | Name |
|---|---|---|---|
| 1 | Standard Tax Invoice | 388 | 0100000 |
| 2 | Standard Credit Note | 381 | 0100000 |
| 3 | Standard Debit Note | 383 | 0100000 |
| 4 | Simplified Tax Invoice | 388 | 0200000 |
| 5 | Simplified Credit Note | 381 | 0200000 |
| 6 | Simplified Debit Note | 383 | 0200000 |
curl -X POST -F "file=@invoice.json" http://localhost:5000/upload
curl -X POST http://localhost:5000/zatca/submit \
-H "Content-Type: application/json" \
-d '{"invoiceid": "INV001"}'
Credit and Debit notes require cac:BillingReference and
cac:PaymentMeans/cbc:InstructionNote. The remote generator adds
these automatically based on InvoiceTypeCode.
Stage 5 โ Request the Production CSID
curl -X POST http://localhost:5000/zatca/onboard/production \
-H "Content-Type: application/json" \
-d '{"env": "simulation"}'
Returns a new binarySecurityToken and secret. The route
writes only the cert โ the private key is not touched.
Stage 6 โ Submit with the Production CSID
/zatca/submit auto-detects cert type: uses
production_csid.json if present, otherwise compliance_csid.json.
No code change needed.
podman exec -i ktuzatca-mysql-1 mysql -uroot -pTheRoot@Pass -t keytouse_zatca << 'EOF'
SELECT ID, InvoiceID, HTTPStatus, ZATCAStatus, ClearanceStatus, SubmittedAt
FROM SubmissionLog ORDER BY ID DESC LIMIT 5;
EOF
API Reference
Local stack
| Endpoint | Method | Purpose |
|---|---|---|
/hi | GET | Health check |
/upload | POST | Upload invoice.json |
/zatca/submit | POST | Sign, validate, submit to ZATCA |
/zatca/onboard/csr | POST | Generate CSR + private key |
/zatca/onboard/compliance | POST | OTP โ Compliance CSID |
/zatca/onboard/check | POST | Submit one compliance test invoice |
/zatca/onboard/production | POST | CCSID โ Production CSID |
/invoiceid?id=INV001 | GET/POST | Retrieve or store invoice by ID |
/deleteinvoice?id=INV001 | DELETE | Remove a specific invoice |
/deleteall | GET | Remove all invoices |
Hosted endpoint (remote)
| Endpoint | Response | Purpose |
|---|---|---|
/zatca | JSON | Returns {"status":"200","message":"<xml>"} |
/zatca/xml | Raw XML | Same input; returns XML directly |
/xmlgen?invoiceid=INV001 | Raw XML (GET) | Quick browser view |
Sample JSON Payload
The data field must contain a Config array and all
invoice sections. Below is a complete example.
[
{
"requesttype": "setdata",
"data": {
"Invoice": [
{
"ID": "INV001",
"ProfileID": "reporting:1.0",
"UUID": "8e6f8f2b-6e6a-4a4a-9c1c-1234567890ab",
"IssueDate": "2025-09-15",
"IssueTime": "14:30:00",
"InvoiceTypeCode": "388",
"Note": "Sample invoice note",
"DocumentCurrencyCode": "SAR",
"TaxCurrencyCode": "SAR",
"CreatedAt": "2025-09-15T14:30:00Z"
}
],
"AdditionalDocumentReference": [
{
"InvoiceID": "INV001",
"ID": "ICV",
"UUID": null,
"EmbeddedDocumentBinaryObject": null
},
{
"InvoiceID": "INV001",
"ID": "PIH",
"UUID": "1",
"EmbeddedDocumentBinaryObject": "NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ=="
},
{
"InvoiceID": "INV001",
"ID": "QR",
"UUID": "1",
"EmbeddedDocumentBinaryObject": "AW/YtNix2YPYqSDYqtmI2LHZitivINin2YTYqtmD2YbZiNmE2YjYrNmK2Kcg2KjYo9mC2LXZiSDYs9ix2LnYqSDYp9mE2YXYrdiv2YjYr9ip"
}
],
"Signature": [
{
"InvoiceID": "INV001",
"ID": "urn:oasis:names:specification:ubl:signature:Invoice",
"SignatureMethod": "urn:oasis:names:specification:ubl:dsig:enveloped:xades"
}
],
"AccountingParty": [
{
"InvoiceID": "INV001",
"ID": "CUST001",
"PartyIdentification": "6534565243524",
"SchemeID": "IQA",
"PartyType": "CUSTOMER",
"RegistrationName": "Customer Name"
},
{
"InvoiceID": "INV001",
"ID": "SUPP001",
"PartyIdentification": "1010010000",
"SchemeID": "CRN",
"PartyType": "SUPPLIER",
"RegistrationName": "Supplier Company Name"
}
],
"AccountingPartyAddress": [
{
"AccountingPartyID": "CUST001",
"ID": "CustomerAddress1",
"InvoiceID": "INV001",
"StreetName": "Choburji Chowk",
"BuildingNumber": "5472",
"CitySubdivisionName": "Ichra more",
"CityName": "Lahore",
"PostalZone": "54788",
"CountryID": "SA"
},
{
"AccountingPartyID": "SUPP001",
"ID": "SupplierAddress1",
"InvoiceID": "INV001",
"StreetName": "123 Supplier St",
"BuildingNumber": "5847",
"CitySubdivisionName": "Downtown",
"CityName": "Riyadh",
"PostalZone": "12415",
"CountryID": "SA"
}
],
"AccountingPartyTaxScheme": [
{
"AccountingPartyID": "CUST001",
"ID": "CustomerTaxScheme1",
"InvoiceID": "INV001",
"CompanyID": "300000000000003",
"TaxSchemeCode": "VAT"
},
{
"AccountingPartyID": "SUPP001",
"ID": "SupplierTaxScheme1",
"InvoiceID": "INV001",
"CompanyID": "300000000000004",
"TaxSchemeCode": "VAT"
}
],
"Delivery": [
{
"InvoiceID": "INV001",
"AccountingPartyID": "CUST001",
"ActualDeliveryDate": "2025-09-15"
}
],
"PaymentMeans": [
{
"InvoiceID": "INV001",
"AccountingPartyID": "SUPP001",
"PaymentMeansCode": "30"
}
],
"AllowanceCharge": [
{
"InvoiceID": "INV001",
"ID": "1",
"ChargeIndicator": "false",
"AllowanceChargeReason": "discount",
"Amount": 0.0,
"CurrencyID": "SAR"
}
],
"AllowanceTaxCategory": [
{
"AllowanceChargeID": "1",
"ID": "ATC1",
"InvoiceID": "INV001",
"SchemeID": "UN/ECE 5305",
"SchemeAgencyID": "6",
"Percent": 15.0,
"TaxCategoryCode": "S"
}
],
"AllowanceTaxCategoryScheme": [
{
"AllowanceTaxCategoryID": "ATC1",
"ID": "ATCS1",
"InvoiceID": "INV001",
"SchemeID": "UN/ECE 5153",
"SchemeAgencyID": "6",
"TaxSchemeCode": "VAT"
}
],
"TaxTotal": [
{
"InvoiceID": "INV001",
"ID": "TT1",
"TaxAmount": 0.6,
"CurrencyID": "SAR"
}
],
"TaxSubTotal": [
{
"TaxTotalID": "TT1",
"ID": "TST1",
"InvoiceID": "INV001",
"TaxableAmount": 4.0,
"TaxAmount": 0.6,
"CurrencyID": "SAR"
}
],
"TaxSubCategory": [
{
"TaxSubTotalID": "TST1",
"ID": "TSC1",
"InvoiceID": "INV001",
"TaxCategoryCode": "S",
"SchemeID": "UN/ECE 5305",
"SchemeAgencyID": "6",
"Percent": 15.0
}
],
"TaxSubCategoryScheme": [
{
"TaxSubCategoryID": "TSC1",
"ID": "TSCS1",
"InvoiceID": "INV001",
"TaxSchemeCode": "VAT",
"SchemeID": "UN/ECE 5153",
"SchemeAgencyID": "6"
}
],
"LegalMonetaryTotal": [
{
"InvoiceID": "INV001",
"LineExtensionAmount": 4.0,
"LineExtensionCurrencyID": "SAR",
"TaxExclusiveAmount": 4.0,
"TaxExclusiveCurrencyID": "SAR",
"TaxInclusiveAmount": 4.6,
"TaxInclusiveCurrencyID": "SAR",
"AllowanceTotalAmount": 0.0,
"AllowanceCurrencyID": "SAR",
"PrepaidAmount": 0.0,
"PrepaidCurrencyID": "SAR",
"PayableAmount": 4.6,
"PayableCurrencyID": "SAR"
}
],
"InvoiceLine": [
{
"InvoiceID": "INV001",
"ID": "L1",
"InvoicedQuantity": 2.0,
"UnitCode": "PCE",
"LineExtensionAmount": 4.0,
"LineExtensionCurrencyID": "SAR"
}
],
"InvoiceLineTaxTotal": [
{
"LineID": "L1",
"ID": "ILTT1",
"InvoiceID": "INV001",
"TaxAmount": 0.6,
"TaxAmountCurrencyID": "SAR",
"RoundingAmount": 4.6,
"RoundingAmountCurrencyID": "SAR"
}
],
"InvoiceLineItem": [
{
"LineID": "L1",
"ID": "LI1",
"InvoiceID": "INV001",
"Name": "Apple Juice 100% organic"
}
],
"InvoiceLineTaxCategory": [
{
"InvoiceLineItemID": "LI1",
"ID": "ILTC1",
"InvoiceID": "INV001",
"Percent": 15.0,
"TaxCategoryCode": "S"
}
],
"InvoiceLineTaxScheme": [
{
"InvoiceLineTaxCategoryID": "ILTC1",
"ID": "ILTS1",
"InvoiceID": "INV001",
"TaxSchemeCode": "VAT"
}
],
"InvoiceLinePrice": [
{
"LineID": "L1",
"ID": "LP1",
"InvoiceID": "INV001",
"PriceAmount": 2.0,
"PriceAmountCurrencyID": "SAR"
}
],
"InvoiceLineAllowanceCharge": [
{
"InvoiceLinePriceID": "LP1",
"ID": "LAC1",
"InvoiceID": "INV001",
"ChargeIndicator": "true",
"AllowanceChargeReason": "Straight Discount",
"Amount": 0.0,
"CurrencyID": "SAR"
}
]
}
}
]
Rules worth knowing
1. PaymentMeans.AccountingPartyID references the customer
Not the supplier. Otherwise:
1EQ: Error: PaymentMeans should have at least one row.
2. The server adds the second TaxTotal automatically
BR-KSA-EN16931-09 requires a second bare TaxTotal when
TaxCurrencyCode is present. Send just one โ the server handles it.
3. Supplier VAT must be 15 digits, starting and ending with 3
Example: 311111111111113. Otherwise:
BR-KSA-40: seller VAT registration number must contain 15 digits.
The first and the last digits are "3".
4. Credit and Debit notes need a reason
BR-KSA-17 and BR-KSA-56 require cac:BillingReference and
cac:PaymentMeans/cbc:InstructionNote. Both are added by the
remote generator from the invoice's Note field.
Duplicate invoice responses (208 / 409)
ZATCA added a runtime duplicate check in 2025. Submitting the same invoice hash within 24 hours returns:
- 208 (Clearance) โ hash previously submitted. Response includes the original cleared invoice.
- 409 (Reporting) โ invoice was already reported successfully.
Both are success indicators, not failures. The
reportingStatus: NOT_REPORTED field in a 409 body is a response
artifact โ the invoice was accepted on the first submission.
Sandbox does not enforce this check. It only applies to Simulation and Production.
How this compares
| This solution | Build from scratch | Paid SaaS | |
|---|---|---|---|
| Setup time | ~15 minutes | Weeks | Hours |
| Cost | Free | Developer time | Per-invoice or monthly fees |
| Where invoice data lives | Your network | Your network | Their servers |
| Customizable | Yes (MIT) | Yes | Limited |
| Signing included | Built in | Build it yourself | Built in |
| Support | Community | None | Vendor |
Frequently asked questions
Do I need a ZATCA account to use this?
For the hosted JSON โ XML endpoint, no. For the full pipeline (onboarding, signing, submission), yes โ you need a Taxpayer TIN and access to the Fatoora portal.
Is this production ready?
The full pipeline is verified against Sandbox and Simulation, including the production clearance endpoint within Simulation. Production onboarding is a separate step requiring a real Taxpayer TIN and their explicit consent.
Do you store my invoices?
No. The remote is stateless โ it holds only the in-flight invoice for up to 24 hours. Your invoice data stays on your own server in your own MySQL database.
Can I run the whole thing myself, without calling the remote?
Not yet. The remote XML generator isn't open source. The local stack depends on it for the XML-generation step.
What if I find a bug?
Open an issue on the GitHub repo. Best-effort maintenance by a solo developer.
How is this free?
Open source, MIT-licensed. The hosted endpoint runs at minimal cost. No accounts, no tracking, no paid tier.
What's tested, what isn't
Working today
- Full local stack: upload โ DB insert โ remote XML โ local validation
- CSR generation and Compliance CSID onboarding
- XAdES-BES signing with a real ZATCA-issued certificate
- Submission to Sandbox โ
CLEARED - Submission to Simulation โ full onboarding verified: CCSID, 6 checks, PCSID, production clearance endpoint returns
CLEARED - Credit Notes (381) and Debit Notes (383)
- Duplicate invoice handling (208 / 409)
- Audit log with full request and response
Not yet tested
- Submission to Production โ requires a Production Taxpayer TIN
- Real-world invoice variety beyond the sample
What this is not
- Not a ZATCA SDK replacement. We use their SDK.
- Not a billing system. No invoicing history beyond the local database.
- Not multi-tenant. The remote is single-invoice.
- Not tied to Saudi Arabia only. The UBL layer is generic; ZATCA-specific rules are what this implements.