Introduction
The API is JSON over HTTPS. Every call is stateless and scoped to the establishment
that owns the key. Amounts are in Saudi Riyal (SAR) with two decimals, dates are
YYYY-MM-DD, and timestamps are ISO 8601 in Riyadh time (UTC+3).
A standard invoice (B2B, tax invoice) is sent to the Authority for clearance and
comes back stamped; a simplified invoice (B2C) is signed here and reported. Both
happen inside the one POST /v1/invoices call, and the response
already carries the outcome.
ztkp_ key acts for every merchant you create, and the endpoints
below accept it with an X-Merchant-Id header.
Authentication
Every request carries the establishment's API key as a bearer token. Generate it under Settings → API. The key is shown once, at generation, and it has full authority over the establishment's invoicing — keep it in an environment variable, never in source code or a repository. One key is active per establishment; generating a new one revokes the previous one immediately.
curl https://zatcatools.com/api/v1/account \
-H "Authorization: Bearer ztk_live_..."
Errors
Every error uses the same envelope. The code is stable and meant for your program; the message is for the human reading the log.
{
"error": {
"code": "quota_exceeded",
"message": "Free-plan invoice quota exhausted. Contact us to upgrade."
}
}
| HTTP | code | Meaning |
|---|---|---|
| 401 | unauthenticated | The key is missing, malformed or revoked. |
| 402 | quota_exceeded | The plan's invoice allowance is used up. Contact us to upgrade. |
| 404 | not_found | No such resource under this establishment. |
| 409 | not_connected | The establishment has not completed its ZATCA onboarding yet. |
| 422 | invalid_lines · customer_required · customer_incomplete · type_disabled · branch_required · device_required · device_not_usable … | The request is incomplete or inconsistent. The code names what to fix; the message explains it. |
| 422 | submission_failed | The document could not be signed or the Authority refused it. Nothing was recorded — fix and resend. |
| 429 | too_many_requests | More than 60 requests in a minute on this key. Back off and retry. |
A 2xx with "status": "rejected" is not an error of the API: the document
reached the Authority and was refused. The reasons are in zatca.rejection, and each
rule code is explained in the validation code guide.
Idempotency
Pass your own order identifier as external_id (or as an
Idempotency-Key header). A repeated request with the same value returns the
document that was already issued — 200 instead of 201,
same body — and never a duplicate. Retries after a timeout are therefore always safe.
Notes are namespaced by kind, so a credit note may reuse the id of the invoice it adjusts.
Rate limits
- 60 requests per minute per key. Beyond that:
429. - Issuing a document consumes one unit of the establishment's plan. Watch what is left on
GET /v1/account. - Signing and clearance take one to three seconds. Set client timeouts to 30 seconds and rely on idempotency for retries.
/v1/account
The establishment, its ZATCA connection, its branches with their devices, and what is left of the plan.
{
"name": "مؤسسة النخبة للتجارة",
"vat_number": "310000000000003",
"cr_number": "1010101010",
"address": {
"street": "طريق الملك فهد", "building_number": "8228", "subdivision": "العليا",
"city": "الرياض", "postal_zone": "12244", "short_address": "RRRD2929"
},
"zatca": { "connected": true, "environment": "production", "certificate_expires_at": "2027-07-01" },
"branches": [
{ "id": 3, "name": "فرع الملز", "city": "الرياض", "connected": true,
"devices": [ { "id": 7, "name": "كاشير 1", "status": "connected", "usable": true } ] }
],
"quota": { "used": 12, "limit": 50, "remaining": 38, "unlimited": false }
}
/v1/invoices
Builds the invoice, signs it and submits it to ZATCA in one call — clearance for a
standard invoice, reporting for a simplified one.
A standard invoice needs a complete customer; a simplified one does not.
Customers are matched by VAT number inside your establishment and created when new.
curl -X POST https://zatcatools.com/api/v1/invoices \
-H "Authorization: Bearer ztk_live_..." \
-H "Content-Type: application/json" \
-d '{
"type": "standard",
"external_id": "SO-2026-1188",
"customer": {
"name": "شركة المثال للتجارة",
"vat_number": "311111111101113",
"cr_number": "1010101010",
"short_address": "RRRD2929"
},
"lines": [
{ "name": "اشتراك سنوي", "quantity": 1, "unit_price": 1000 }
],
"due_date": "2026-08-18",
"notes": "السداد خلال 30 يومًا بالتحويل البنكي"
}'
{
"type": "simplified",
"external_id": "order-5501",
"customer_email": "[email protected]",
"prices_include_vat": true,
"branch_id": 3,
"device_id": 7,
"lines": [
{ "name": "قهوة مختصة 250غ", "quantity": 2, "unit_price": 55,
"description": "حبوب إثيوبية — تحميصة فاتحة" },
{ "name": "أدوات تحضير", "quantity": 1, "unit_price": 120,
"discount": 10, "discount_type": "percent" },
{ "name": "دواء", "quantity": 1, "unit_price": 80,
"tax_category": "Z", "tax_reason_code": "VATEX-SA-35" }
]
}
Parameters
| Field | Description |
|---|---|
| type REQUIRED | standard (B2B, cleared) or simplified (B2C, reported). Each must be enabled for the establishment in Settings, or the call answers type_disabled. |
| lines[] REQUIRED | At least one line with name, quantity, unit_price. Optional per line: description, discount + discount_type, tax_category + tax_reason_code — see Line items and Tax categories. |
| customer optional | Required for standard. name and a valid 15-digit vat_number, plus the buyer's national short_address (RRRD2929) — street, building number, district, city and postal code are resolved from it. Or spell them out: street, building_number, subdivision, city, postal_zone. cr_number is optional. On a simplified invoice a customer with just a name is printed on the document. |
| discount optional | A document-level discount in SAR, allocated across the lines under ZATCA's rounding rules. For a discount on one item use the line's own discount instead. |
| prices_include_vat optional | true when every amount you send (lines and discount) is what the customer pays. Default false: amounts are net and 15% is added. See Prices including VAT. |
| issue_date optional | YYYY-MM-DD, defaults to today in Riyadh. supply_date (standard only) defaults to the issue date. |
| due_date optional | Payment due date of a standard invoice, YYYY-MM-DD. Printed on the document and used for receivables. |
| notes optional | Free text for the buyer, up to 2000 characters — payment terms, a delivery remark. Printed under the totals on the PDF; never part of the signed XML. |
| external_id optional | Your order id. Makes the call idempotent — see Idempotency. |
| customer_email optional | When present, the buyer receives the PDF (with its QR code) by email as soon as the document is accepted. |
| branch_id · device_id optional | A sale from a branch names the branch and the device that rang it — always both. Omit both for the head office. See Branches & devices. |
{
"uuid": "8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b",
"number": "INV-2026-00042",
"kind": "invoice",
"type": "standard",
"status": "accepted",
"issue_date": "2026-07-19",
"created_at": "2026-07-19T09:30:00+03:00",
"totals": {
"lines": "1000.00", "discount": "0.00",
"vat": "150.00", "grand": "1150.00", "currency": "SAR"
},
"customer": { "name": "شركة المثال للتجارة", "vat_number": "311111111101113" },
"lines": [
{ "name": "اشتراك سنوي", "quantity": 1, "unit_price": 1000, "tax_category": "S", "total": 1000 }
],
"due_date": "2026-08-18",
"notes": "السداد خلال 30 يومًا بالتحويل البنكي",
"zatca": {
"channel": "clearance",
"icv": 42,
"submitted_at": "2026-07-19T09:30:02+03:00",
"warnings": []
},
"links": {
"view": "https://zatcatools.com/invoice/{uuid}/{token}",
"xml": "https://zatcatools.com/api/v1/invoices/{uuid}/xml",
"pdf": "https://zatcatools.com/api/v1/invoices/{uuid}/pdf",
"qr": "https://zatcatools.com/api/v1/invoices/{uuid}/qr.svg"
}
}
Lines come back as they were sent: a line with a discount also carries
discount, discount_type and the resulting
net_unit_price; a line with a description carries it; and
totals.line_discounts appears when any line was discounted.
links.view is the one link to put in front of a person — an order screen,
an email — it opens in any browser with no key. The xml, pdf
and qr links need the bearer key and are for server-to-server use.
status.
201 means the document was created, signed and submitted:
accepted — accepted ·
warnings — accepted with warnings, listed in zatca.warnings ·
rejected — refused by the Authority, reasons in zatca.rejection.
A rejected document keeps its number and its place in the chain, as the regulation requires; issue a corrected one.
Line items & discounts
| Field | Description |
|---|---|
| name REQUIRED | What was sold. Printed as the item name. |
| quantity REQUIRED | Decimal, greater than zero. |
| unit_price REQUIRED | The list price of one unit — net, or gross with prices_include_vat. |
| description optional | Up to 500 characters, printed under the item name. |
| discount optional | A discount on this line: an amount in SAR by default, or a percentage with "discount_type": "percent". It is taken off the unit price — the Authority requires quantity × unit price to equal the line's net (BR-KSA-EN16931-11) — so the document prints the agreed price with the discount beside it. A discount that reaches the whole line is refused (invalid_lines). |
| tax_category optional | S, Z, E or O, with a tax_reason_code for anything but S. Omit it and the line takes the establishment's own VAT treatment (Settings → Establishment; standard-rated unless changed), which is what most integrations want. See below. |
Tax categories
Each line carries its own VAT treatment, so one basket can hold a standard-rated item beside zero-rated medicine; the document gets one subtotal per category, as the Authority requires. A zero-rated, exempt or out-of-scope line must say why — ZATCA rejects one without a reason code. Send the code; the official reason text is written into the XML for you.
| tax_category | Meaning | tax_reason_code |
|---|---|---|
| S · 15% | Standard rated — 15% VAT | not needed |
| Z · 0% | Zero rated — 0% VAT, reason required |
VATEX-SA-32 — Export of goods
VATEX-SA-33 — Export of services
VATEX-SA-34-1 — International transport of Goods
VATEX-SA-34-2 — International transport of passengers
VATEX-SA-34-3 — Services directly connected and incidental to international passenger transport
VATEX-SA-34-4 — Supply of a qualifying means of transport
VATEX-SA-34-5 — Services relating to Goods or passenger transportation
VATEX-SA-35 — Medicines and medical equipment
VATEX-SA-36 — Qualifying metals
VATEX-SA-EDU — Private education to citizen
VATEX-SA-HEA — Private healthcare to citizen
|
| E · 0% | Exempt — reason required |
VATEX-SA-29 — Financial services mentioned in Article 29 of the VAT Regulations
VATEX-SA-29-7 — Life insurance services mentioned in Article 29 of the VAT Regulations
VATEX-SA-30 — Real estate transactions mentioned in Article 30 of the VAT Regulations
|
| O · 0% | Out of scope of VAT — reason required |
VATEX-SA-OOS — Outside scope of VAT
|
Prices including VAT
A shelf price in a Saudi shop already includes VAT; an agency quotes before it. With
"prices_include_vat": true every amount in the request — line prices and the
document discount — is what the customer actually pays, and the net is derived per line
at that line's own rate, so a zero-rated line is never "un-taxed" by mistake. The stored,
signed document is always net, as the regulation requires: the flag changes what a sent riyal
means, never the shape of the invoice. Without it (the default) prices are net and 15% is added on top.
Branches & devices
One VAT number, many outlets. Each branch issues through its own device — ZATCA's own model, the EGS unit: every device is activated with its own OTP from the Fatoora portal (Onboard new solution unit/device; single-use, valid one hour), carries its own certificate and its own invoice chain, so branches issue in parallel. The head office's device is the establishment's main connection. A branch cannot issue until a device of its own is connected; a branch may have several.
| POST /v1/branches | Create a branch. name and short_address are required — street, district, city and postal code resolve from the code; cr_number optional. Explicit address fields are for one case only: the code could not be resolved (address_unresolved). |
| POST /v1/branches/{id}/devices | Activate a device for the branch with its Fatoora otp. Answers 202 at once; the CSR, certificate and six compliance checks complete in the background. |
| GET /v1/devices/{id} | Poll until status is connected. failed with otp_invalid means: generate a fresh code and connect again. |
| POST /v1/devices/{id}/connect | Re-activate a device that failed, or that was left behind by a trial graduation — a new otp, and its chain starts afresh. |
| DELETE /v1/devices/{id} | Retire a device. The documents it issued stay as they are. |
An invoice from a branch then names both branch_id and
device_id — standard and simplified alike. The point of sale knows its
till; the document carries the branch's address and CR and is signed by that device on its own
chain. One without the other is refused (device_required /
branch_required). The legal name and VAT number always stay the
establishment's. Notes follow the branch of the invoice they adjust; device_id
on a note is optional. The full list of branches and devices is on GET /v1/account,
and they can also be managed under Settings → Establishment.
/v1/invoices
Newest first, paginated. This is also the polling trigger automation platforms build on: ask for what is newer than the last document you saw.
| Query | Description |
|---|---|
| kind | invoice (default) · credit · debit · all |
| status | accepted · warnings · rejected · draft |
| type | standard · simplified |
| since | An ISO 8601 timestamp: only documents created after it. Feed back the newest created_at you have seen and a poll every minute costs one small query. |
| page · per_page | Page number, and page size up to 100 (default 25). |
{
"data": [ { "uuid": "…", "number": "INV-2026-00042", "kind": "invoice", "status": "accepted", "created_at": "…", … } ],
"meta": { "page": 1, "per_page": 25, "total": 128, "last_page": 6 }
}
/v1/invoices/{uuid}
The full document — the same shape as the create response, including the lines, the note or
due date if any, zatca.rejection when the Authority refused it, and
origin_invoice (uuid, number) on a credit or debit note.
XML · PDF · QR
Every issued document has three downloads, under the same bearer key:
/v1/invoices/{uuid}/xml
The signed XML, byte for byte as ZATCA received it
/v1/invoices/{uuid}/pdf
A print-ready Arabic/English PDF with the QR code
/v1/invoices/{uuid}/qr.svg
The QR code alone as SVG (the signed TLV payload)
/v1/invoices/{uuid}/html
The same document as a self-contained HTML page
To show a document to a person, use links.view from the invoice object instead:
it carries its own per-document token, opens in any browser, and never exposes your key.
/v1/invoices/{uuid}/email
Emails the PDF. to is optional and defaults to the account owner's address.
Answers { "sent": true, "to": "bu…@example.com" }, or
422 invalid_recipient / 502 send_failed.
/v1/notes
A credit note (credit) or a debit note (debit) against
an issued invoice named by invoice_uuid. The reason
is mandatory (BR-KSA-17). Lines take the same shape as on an invoice — description and discount included —
and are priced net: give the price the buyer actually paid. A returned quantity may not exceed the
invoice's, and a credit note may not exceed the invoice's open balance after earlier notes (Article 40).
The note is issued from the same branch as its invoice.
{
"kind": "credit",
"invoice_uuid": "8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b",
"reason": "إرجاع جزئي للبضاعة",
"external_id": "return-5501-1",
"lines": [
{ "name": "أدوات تحضير", "quantity": 1, "unit_price": 120, "discount": 10, "discount_type": "percent" }
]
}
The response is the note in the same shape as an invoice, with kind,
a CRN- or DBN- number and
origin_invoice. Errors: origin_not_found,
reason_required, quantity_exceeds_origin,
exceeds_open_balance.
Cancel & reinstate
An issued invoice is signed and reported: it is never deleted, it is reversed. Send a credit note with
"full": true and no lines, and the open balance
after any earlier notes is credited — computed here, so it can never exceed the legal ceiling.
An invoice already fully covered answers 422 nothing_to_credit.
To reinstate, send the same request with "kind": "debit" and
"full": true: a debit note for exactly what was written off, and the invoice is
owed again (422 nothing_to_restore when nothing was). To undo one specific credit
note rather than everything ever credited, pass its uuid as reverses instead.
Use a different external_id for each cancel/reinstate cycle, or the earlier note is returned.