Skip to content

ZATCA integration

Saudi Arabia's Zakat, Tax and Customs Authority (ZATCA) requires B2B and B2C invoices to be electronically cleared or reported via the Fatoora platform (ZATCA Phase 2). Invora handles the full lifecycle: CSR generation, compliance verification, production onboarding, invoice signing, QR-code embedding, and clearance/reporting submission.

This guide covers the tenant-side flow: onboarding and tracking submissions. Enabling the ZATCA regulation and setting its base configuration for a tenant is an admin operation handled by the Invora team.

Onboarding is per branch, not per organization

ZATCA registers an EGS (E-invoice Generation Solution) unit, and Invora maps one EGS unit to one branch. Each branch gets its own EGS serial number, CSR, compliance CSID, production CSID, certificate expiry and invoice hash chain. Branches do not share a CSID.

Consequently VAT number, commercial registration (CR) number and national address are branch-level — they live on the branch's seller party, and Invora holds no tenant-level tax record. The Settings "self-party" you configure is the primary branch's party. Each additional branch needs its own complete identity before it can be onboarded.

Onboard each additional branch with the per-branch endpoint POST /api/v2/regulations/{regulationId}/enrollment/branches/{branchId}/begin (below). Do not re-run the tenant-level POST /api/v1/regulations/zatca/onboarding for a second branch — it carries no branch parameter and always resolves the primary branch, so it would re-onboard the branch you already have live. See the danger note under Start onboarding.

Base URL & auth

Environment Base URL
Production https://gateway.invora.app
Staging https://stg-gateway.invora.app
TOKEN=$(curl -s -X POST https://auth.invora.app/oauth/v2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET" \
  --data-urlencode "scope=openid urn:zitadel:iam:org:project:id:372376660185448530:aud urn:zitadel:iam:user:resourceowner" \
  | jq -r '.access_token')

ZATCA environments

Environment enum Purpose Validation
ZATCA_ENVIRONMENT_PHASE2_SANDBOX Development Simulated responses, no real validation
ZATCA_ENVIRONMENT_PHASE2_SIMULATION Pre-production Real ZATCA validation, no legal effect
ZATCA_ENVIRONMENT_PHASE2_PRODUCTION Live compliance Legally binding clearance/reporting

Onboarding

Prerequisites

  1. Register at the ZATCA Fatoora portal and generate a one-time password (OTP).
  2. Ensure ZATCA is enabled for your tenant (contact Invora support).
  3. Populate the complete identity of the branch you are onboarding. Invora validates all of the following locally, before contacting ZATCA — an incomplete branch fails without ever spending a ZATCA request (and without consuming the OTP).

Branch seller party — national address. All six are required (ZATCA BR-KSA-09); the first missing one aborts onboarding:

Field ZATCA term Party path Shape
Street name BT-35 postalAddress.streetName non-empty
Building number KSA-17 postalAddress.buildingNumber exactly 4 digits (BR-KSA-37)
City BT-37 postalAddress.cityName non-empty
Postal code BT-38 postalAddress.postalZone non-empty (5 digits)
District KSA-3 postalAddress.citySubdivisionName non-empty
Country code BT-40 postalAddress.country.identificationCode e.g. SA

Country (BT-40) is the one that catches people

A branch created through the Add Branch dialog persists no country node at all, so it fails this check even though every visible address field looks filled in. Set the country explicitly on the branch party.

Branch identity — CSR subject fields. Also required, and also validated locally:

Field Where it lives Error if missing
Legal / organization name partyLegalEntity[0].registrationName (falls back to partyName[0].name, then the branch name) —
VAT number partyTaxScheme[0].companyID Invalid organization identifier, please provide a valid 15 digit of your vat number — must be 15 digits, first and last digit 3
CR number → CSR OU partyIdentification[0].id — not partyLegalEntity[0].companyId, see the warning below Organization unit name is mandatory field
Industry / business category branch regulationConfigs["zatca"].config.industry_category — not part of the UBL party Industry is a mandatory field.
Common name branch display name Common name is mandatory field
Invoice type flag branch onboarding record, 4 characters of 0/1 (e.g. 1100 = standard + simplified) Invoice type is mandatory field
Location address derived from the branch postal address Location is mandatory field

