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¶
- Register at the ZATCA Fatoora portal and generate a one-time password (OTP).
- Ensure ZATCA is enabled for your tenant (contact Invora support).
- 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 inorganization_settings_controller_test.dart). - The CSR generator reads
partyIdentification[0].id.ZatcaBranchEnrollmentRunner.ParsePartyIdentitytakesparty.PartyIdentification[0].Id?.Valueintoonboarding.CommercialRegistrationNumber, andZatcaBranchCsrGenerator.ResolveOrganizationUnitNameuses only that field for the CSR OU. It never looks atpartyLegalEntity[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 '{}'
{
"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_primaryanddocument_numberingare the two fields gated by the mask; with no mask, the unsetchanges.isPrimarydefaults tofalseand demotes a primary branch. (Setting"isPrimary": trueexplicitly works too.) changes.nameis 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" }'
{"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:
- 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. - 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.
- 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:
{
"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:
{"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"
{
"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.
{
"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"
{
"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"
{
"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.
Related¶
- Simple invoicing — freeze an invoice and get the QR code.
- Documents API — regulation metadata, validation profiles, artifacts.
- Zero to invoice (demo) — an end-to-end ZATCA walkthrough.
- Error handling — status codes and structured errors.