ZATCA E-Invoicing,
end to end

JSON invoices in. Signed, ZATCA-cleared UBL XML out.
Free and open. Runs on your network.

Free No signup No fees Open source ยท MIT
โœ… Verified against ZATCA Sandbox & Simulation ๐Ÿณ Docker image on Docker Hub ๐Ÿ”’ Runs entirely on your infrastructure โš– MIT license โ€” use it commercially

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.

Do it yourself. No signup, no account, no sales call. Pull the image, follow the README, and you'll be generating XML in minutes.

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 keyPOST /zatca/onboard/csr
Exchange OTP for Compliance CSIDPOST /zatca/onboard/compliance
Run one compliance checkPOST /zatca/onboard/check
Request Production CSIDPOST /zatca/onboard/production
Submit an invoicePOST /zatca/submit
No terminal. No SDK commands. No manual certificate handling. Any HTTP client can drive the entire flow โ€” your ERP, a CI script, a Python notebook, a browser extension. If it can POST, it can onboard. The only human step is generating the OTP from the Fatoora portal.

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.

LOCAL (Docker image โ€” runs on your network) โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ Flask server + MySQL โ”‚ โ”‚ โ”‚ โ”‚ โ€ข Receives invoice JSON (HTTP POST) โ”‚ โ”‚ โ€ข Stores in local MySQL (permanent history) โ”‚ โ”‚ โ€ข Calls remote for XML generation โ”‚ โ”‚ โ€ข Validates XML with official ZATCA SDK โ”‚ โ”‚ โ€ข Signs with your CSID (XAdES-BES) โ”‚ โ”‚ โ€ข Submits to Fatoora gateway โ”‚ โ”‚ โ€ข Records every submission in SubmissionLog โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ HTTPS: JSON out, UBL XML in โ–ผ REMOTE (ubl.keytouse.com โ€” free, public) โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ Stateless XML generator โ”‚ โ”‚ โ”‚ โ”‚ โ€ข Takes JSON, returns UBL XML โ”‚ โ”‚ โ€ข Single-invoice design โ”‚ โ”‚ โ€ข No permanent storage โ”‚ โ”‚ โ€ข Free, no account, no signup โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
What runs where. The remote generates the XML. The local stack does everything else โ€” storage, validation, signing, submission, audit. Your invoices never persist on the remote.

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.

POST /zatca/submit โ€” response received from ZATCA Simulation:
{
  "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.

EnvironmentStatus
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
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
#ScenarioCodeName
1Standard Tax Invoice3880100000
2Standard Credit Note3810100000
3Standard Debit Note3830100000
4Simplified Tax Invoice3880200000
5Simplified Credit Note3810200000
6Simplified Debit Note3830200000
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.

Every submission is logged. Query the audit trail with:
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
Production onboarding is separate. Simulation runs without affecting real tax records. Production onboarding requires explicit Taxpayer consent and files real legal invoices.

API Reference

Local stack

EndpointMethodPurpose
/hiGETHealth check
/uploadPOSTUpload invoice.json
/zatca/submitPOSTSign, validate, submit to ZATCA
/zatca/onboard/csrPOSTGenerate CSR + private key
/zatca/onboard/compliancePOSTOTP โ†’ Compliance CSID
/zatca/onboard/checkPOSTSubmit one compliance test invoice
/zatca/onboard/productionPOSTCCSID โ†’ Production CSID
/invoiceid?id=INV001GET/POSTRetrieve or store invoice by ID
/deleteinvoice?id=INV001DELETERemove a specific invoice
/deleteallGETRemove all invoices

Hosted endpoint (remote)

EndpointResponsePurpose
/zatcaJSONReturns {"status":"200","message":"<xml>"}
/zatca/xmlRaw XMLSame input; returns XML directly
/xmlgen?invoiceid=INV001Raw 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:

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 minutesWeeksHours
CostFreeDeveloper timePer-invoice or monthly fees
Where invoice data livesYour networkYour networkTheir servers
CustomizableYes (MIT)YesLimited
Signing includedBuilt inBuild it yourselfBuilt in
SupportCommunityNoneVendor

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
Not yet tested
What this is not