The CR number saved in Organization Settings does NOT reach the CSR

Two different UBL paths hold a commercial-registration number, and the read and the write do not meet:

  • The dashboard writes it to partyLegalEntity[0].companyId. Organization Settings → Legal Information → CR Number lands there (SettingsSelfPartyBranchFacadeTests: afterCrn.MyInfo.Party.PartyLegalEntity[0].CompanyId.Value.ShouldBe("1010101010"); mirrored in organization_settings_controller_test.dart).
  • The CSR generator reads partyIdentification[0].id. ZatcaBranchEnrollmentRunner.ParsePartyIdentity takes party.PartyIdentification[0].Id?.Value into onboarding.CommercialRegistrationNumber, and ZatcaBranchCsrGenerator.ResolveOrganizationUnitName uses only that field for the CSR OU. It never looks at partyLegalEntity[0].companyId.

So a branch whose CR is filled in on the settings screen still has an empty CommercialRegistrationNumber for ZATCA. For an ordinary taxpayer the OU silently falls back to the branch/common name; for a VAT-group member there is no fallback and enrollment fails with Organization unit name is mandatory field. If you are debugging an OU problem, check partyIdentification[0].id — confirming the CR is present in Legal Information proves nothing about the CSR.

Set partyIdentification[0].id explicitly via the branch API (below) when the CSR OU matters.

Exactly one seller identification (BR-KSA-08)

The branch party must carry exactly one partyIdentification, from ZATCA's priority list CRN > MOM > MLS > 700 > SAG > OTH. A second, redundant entry — a TIN duplicating the VAT already present via partyTaxScheme is the common case — makes the live ZATCA sandbox reject all six compliance samples with an opaque Unable to execute Business Rules validation -> and no rule id.

VAT-group members

If your VAT number's 11th digit is 1 you are a VAT-group member, and the CSR OU must be that member's own 10-digit TIN. Invora will not invent a fallback in that case — supply the CR/TIN explicitly or onboarding fails with Organization unit name is mandatory field.

Give a branch a complete national address (and identity)

This is the fix for the [BR-KSA-09] Seller address must contain … rejection. No screen in the dashboard writes a branch's national address today, so use the Branches API.

The ZATCA Integration screen's address fields are discarded

The ZATCA Integration screen renders editable Short Address / Building Number / District / Additional Code / Postal Code / Street / City fields, but ZatcaIntegrationController.connect() persists only industry_category before calling onboarding — every address value typed there is thrown away, and the form has no country control at all. Retrying from that screen therefore reproduces the identical BR-KSA-09 error no matter what you type. Tracked as invora-flutter#324. Use the API calls below instead.

Step 1 — find the branch key.

curl -X POST https://gateway.invora.app/api/v2/branches/list \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
Response (trimmed)
{
  "items": [
    { "id": { "key": "brn_riyadh_hq" }, "name": "Riyadh HQ", "isPrimary": true,
      "concurrencyStamp": "8f3c…", "party": { "…": "…" } }
  ],
  "totalCount": 1
}

Step 2 — read the branch you are about to change.

curl -X GET https://gateway.invora.app/api/v2/branches/brn_riyadh_hq \
  -H "Authorization: Bearer $TOKEN"

Keep the details.party object and the details.concurrencyStamp from this response.

Step 3 — PUT the branch back with the address merged into its party.

