Connect to ZATCA · 2 of 3
Connect your system to ZATCA
The sandbox lends everyone the same test certificate. To send your own invoices, your system — ZATCA calls it an EGS unit: a server, a till, an app — needs its own. You get it once, with one call. You need three things:
- Your company's VAT number, legal name, CR number and address, as registered with ZATCA.
- A one-time password (OTP) from the Fatoora portal — step 1.
- A place to keep a secret (a secret manager, or encrypted storage) and two values in your database.
| Who | Does what |
|---|---|
| You, on the Fatoora portal | Generate the OTP. Only the taxpayer can — no library or service can do it for you. |
The SDK, in onboard() | Creates the private key on your machine, sends ZATCA a certificate request, signs and sends the sample invoices ZATCA requires (six for a system that issues both kinds), and obtains your production certificate. |
| ZATCA | Checks the OTP and the samples, and issues the certificate. If it refuses, you get its reason and a help link. |
| You, in your code | Store the credentials as a secret and the chain in your database; send invoices. |
Sandbox, simulation, production
| Environment | For | VAT number | OTP |
|---|---|---|---|
| "sandbox" | Trying the SDK. Nothing is a tax invoice. | ZATCA's test number 399999999900003 only — the SDK uses it when you give no seller | None needed |
| "simulation" | A rehearsal with your own identity. Not tax invoices. | Yours | From the Fatoora simulation portal |
| "production" | Real, legally binding tax invoices. | Yours | From fatoora.zatca.gov.sa |
The code is the same in all three; only the first argument of Zatca(...) changes. Each environment issues its own certificate, so a system onboarded in simulation onboards again for production.
Step 1 — Get an OTP
- Sign in to the Fatoora portal with your company's account (the simulation portal for
"simulation"). - Open Onboard new solution unit/device and generate one OTP per system you are connecting.
- Use it within the hour: it works once, and expires after 60 minutes.
Invalid-OTP, the code was mistyped, already used, expired, or generated in the other environment. Generate a new one.Step 2 — Onboard the system
One call. It takes a few seconds, and returns the credentials that sign and authenticate every invoice from now on: the certificate, its secret and the private key.
Optional arguments describe the system to ZATCA: branch (for a VAT group, the member's 10-digit TIN), location, industry, and invoice_types — "1100" for both kinds (the default), "1000" standard only, "0100" simplified only.
from zatca_tools import Zatca
SELLER = {
"vat_number": "310000000000003", # yours, as registered with ZATCA
"name": "My Company LLC",
"cr_number": "1010010000",
"address": {
"street": "King Fahd Road", "building_number": "1234",
"district": "Al Olaya", "city": "Riyadh", "postal_code": "12345",
},
}
zatca = Zatca("production", seller=SELLER)
credentials = zatca.onboard(otp="123456") # the OTP from the Fatoora portal
save_secret("zatca-credentials", credentials.export())
Step 3 — Keep two things
The credentials, as a secret. Pass them back on every start.
The chain, in your database. ZATCA links each invoice to the one before it: a counter and the previous invoice's hash. The SDK moves the chain forward by itself; you only save zatca.chain.to_dict() after each invoice and pass it back when your application restarts. The sandbox keeps it in memory, so there you can skip this.
One client per system. A client numbers its invoices one at a time, even across threads; two processes sharing one system's chain would break it.
zatca = Zatca(
"production",
seller=SELLER,
credentials=load_secret("zatca-credentials"),
chain=load_chain(), # {"icv": 41, "hash": "..."} — or "new" for a unit with no invoices yet
)
invoice = zatca.create_invoice({...})
save_chain(zatca.chain.to_dict()) # after every invoice, before sending it
result = zatca.submit(invoice)
Step 4 — Send invoices
submit() picks what ZATCA requires: a simplified invoice (B2C, a receipt) is reported — hand it to the customer at once, report within 24 hours. A standard invoice (B2B) is cleared — ZATCA validates and stamps it, and only the cleared copy, result.xml, goes to the buyer.
| result.status | Meaning | What to do |
|---|---|---|
| REPORTED · CLEARED | ZATCA accepted it. | Store it. Read warnings. |
| NOT_REPORTED · NOT_CLEARED | ZATCA rejected it. | Fix what errors say; issue a new invoice. |
| NOT_SENT | No connection: ZATCA never saw it. | Send the same invoice again. |
| UNKNOWN | Sent, but no answer came back. | Send the same invoice again — never a new one for the same sale. |
| FAILED | ZATCA refused the request, not the invoice. | Fix the cause in error; send it again. |
if result.success:
store(result.xml) # for a standard invoice, ZATCA's cleared copy
elif result.status in ("NOT_REPORTED", "NOT_CLEARED"):
show(result.errors) # ZATCA's reasons, each with a help_url
elif result.status in ("NOT_SENT", "UNKNOWN"):
retry_later(invoice) # the SAME invoice — never a new one for the same sale
else: # FAILED: credentials or ZATCA's service
alert(result.error.message, result.error.help_url)
Step 5 — Renew the certificate
Certificates expire; credentials.expires_at says when. Before then, generate a fresh OTP and call renew(). The system keeps its chain; only the certificate changes.
print(zatca.credentials.expires_at) # e.g. 2029-01-09T09:19:30+00:00
credentials = zatca.renew(otp="654321") # a fresh OTP; the invoice chain carries on
save_secret("zatca-credentials", credentials.export())
Before going live
- Run your real invoices through
"simulation"first, with your own VAT number. - The credentials are stored as a secret, never in source control or logs.
- The chain is saved after every invoice and loaded on every start.
NOT_SENTandUNKNOWNare retried with the same invoice; simplified invoices reach ZATCA within 24 hours.- Standard invoices reach the buyer only after
CLEARED, as ZATCA's cleared copy. - Warnings are read and fixed — they are accepted today and often refused later.
Field by field, and what happens inside each call: Advanced.
Complete example
The whole lifecycle in one file: onboard on the first run, reload on every run after it. It runs as it is against the sandbox; set your details and an OTP to run it in simulation or production.
import json
import os
from pathlib import Path
from zatca_tools import Zatca
ENVIRONMENT = os.environ.get("ZATCA_ENV", "sandbox")
STATE = Path(f"zatca-unit-{ENVIRONMENT}.json") # holds a private key: never commit it
# Your company, as registered with ZATCA. In the sandbox the SDK uses ZATCA's test seller.
SELLER = None if ENVIRONMENT == "sandbox" else {
"vat_number": os.environ["ZATCA_VAT_NUMBER"],
"name": os.environ["ZATCA_SELLER_NAME"],
"cr_number": os.environ["ZATCA_CR_NUMBER"],
"address": {
"street": os.environ["ZATCA_STREET"],
"building_number": os.environ["ZATCA_BUILDING"],
"district": os.environ["ZATCA_DISTRICT"],
"city": os.environ["ZATCA_CITY"],
"postal_code": os.environ["ZATCA_POSTAL_CODE"],
},
}
if STATE.exists():
# Every start after the first: the saved credentials, and where the chain stands.
saved = json.loads(STATE.read_text())
zatca = Zatca(ENVIRONMENT, seller=SELLER, credentials=saved["credentials"], chain=saved["chain"])
else:
# Once per unit.
zatca = Zatca(ENVIRONMENT, seller=SELLER)
credentials = zatca.onboard(otp=os.environ.get("ZATCA_OTP")) # the sandbox needs no OTP
print("Onboarded; the certificate expires", credentials.expires_at)
def save_state() -> None:
STATE.write_text(json.dumps({"credentials": zatca.credentials.export(), "chain": zatca.chain.to_dict()}))
invoice = zatca.create_invoice({
"type": "simplified",
"number": f"INV-{zatca.chain.icv + 1:05d}",
"prices_include_vat": True,
"items": [{"name": "Coffee", "quantity": 2, "unit_price": 18}],
})
save_state() # the chain moves with every invoice signed — save it before sending
result = zatca.submit(invoice)
print(invoice.number, result.status, result.error.message if result.error else "")