ZATCA-compliant e-invoicing solution.
253
A tool for generating ZATCA-compliant UBL XML invoices from JSON, signing them, and submitting them to the Fatoora gateway. Full onboarding, signing, submission, and audit logging — all over HTTP.
| Hosted API | Self-hosted stack | |
|---|---|---|
| Setup | None | Docker, ~15 minutes |
| JSON → UBL XML | ✅ | ✅ |
| Onboarding (CSR, CSID, PCSID) | — | ✅ |
| Signing (XAdES-BES) | — | ✅ with your CSID |
| Submission to Fatoora | — | ✅ Sandbox / Simulation / Prod |
| Local invoice history | — | ✅ MySQL on your network |
| Local ZATCA SDK validation | — | ✅ fatoora CLI bundled |
| Invoice data stays on your network | — | ✅ Only the XML request leaves |
If all you need is JSON → XML, the hosted endpoint alone is enough.
If you want the full pipeline — onboarding, signing, submission, and audit — deploy this image.
The full solution uses both. The local stack orchestrates everything; the hosted endpoint performs the stateless XML conversion.
| Environment | Status |
|---|---|
| ZATCA Sandbox | ✅ Full pipeline works. clearanceStatus: CLEARED. |
| ZATCA Simulation | ✅ Full onboarding verified: Compliance CSID obtained, all 6 compliance checks passed, Production CSID issued, production clearance endpoint returns CLEARED. |
| ZATCA Production | ⬜ Not yet tested. Same code paths as Simulation; requires onboarding against /e-invoicing/core with a real Taxpayer TIN. |
ZATCA compliance has two separate concerns:
| Concern | Where it runs | Why |
|---|---|---|
| XML generation | Remote | Stateless, single-invoice — nothing is retained |
| Everything else | Local | Onboarding, storage, validation, signing, submission, audit |
The remote holds one invoice globally at any time. Each new upload replaces the previous one.
SubmissionLogYour invoice data persists locally. The remote sees only the in-flight payload.
Send your JSON invoice to the hosted endpoint and save the XML:
curl -X POST https://ubl.keytouse.com/zatca/xml \
-H "Content-Type: application/json" \
-d @your_invoice.json \
-o invoice.xml
The request body must be a list containing one object with requesttype: "setdata" and a data field. See Sample JSON payload below.
https://ubl.keytouse.com and gw-fatoora.zatca.gov.samkdir 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 -
This copies app.py, zatca_client.py, InsertIntoDB.py, DeleteInvoice.py, xmlToSDK.py, required_invoice_elements.json, schema.sql, and supporting files into the current directory.
docker-compose.ymlservices:
flask-app:
image: keytouse/ubl_zatca:ktuzatca-flask-app-beta-1.1
container_name: ktuzatca-flask-app
ports:
- "5000:5000"
environment:
- ZATCA_ENV=simulation
volumes:
- ./app.py:/app/app.py:ro
- ./zatca_client.py:/app/zatca_client.py:ro
- ./InsertIntoDB.py:/app/InsertIntoDB.py:ro
- ./DeleteInvoice.py:/app/DeleteInvoice.py:ro
- ./xmlToSDK.py:/app/xmlToSDK.py:ro
- ./required_invoice_elements.json:/app/required_invoice_elements.json:ro
- ./credentials:/app/credentials
- ./credentials/cert.pem:/app/ZatcaSDK/Data/Certificates/cert.pem
- ./credentials/private-key.pem:/app/ZatcaSDK/Data/Certificates/ec-secp256k1-priv-key.pem
networks:
- my_network
depends_on:
- mysql
mysql:
image: keytouse/ubl_zatca:mysql-8.0.39
container_name: ktuzatca-mysql-1
environment:
MYSQL_ROOT_PASSWORD: TheRoot@Pass
MYSQL_DATABASE: keytouse_zatca
volumes:
- mysql_data:/var/lib/mysql
- ./schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
networks:
- my_network
networks:
my_network:
driver: bridge
volumes:
mysql_data: {}
Before deploying, change MYSQL_ROOT_PASSWORD to your own value. Port 3306 is intentionally not exposed to the host — the database is reachable only from inside the Docker network.
docker compose up -d
Wait ~15 seconds for MySQL to initialize and load the schema.
curl http://localhost:5000/hi
# -> Hello there!
From nothing to a Production CSID, all over HTTP:
curl -X POST http://localhost:5000/zatca/onboard/csr \
-H "Content-Type: application/json" \
-d '{"env": "simulation", "config": "/app/credentials/csr-config-simulation.properties"}'
Returns the CSR path and key path. The key is saved to the mounted credentials/private-key.pem so it survives container restarts.
Each environment uses a different certificate template inside the CSR:
| Environment | CSR template |
|---|---|
| Sandbox | TSTZATCA-Code-Signing |
| Simulation | PREZATCA-Code-Signing |
| Production | ZATCA-Code-Signing |
curl -X POST http://localhost:5000/zatca/onboard/compliance \
-H "Content-Type: application/json" \
-d '{"otp": "<YOUR_OTP>", "env": "simulation"}'
Requires an OTP from the Fatoora portal's Onboarding section. Returns a requestID, binarySecurityToken, and secret. The Compliance CSID is valid for 24 hours.
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
ZATCA requires six test invoices before issuing a Production CSID:
| # | Scenario | InvoiceTypeCode | InvoiceTypeName |
|---|---|---|---|
| 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 |
For each: set the two fields in invoice.json, then:
curl -X POST -F "[email protected]" http://localhost:5000/upload
curl -X POST http://localhost:5000/zatca/submit \
-H "Content-Type: application/json" \
-d '{"invoiceid": "INV001"}'
Expected: status: "PASS" with clearance: "CLEARED" (Standard) or reporting: "REPORTED" (Simplified).
Credit and Debit notes require cac:BillingReference and cac:PaymentMeans/cbc:InstructionNote. The remote generator adds these automatically based on InvoiceTypeCode.
curl -X POST http://localhost:5000/zatca/onboard/production \
-H "Content-Type: application/json" \
-d '{"env": "simulation"}'
Returns a new binarySecurityToken and secret. Saved to credentials/production_csid.json.
The route writes only the cert — the private key is not touched. ZATCA issues the Production CSID for the same key pair as the CCSID.
/zatca/submit auto-detects the cert type: uses production_csid.json if present, otherwise compliance_csid.json. No code change needed.
curl -X POST -F "[email protected]" http://localhost:5000/upload
curl -X POST http://localhost:5000/zatca/submit \
-H "Content-Type: application/json" \
-d '{"invoiceid": "INV001"}'
Every submission is recorded in SubmissionLog:
podman exec -i ktuzatca-mysql-1 mysql -uroot -pTheRoot@Pass -t keytouse_zatca << 'EOF'
SELECT ID, InvoiceID, Environment, HTTPStatus, ZATCAStatus, ClearanceStatus, SubmittedAt
FROM SubmissionLog ORDER BY ID DESC LIMIT 5;
EOF
Everything above runs against Simulation without affecting real tax records. Production onboarding is separate:
"env": "production"Production onboarding affects the taxpayer's tax record. It requires explicit consent.
| Endpoint | Method | Purpose |
|---|---|---|
/hi | GET | Health check — returns Hello there! |
/upload | POST | Upload invoice.json (multipart, field file) |
/zatca/submit | POST | Sign, validate, submit to ZATCA |
/invoiceid?id=INV001 | GET | Retrieve stored invoice data by ID |
/deleteinvoice?id=INV001 | DELETE | Delete a specific invoice from the local DB |
/deleteall | GET | Delete all invoices from the local DB |
| Endpoint | Method | Purpose |
|---|---|---|
/zatca/onboard/csr | POST | Generate CSR + private key |
/zatca/onboard/compliance | POST | OTP → Compliance CSID |
/zatca/onboard/check | POST | Submit one compliance test |
/zatca/onboard/production | POST | CCSID → Production CSID |
All routes are HTTP — no terminal or SDK knowledge required.
All requests are POST with a JSON body. The body is a list containing one request object.
| Endpoint | Response | Purpose |
|---|---|---|
/zatca | JSON envelope | Returns {"status": "200", "message": "<xml or error>"} |
/zatca/xml | Raw XML | Same input as /zatca. On success, returns XML directly with Content-Type: application/xml |
/xmlgen?invoiceid=INV001 | Raw XML (GET) | Quick browser view for manual inspection |
| Request type | Purpose | Required fields |
|---|---|---|
setdata | Upload a full invoice. Generates and returns XML. | data (object) |
getxml | Retrieve the XML for the currently stored invoice. | invoiceid |
getsetdata | Run a SQL query directly (SELECT, INSERT, UPDATE, DELETE, DESCRIBE, SHOW, EXPLAIN). | query |
The data field must contain a Config array and all invoice sections.
[
{
"requesttype": "setdata",
"data": {
"Config": [
{
"InvoiceType": "StandardInvoice",
"CreatedBy": "192.168.1.10",
"Status": "NEW"
}
],
"Invoice": [
{
"ID": "INV001",
"ProfileID": "reporting:1.0",
"UUID": "8e6f8f2b-6e6a-4a4a-9c1c-1234567890ab",
"IssueDate": "2026-09-16",
"IssueTime": "14:30:00",
"InvoiceTypeCode": "388",
"InvoiceTypeName": "0100000",
"Note": "Sample invoice",
"DocumentCurrencyCode": "SAR",
"TaxCurrencyCode": "SAR"
}
],
"AccountingParty": [ /* supplier + customer rows */ ],
"AccountingPartyAddress": [ /* addresses */ ],
"AccountingPartyTaxScheme": [ /* VAT numbers */ ],
"AdditionalDocumentReference": [ /* ICV, PIH, QR */ ],
"Signature": [ /* signature metadata */ ],
"Delivery": [ /* ... */ ],
"PaymentMeans": [ /* ... */ ],
"AllowanceCharge": [ /* ... */ ],
"AllowanceTaxCategory": [ /* ... */ ],
"AllowanceTaxCategoryScheme": [ /* ... */ ],
"TaxTotal": [ /* ... */ ],
"TaxSubTotal": [ /* ... */ ],
"TaxSubCategory": [ /* ... */ ],
"TaxSubCategoryScheme": [ /* ... */ ],
"LegalMonetaryTotal": [ /* ... */ ],
"InvoiceLine": [ /* ... */ ],
"InvoiceLineTaxTotal": [ /* ... */ ],
"InvoiceLineItem": [ /* ... */ ],
"InvoiceLineTaxCategory": [ /* ... */ ],
"InvoiceLineTaxScheme": [ /* ... */ ],
"InvoiceLinePrice": [ /* ... */ ],
"InvoiceLineAllowanceCharge": [ /* ... */ ]
}
}
]
1. PaymentMeans.AccountingPartyID must reference the customer, not the supplier. Otherwise:
1EQ: Error: PaymentMeans should have at least one row.
2. When TaxCurrencyCode is present, the server adds a second bare TaxTotal automatically. You can send just one — the server duplicates it as a bare block. BR-KSA-EN16931-09 compliant without any client-side work.
3. Supplier VAT must be 15 digits, starting and ending with 3. Set AccountingPartyTaxScheme.CompanyID accordingly. Example: 311111111111113. Otherwise:
BR-KSA-40: seller VAT registration number must contain 15 digits.
The first and the last digits are "3".
4. Credit Note (381) and Debit Note (383) require a reason for issuance. The remote generator adds <cac:BillingReference> and <cac:PaymentMeans><cbc:InstructionNote> automatically from the invoice's Note field.
| Status | Code | Meaning |
|---|---|---|
200 | — | Success. |
202 | — | Accepted with warnings. Invoice still cleared. |
208 | — | Clearance duplicate — same hash submitted within 24h. Treated as success. |
400 | 1R–7R | Bad request — missing field, wrong type, unknown requesttype. |
400 | 8R | Database error — usually a missing table or mismatched credentials. |
400 | 1EQ | A required lookup (e.g. PaymentMeans) returned no rows. |
400 | 1GR, 5EQ | MySQL error during query execution. |
408 | — | Connection issue. |
409 | — | Reporting duplicate — already reported within 24h. Treated as success. |
429 | — | Server busy (concurrent request). Retry in ~1 second. |
500 | — | Unhandled exception. |
503 | 3EQ | Could not connect to the database. |
Duplicate handling. ZATCA returns 208 (Clearance) or 409 (Reporting) if the same invoice hash is submitted within 24 hours. Both are success indicators — the invoice was accepted on the first submission. The reportingStatus: NOT_REPORTED field in a 409 body is a response artifact, not a failure. The pipeline records both as DUPLICATE_CLEARED / DUPLICATE_REPORTED, never as errors.
After signing, the pipeline runs the XML through the official ZATCA SDK (238-R3.3.8) using the bundled fatoora CLI. It validates:
If validation fails, the SDK names the exact rule that was violated.
The MySQL image ships with a MariaDB database named keytouse_zatca. Tables map directly to the JSON sections, plus:
SubmissionLog — every submission with full request and responseIPLog — audit trail of every request by client IPInvoiceType — allowed invoice type codes (388, 389, 381, 383, ...)Requests that touch the invoice tables are serialized with a MySQL named lock (zatca_invoice_lock). If two clients submit at the same moment, the second receives 429 Server busy immediately — no blocking, no waiting.
| Variable | Default | Purpose |
|---|---|---|
ZATCA_ENV | sandbox | One of sandbox, simulation, production |
ZATCA_CSID_PATH | /app/credentials/compliance_csid.json | CSID location for compliance requests |
ZATCA_TIMEOUT | 30 | HTTP timeout in seconds |
The /zatca/submit route auto-detects cert type: uses production_csid.json if present, otherwise compliance_csid.json.
TaxTotal on the remote (removes client-side awareness)CLEARED.BillingReference and InstructionNote (BR-KSA-56 and BR-KSA-17)./zatca/submit.generate_csr() copies the private key to a stable mounted path and refuses to run if a CCSID already exists.load_csid() selects cert file by cert type (compliance vs production).ktuzatca-flask-app-beta-1.1 — layers current source over beta-1.0.zatca_client.py, /zatca/submit, /zatca/onboard/* routes.SubmissionLog table.fatoora CLI verified.fatoora tool.Thanks!
𝓎𝓪𝓼𝓲𝓻𝓲𝓶𝓻𝓪𝔶
Content type
Image
Digest
sha256:e43625724…
Size
1.5 GB
Last updated
10 days ago
docker pull keytouse/ubl_zatca:ktuzatca-flask-app-beta-1.1