curl -X PUT https://gateway.invora.app/api/v2/branches/brn_riyadh_hq \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "concurrencyStamp": "8f3c…",
    "mask": "party",
    "changes": {
      "name": "Riyadh HQ",
      "party": {
        "partyIdentification": [ { "id": { "value": "1010101010", "schemeId": "CRN" } } ],
        "partyName":           [ { "name": { "value": "Acme Trading Co" } } ],
        "partyLegalEntity":    [ { "registrationName": { "value": "Acme Trading Co" },
                                   "companyId": { "value": "1010101010" } } ],
        "partyTaxScheme":      [ { "companyId": { "value": "311111111111113" },
                                   "taxScheme": { "id": { "value": "VAT" } } } ],
        "postalAddress": {
          "streetName":           { "value": "King Fahd Road" },
          "buildingNumber":       { "value": "8765" },
          "citySubdivisionName":  { "value": "Al Olaya" },
          "cityName":             { "value": "Riyadh" },
          "postalZone":           { "value": "12345" },
          "country": { "identificationCode": { "value": "SA" } }
        }
      }
    }
  }'

Every UBL leaf is a wrapper object with a value field — "streetName": "King Fahd Road" is not accepted, it must be { "value": "King Fahd Road" }.

changes.party REPLACES the whole party — it does not merge

BranchesAppService.ApplyBranchChanges does branch.DetailsJson = JsonFormatter.Default.Format(changes.Party). Sending only a postalAddress therefore erases the branch's VAT number, CR number, legal name and contacts. Always read the branch first (step 2) and send the complete party back with the address added — that is why the example above repeats the identity fields.

Two more traps in the same call:

  • Send mask: "party" — a single comma-separated string, camelCase, not an object and not {"paths": [...]} (see Field masks). is_primary and document_numbering are the two fields gated by the mask; with no mask, the unset changes.isPrimary defaults to false and demotes a primary branch. (Setting "isPrimary": true explicitly works too.)
  • changes.name is applied whenever it is non-empty, so pass the branch's existing name unless you mean to rename it.

Step 4 — set the industry category (a branch regulation config, not part of the party).

Re-read the branch first — step 3 already rotated the stamp

Step 3's response carries a new concurrencyStamp; the one you kept after step 2 is now stale. Reusing it here fails — confirmed live on a real branch: a second PUT replaying an already-consumed stamp returned HTTP 409 / {"code":10,"message":"Concurrency stamp mismatch."}, while the identical request with a freshly-read stamp returned HTTP 200. Re-run step 2 (or use the concurrencyStamp step 3's own response returned) and use that value below. The same read also supplies the current regulationConfigs.zatca.config, which you need for the warning below.

curl -X PUT https://gateway.invora.app/api/v2/branches/brn_riyadh_hq \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "concurrencyStamp": "9a1d…",
    "mask": "regulationConfigs",
    "changes": {
      "name": "Riyadh HQ",
      "regulationConfigs": {
        "zatca": {
          "config": {
            "industry_category": "Retail"
          }
        }
      }
    }
  }'

The config above is the whole map you are writing, not a patch — read the warning below before copying it. It is correct as printed only for a branch whose step-2 read showed no regulationConfigs.zatca.config. If step 2 returned keys, add industry_category to those and send the result.

changes.regulationConfigs REPLACES the whole map — it does not merge

Same trap as changes.party above, on a different field. BranchesAppService.ApplyBranchChanges assigns branch.RegulationConfigs["zatca"] = Map(config) wholesale whenever changes.regulationConfigs names the key — the mask does not gate this at all; only is_primary and document_numbering are mask-gated. If the branch has already been enrolled, that Struct is the enrollment-state snapshot that GET …/enrollment/branches/{branchId}/state reads back, so every key you leave out of config is dropped from it.

Confirmed live on an enrolled branch whose zatca config held industry_category, enrollment_state, environment, last_step_name, last_step_at and csid_expires_at: a PUT sending {"industry_category": "Retail"} alone returned 200; the re-read branch came back holding {"industry_category": "Retail"} and nothing else; and GET …/enrollment/branches/{branchId}/state then reported REGULATION_ENROLLMENT_STATE_NOT_STARTED. The enrolment itself was still intact underneath — clearing the same branch's config to {} made that endpoint answer REGULATION_ENROLLMENT_STATE_ACTIVE, derived from the server-side onboarding record instead of from the config. So what a partial config destroys is the state the platform reports, while the real enrolment stays put — which is what makes the next paragraph dangerous rather than merely untidy. Re-sending the six keys restored both — but only because they had been captured before the overwrite. The PUT leaves no server-side copy, so if you discover this after the fact there is nothing to read them back from.

