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.
WhoDoes what
You, on the Fatoora portalGenerate 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.
ZATCAChecks the OTP and the samples, and issues the certificate. If it refuses, you get its reason and a help link.
You, in your codeStore the credentials as a secret and the chain in your database; send invoices.

Sandbox, simulation, production

EnvironmentForVAT numberOTP
"sandbox"Trying the SDK. Nothing is a tax invoice.ZATCA's test number 399999999900003 only — the SDK uses it when you give no sellerNone needed
"simulation"A rehearsal with your own identity. Not tax invoices.YoursFrom the Fatoora simulation portal
"production"Real, legally binding tax invoices.YoursFrom 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

  1. Sign in to the Fatoora portal with your company's account (the simulation portal for "simulation").
  2. Open Onboard new solution unit/device and generate one OTP per system you are connecting.
  3. Use it within the hour: it works once, and expires after 60 minutes.
If ZATCA answers 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.

The private key is in the credentials and nowhere else. It was created on your machine and never sent. Lose it and the system must be onboarded again.
onboard.py
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.

every start
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.statusMeaningWhat to do
REPORTED · CLEAREDZATCA accepted it.Store it. Read warnings.
NOT_REPORTED · NOT_CLEAREDZATCA rejected it.Fix what errors say; issue a new invoice.
NOT_SENTNo connection: ZATCA never saw it.Send the same invoice again.
UNKNOWNSent, but no answer came back.Send the same invoice again — never a new one for the same sale.
FAILEDZATCA refused the request, not the invoice.Fix the cause in error; send it again.
after submit()
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.

renew.py
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_SENT and UNKNOWN are 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.

connect.py
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 "")