Calling begin on a branch the state endpoint says is NotStarted takes the hard-restart path, wiping its live CSIDs and PIH/ICV chain exactly like re-running v1 onboarding on a Production branch (see the danger box below).

So send back exactly the keys your step-2 read returned, plus the one you are adding — no more, no fewer. If that read showed no zatca config, then {"industry_category": "…"} on its own is the complete, correct map. Echo those keys back exactly as step 2 returned them; never invent a value for one. The state endpoint reports whatever that Struct says, so an enrollment_state you wrote yourself makes it claim a state the branch is not in.

Then re-read the branch (step 2) and confirm all six address fields, the CSR-subject fields above, and whatever regulationConfigs.zatca.config keys your first step-2 read showed are all present before spending an OTP.

Start onboarding

POST /api/v1/regulations/zatca/onboarding always targets the PRIMARY branch

The v1 RPC has no branch_id field. ZatcaRegulationAppService.InitiateOnboarding resolves BranchQueryInput { IsPrimary = true, Take = 1 } (falling back to the first branch of the tenant) and enrolls that branch — whichever branch you meant.

Running it against a branch that is already in Production takes the hard-restart path: ZatcaBranchEnrollmentRunner.BeginAsync calls WipeCredentials, which nulls the CSR, private key, both CSIDs, the certificate expiry and the PIH/ICV invoice chain. A live branch stops being able to clear invoices until it is fully re-onboarded, and its hash chain restarts at ICV 1.

So: use v1 only for a single-branch tenant's first onboarding. For a second branch, or a re-run on a multi-branch tenant, use the per-branch endpoint — and check GET …/enrollment/branches/{branchId}/state first.

InitiateOnboarding returns a server-sent stream of progress events — one per onboarding step.

curl -N -X POST https://gateway.invora.app/api/v1/regulations/zatca/onboarding \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "otp": "123456", "environment": "ZATCA_ENVIRONMENT_PHASE2_SIMULATION" }'
Response (streamed events)
{"step": "ZATCA_ONBOARDING_STEP_CSR_GENERATION", "disposition": "ZATCA_DISPOSITION_ISSUED", "isFinal": false}
{"step": "ZATCA_ONBOARDING_STEP_COMPLIANCE_CSID", "disposition": "ZATCA_DISPOSITION_ISSUED", "isFinal": false}
{"step": "ZATCA_ONBOARDING_STEP_PRODUCTION_CSID", "disposition": "ZATCA_DISPOSITION_ISSUED", "isFinal": true}

The three steps run in sequence:

  1. CSR generation — Invora generates a Certificate Signing Request from the branch's identity (its seller party plus its industry_category). Missing or malformed fields are rejected here, locally, before ZATCA is contacted.
  2. Compliance CSID — the branch's postal address is re-checked against BR-KSA-09/BR-KSA-37; ZATCA then issues a compliance certificate and Invora submits six signed sample invoices (one per Phase-2 document flavour) for validation. All six must pass.
  3. Production CSID — ZATCA issues the production certificate for live invoicing for that branch.

A failed step emits disposition: ZATCA_DISPOSITION_REJECTED (or _ERROR) with structured errors:

Response (failed step)
{
  "step": "ZATCA_ONBOARDING_STEP_COMPLIANCE_CSID",
  "disposition": "ZATCA_DISPOSITION_REJECTED",
  "message": "Compliance check failed",
  "errors": [
    {
      "code": "COMPLIANCE_CHECK_FAILED",
      "category": "COMPLIANCE",
      "message": "[BR-KSA-09] Seller address must contain country code (BT-40). Complete the organization profile's national address before ZATCA enrollment."
    }
  ],
  "isFinal": true
}

Enroll one branch (the per-branch endpoint)

Use this for every branch after the first, and whenever a tenant has more than one branch. It takes the branch explicitly, so it cannot touch a branch you did not name.

First, check what state the branch is in. BeginEnrollment on a branch already in Production is a hard restart — it wipes that branch's CSIDs and its PIH/ICV chain, exactly as the v1 call does.

curl -X GET https://gateway.invora.app/api/v2/regulations/zatca/enrollment/branches/brn_jeddah/state \
  -H "Authorization: Bearer $TOKEN"

Then begin. Note the payload shape: regulationId and branchId are path parameters, and the OTP and environment go inside a config object — a body of {"otp": …, "environment": …} is rejected with config is required.

curl -N -X POST https://gateway.invora.app/api/v2/regulations/zatca/enrollment/branches/brn_jeddah/begin \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "config": { "otp": "654321", "environment": "Phase2Simulation" } }'
config key Accepted values
otp required, exactly 6 digits (^[0-9]{6}$)
environment Phase2Sandbox | Phase2Simulation | Phase2Production (aliases Sandbox/NonProduction, Simulation, Production; anything unrecognised falls back to sandbox)

Any other key in config is discarded — in particular industry_category cannot be passed here, it must already be on the branch (step 4 above).

Each streamed line wraps a single EnrollmentStep inside a step field (BeginEnrollmentResponse.step — "Wraps a single EnrollmentStep so each RPC has its own response type") — unlike the v1 stream above, which is flat. Confirmed live against a real branch:

Response (streamed events)
{"step": {"stepName": "CsrGeneration",   "disposition": "ENROLLMENT_DISPOSITION_ISSUED", "isFinal": false}}
{"step": {"stepName": "SandboxCsid",     "disposition": "ENROLLMENT_DISPOSITION_ISSUED", "isFinal": false}}
{"step": {"stepName": "ProductionCsid",  "disposition": "ENROLLMENT_DISPOSITION_ISSUED", "isFinal": true}}

POST …/enrollment/branches/{branchId}/resume continues an interrupted run and is idempotent on an active branch (one final step with message: "already_active").

Poll progress

If the client disconnected mid-stream, poll the current state:

curl -X GET https://gateway.invora.app/api/v1/regulations/zatca/onboarding/progress \
  -H "Authorization: Bearer $TOKEN"
Response
{
  "currentStep": "ZATCA_ONBOARDING_STEP_PRODUCTION_CSID",
  "lastDisposition": "ZATCA_DISPOSITION_ISSUED",
  "completed": true
}

Check integration status

curl -X GET https://gateway.invora.app/api/v1/regulations/zatca/integration-status \
  -H "Authorization: Bearer $TOKEN"

Both v1 reads are primary-branch only

GET …/onboarding/progress and GET …/integration-status carry no branch parameter either: GetStatusFromOnboardingTruthAsync resolves BranchQueryInput { IsPrimary = true, Take = 1 }, the same resolution InitiateOnboarding uses. On a multi-branch tenant they describe the primary branch and say nothing about the others. Use GET /api/v2/regulations/zatca/enrollment/branches/{branchId}/state per branch.

Response
{
  "onboarded": true,
  "environment": "ZATCA_ENVIRONMENT_PHASE2_SIMULATION",
  "details": {
    "complianceCsidExpiry": "2027-01-15T00:00:00Z",
    "productionCsidExpiry": "2027-01-15T00:00:00Z",
    "isCompliant": true
  }
}

Submitting invoices

Once onboarded, ZATCA runs automatically when you freeze a document — whether through the Simple surface or the Documents API. On freeze, Invora validates against ZATCA rules, canonicalizes and signs the XML with your CSID, generates the TLV-encoded QR code, and submits for clearance (B2B) or reporting (B2C).

Invoice Model Behavior
B2B (standard tax invoice) Pre-clearance Blocking — ZATCA must clear before the invoice is valid
B2C (simplified invoice) Reporting Async — reported after issuance

The model is chosen automatically from the invoice content; no per-call configuration is needed.

Check a document's regulation status

curl -X GET https://gateway.invora.app/api/v2/regulations/documents/INV-2026-001 \
  -H "Authorization: Bearer $TOKEN"
Response
{
  "items": [
    {
      "regulationId": "zatca",
      "submissionStatus": "REGULATION_SUBMISSION_STATUS_ACCEPTED",
      "artifacts": [ { "artifactId": "signed-xml" }, { "artifactId": "qr-code" } ]
    }
  ]
}

This returns status and artifact descriptors (no bytes). For the typed ZATCA detail (clearance ID, invoice hash), read the regulation metadata on the document itself.

Download the signed XML or QR code

curl -X GET "https://gateway.invora.app/api/v2/regulations/zatca/documents/INV-2026-001/artifact?artifactId=signed-xml" \
  -H "Authorization: Bearer $TOKEN"
Response
{
  "artifact": "PD94bWwgdmVyc2lvbj0i...",
  "contentType": "application/xml",
  "artifactHash": "9f2c1a44..."
}

artifact is base64-encoded. Use artifactId=qr-code for the QR image.

Retry a failed submission

curl -X POST https://gateway.invora.app/api/v2/regulations/zatca/submissions/INV-2026-001/retry \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'

Decode a QR code

Decode a ZATCA QR code from a printed or received invoice into structured TLV data (seller, VAT number, totals, signature):

curl -X POST https://gateway.invora.app/api/v1/regulations/zatca/explain-qr \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "input": ["AXZWYW..."] }'

Common issues

Symptom Cause Fix
[BR-KSA-09] Seller address must contain … — often country code (BT-40), and onboarding fails immediately with no ZATCA traffic at all The branch's postal address is missing one of the six required fields. A branch created through the Add Branch dialog has no address, and in particular no country node. Set all six fields on the branch being onboarded — follow Give a branch a complete national address step by step, then retry. Do not retry from the ZATCA Integration screen: its address fields are never saved (invora-flutter#324). See also the warning below — the message names the wrong record.
[BR-KSA-37] Seller address building number '…' must contain exactly 4 digits Branch building number is not 4 digits Correct the branch's buildingNumber to exactly 4 digits
Industry is a mandatory field. The branch has no industry_category on its zatca regulation config. It is not part of the UBL party and no user-facing form collects it. Set regulationConfigs["zatca"].config.industry_category on the branch
Organization unit name is mandatory field The branch has no CR at partyIdentification[0].id, and (for a VAT-group member) no fallback may be invented. A CR entered in Organization Settings does not count — that writes partyLegalEntity[0].companyId, which the CSR generator never reads. Set partyIdentification[0].id on the branch (step 3); VAT-group members must supply their own 10-digit TIN
Invalid organization identifier … Branch VAT is not 15 digits, or does not start and end with 3 Correct partyTaxScheme[0].companyID on the branch
Unable to execute Business Rules validation -> with no rule id, all 6 samples rejected Branch party carries more than one partyIdentification (BR-KSA-08) — typically a redundant TIN beside the CRN Prune to a single identification from CRN > MOM > MLS > 700 > SAG > OTH
OTP expired OTP is short-lived Generate a fresh OTP from the Fatoora portal
Compliance CSID rejected Seller details don't match ZATCA records Correct the branch's legal name, VAT number, and address
Clearance rejected Calculation errors or missing fields Read errors/diagnostics, fix the document, retry
QR code invalid Document altered after signing Frozen documents are immutable — investigate corruption

The address error names the wrong record — fix the BRANCH, not the organization profile

The BR-KSA-09 failure above ends with "Complete the organization profile's national address before ZATCA enrollment." That wording is wrong: the check reads the branch's party, not an organization profile (none exists — see the note at the top of this page). Following the message literally means editing a different record and staying blocked, with every retry producing the identical error. The record to fix is always the branch you are onboarding.

Distinguishing a local rejection from a ZATCA rejection

Both surface as a REJECTED step. If no ZATCA_ONBOARDING_STEP_COMPLIANCE_CSID request was made and the OTP is still unused, the rejection came from Invora's local pre-flight (prerequisites above) — fix the branch data rather than regenerating the OTP.