Developer documentation
ZATCA API reference
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 every endpoint below accepts it with an
X-Merchant-Id header.
Quickstart
- 1. Get a key. Connect your establishment, then open Settings → API and generate a key. It starts with
ztk_live_and is shown once. - 2. Check the connection.
GET /v1/accountanswers with the establishment and"zatca": {"connected": true}once it is ready to issue. - 3. Issue an invoice. Post the lines. Read
statusin the answer:acceptedorwarningsmeans the invoice is issued, andlinks.viewis the page to hand to your customer.
curl -X POST https://zatcatools.com/api/v1/invoices \
-H "Authorization: Bearer $ZATCA_TOOLS_KEY" \
-H "Content-Type: application/json" \
-d '{
"lines": [
{
"name": "استشارة",
"quantity": 1,
"unit_price": 500
}
]
}'
const response = await fetch('https://zatcatools.com/api/v1/invoices', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"lines": [
{
"name": "استشارة",
"quantity": 1,
"unit_price": 500
}
]
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_KEY'))
->post('https://zatcatools.com/api/v1/invoices', [
'lines' => [
[
'name' => 'استشارة',
'quantity' => 1,
'unit_price' => 500,
],
],
]);
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/v1/invoices",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_KEY']}"},
json={
"lines": [
{
"name": "استشارة",
"quantity": 1,
"unit_price": 500
}
]
},
timeout=30,
)
data = response.json()
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 $ZATCA_TOOLS_KEY"
const response = await fetch('https://zatcatools.com/api/v1/account', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_KEY}`,
},
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_KEY'))
->get('https://zatcatools.com/api/v1/account');
$data = $response->json();
import os
import requests
response = requests.get(
"https://zatcatools.com/api/v1/account",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_KEY']}"},
timeout=30,
)
data = response.json()
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, and names the field to fix.
Nothing is signed or numbered until a request passes every check: a 4xx leaves no trace on the establishment and can be fixed and resent.
{
"error": {
"code": "invalid_lines",
"message": "lines[1].quantity must be a number greater than zero."
}
}
{
"error": {
"code": "too_many_requests",
"message": "Too many requests. Retry after 12 seconds."
}
}
| HTTP | code | Meaning |
|---|---|---|
| 400 | merchant_required | A partner key was used without X-Merchant-Id. |
| 401 | unauthenticated | The key is missing, malformed or revoked. |
| 402 | quota_exceeded | The plan's invoice allowance is used up, or its term has ended. Subscribe or renew to issue again. |
| 402 | trial_exhausted | Trial mode's documents are used up. Connect the establishment's real VAT number to issue live invoices. |
| 403 | partner_suspended · seat_released | Partner keys only: the partner account is suspended, or this merchant's seat was released (reads still work). |
| 404 | not_found | No such resource under this establishment. |
| 405 | method_not_allowed | The endpoint exists but not with this HTTP method. |
| 409 | not_connected | The establishment has not completed its ZATCA onboarding yet. |
| 409 | conflict · connect_in_progress | The same request is still being processed, or a device is onboarding right now. |
| 422 | invalid_lines | A line cannot be read — the message names it, e.g. lines[1].quantity. The whole request is refused; no line is ever dropped. |
| 422 | invalid_type · invalid_date | type is not standard or simplified; a date is not a real YYYY-MM-DD date, or issue_date is after today in Riyadh (BR-KSA-04). |
| 422 | invalid_discount | The document discount is negative, or takes the whole order to zero — nothing would be left to tax. |
| 422 | customer_required · customer_invalid · customer_incomplete | The buyer is missing, malformed, or not identified well enough for a tax invoice. |
| 422 | customer_national_id_required | A line is exempt as education or healthcare (VATEX-SA-EDU / VATEX-SA-HEA) and the buyer is not named by a Saudi national ID (BR-KSA-49). |
| 422 | type_disabled | This invoice type is switched off in the establishment's settings. |
| 422 | branch_not_found · branch_required · device_required · device_not_found · device_not_usable | The branch and device named on the request do not match — see Branches & devices. |
| 422 | origin_not_found · reason_required · quantity_exceeds_origin · exceeds_open_balance · nothing_to_credit · nothing_to_restore | Credit and debit notes — see the notes endpoint. |
| 422 | submission_failed | The document could not be signed. Nothing was recorded — fix and resend. |
| 429 | too_many_requests | The rate limit was reached. Wait for the Retry-After header, then retry. |
| 500 | server_error | Something failed on our side. Retry with the same external_id; it never issues twice. |
"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, even when the
repeats arrive at the same moment. 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.
curl -X POST https://zatcatools.com/api/v1/invoices \
-H "Authorization: Bearer $ZATCA_TOOLS_KEY" \
-H "Idempotency-Key: order-5501" \
-H "Content-Type: application/json" \
-d '{
"lines": [
{
"name": "اشتراك شهري",
"quantity": 1,
"unit_price": 99
}
]
}'
const response = await fetch('https://zatcatools.com/api/v1/invoices', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_KEY}`,
'Idempotency-Key': 'order-5501',
'Content-Type': 'application/json',
},
body: JSON.stringify({
"lines": [
{
"name": "اشتراك شهري",
"quantity": 1,
"unit_price": 99
}
]
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_KEY'))
->withHeaders([
'Idempotency-Key' => 'order-5501',
])
->post('https://zatcatools.com/api/v1/invoices', [
'lines' => [
[
'name' => 'اشتراك شهري',
'quantity' => 1,
'unit_price' => 99,
],
],
]);
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/v1/invoices",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_KEY']}", "Idempotency-Key": "order-5501"},
json={
"lines": [
{
"name": "اشتراك شهري",
"quantity": 1,
"unit_price": 99
}
]
},
timeout=30,
)
data = response.json()
Rate limits & timeouts
- 60 requests per minute from one client address on the
/v1endpoints (120 on the Partner API's own). Beyond that the answer is429 too_many_requestswith aRetry-Afterheader in seconds. - Issuing a document consumes one unit of the establishment's plan. A document the Authority rejects gives its unit back. 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.
Line items & discounts
Every line is read in full or the request is refused: a line with a missing name or an unreadable
quantity answers 422 invalid_lines naming that line, rather than being left out of a signed invoice.
-
namestring requiredWhat was sold. Printed as the item name. ZATCA expects at least three characters and warns below that. -
quantitynumber requiredDecimal, greater than zero. quantity × unit_price must come to at least 0.01. -
unit_pricenumber requiredThe list price of one unit — net, or gross withprices_include_vat. Send a shelf price as your system holds it, never divided by 1.15 on your side. -
descriptionstringUp to 5000 characters, printed under the item name. -
discountnumberA discount on this line: an amount in SAR for the whole line by default, or a percentage withdiscount_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 5 off a line of two items at 50 prints 47.50 each. A discount that reaches the whole line is refused. -
discount_typestring defaultamountamountorpercent. -
tax_categorystringS,Z,EorO, in any case. Omit it and the line takes the establishment's own VAT treatment (Settings → Establishment; standard-rated unless changed). See Tax categories. -
tax_reason_codestringOptional. WithZ,EorOit names the reason and must belong to that category — e.g.VATEX-SA-35withZ. Left out, the line takes the reason the other lines of the same category name on this document when they agree on one (none when they name two), else the one this establishment used last for that category; if it has none, the line is signed without one and ZATCA accepts the document with warningBR-KSA-69(zero-rated) orBR-KSA-23(exempt). An out-of-scope line always carriesVATEX-SA-OOS. A reason is never guessed.
Identifying the buyer
A VAT number identifies a registered business, and most standard invoices need nothing else. But
plenty of buyers have no VAT registration and still require a tax invoice: a government body, a
business under the registration threshold, a person. The Authority asks for another number and,
with it, what kind of number it is (BT-46-1, BR-KSA-14). Send both: cr_number
carries the number, id_scheme the kind.
A code we do not recognise is refused (customer_invalid) rather than read as a
commercial registration: a national ID fits a commercial registration's shape exactly, so a silent
fallback would sign a document naming the wrong kind of paper.
One case where it is not optional. A line exempted as education or healthcare
(VATEX-SA-EDU, VATEX-SA-HEA) is exempt for a citizen: the buyer needs a
national ID with id_scheme NAT, on a simplified invoice too. Without it the
request answers customer_national_id_required (BR-KSA-49).
{
"customer": {
"name": "أمانة منطقة الرياض",
"cr_number": "7009876543",
"id_scheme": "700",
"short_address": "RRRD2929"
}
}
| id_scheme | What the number is | Shape |
|---|---|---|
| CRN | Commercial registration — the default when id_scheme is left out | 10 digits |
| 700 | Unified national number, how a government body is known | 10 digits, starts with 7 |
| NAT | Saudi national ID | 10 digits, starts with 1 |
| IQA | Iqama (residency) number | 10 digits, starts with 2 |
| MOM | MOMRAH (municipal affairs) licence | letters and digits |
| MLS | MHRSD (human resources) licence | letters and digits |
| SAG | MISA (investment) licence | letters and digits |
| GCC | GCC national ID | letters and digits |
| PAS | Passport | letters and digits |
| OTH | Any other identifier | letters and digits |
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 may carry a reason code of its own category. It is
optional: a line without one takes the reason the other lines of the same category name on the same
document when they name one (when they name two, it stays without one — we never pick between them),
else the reason this establishment used last for that category, and if it has never used one the
line is signed without a reason — ZATCA accepts the document with warning
BR-KSA-69 for a zero-rated line or
BR-KSA-23 for an exempt one; an out-of-scope
line always carries VATEX-SA-OOS. We never pick a reason for you. A reason of another category is refused with
invalid_lines. The official reason text is written into the XML for you.
Lines of one category may name different reasons — goods exported (VATEX-SA-32) beside a
service to a buyer abroad (VATEX-SA-33). Each reason then gets its own subtotal, so every
line's basis reaches ZATCA, and ZATCA accepts the document with warning BR-Z-08
(BR-E-08 when exempt): its check assumes one subtotal per category. For an acceptance with
no warning, issue one document per reason. A credit or debit note with full gives each
reason back its own share.
| tax_category | Meaning | tax_reason_code |
|---|---|---|
| S · 15% | Standard rated — 15% VAT | not needed |
| Z · 0% | Zero rated |
VATEX-SA-32 — Export of goods
VATEX-SA-33 — Export of services
VATEX-SA-34-1 — The international transport of Goods
VATEX-SA-34-2 — international transport of passengers
VATEX-SA-34-3 — services directly connected and incidental to a Supply of international passenger transport
VATEX-SA-34-4 — Supply of a qualifying means of transport
VATEX-SA-34-5 — Any services relating to Goods or passenger transportation, as defined in article twenty five of these Regulations
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 |
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 |
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 signed document is always net, as the
regulation requires. Without the flag (the default) prices are net and 15% is added on top.
If your system holds prices with VAT, send them unchanged with the flag — do not divide by 1.15
yourself. A price you divide and round to two decimals is taken as the net price it now is: three
dishes at 28.00 sent as 24.35 come to 84.01. Sent at 28.00 with the flag they come to 84.00,
and the halala the rounding leaves is declared as the document's allowance — which is why
totals.discount can read 0.01 on an invoice you sent no discount for.
Branches & devices
One VAT number, many outlets. Each branch issues through its own device — ZATCA's 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.
An invoice from a branch names both branch_id and device_id; 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. Ids are the
numbers listed on GET /v1/account.
Advance payments
Money received before the supply is invoiced when it is received: VAT on an advance is due then.
Send "prepayment": true and the document is a prepayment invoice (ZATCA type
386), numbered ADV-. Its lines describe what the advance is for; with
prices_include_vat a 1,000 deposit is 869.57 + 130.43 VAT.
The invoice for the supply takes the advance off. Send prepayments: the advances, by
number or uuid, each with the amount to take (VAT included; omitted, what is left of it as
far as the invoice goes). The invoice states the whole supply and its whole VAT; each advance becomes a
deduction line carrying its number, date, time and VAT breakdown, the sum is the prepaid amount, and
the buyer is asked for the rest — totals.prepaid and totals.payable. VAT is
counted once: on the advance when it was paid, on the rest when the supply is invoiced (ZATCA Detailed
Guidelines §8). One advance can be spread over several invoices, and several advances can come off one.
A billing system that knows only how much came from credit lists the buyer's advances oldest
first, with no amounts, and sends that sum as prepaid_amount: as much as it allows is
taken off, advances already used up are skipped. GET /v1/prepayments?customer_vat=… (or
customer_name= for a buyer with no VAT number) lists the advances still open with what is
left of each. An advance refunded is a credit note on it, up to what is left of it. Refused before
anything is signed: prepayment_not_found, not_a_prepayment,
prepayment_used, prepayment_exceeds_remaining,
prepayment_exceeds_invoice, prepayment_other_customer.
curl -X POST https://zatcatools.com/api/v1/invoices \
-H "Authorization: Bearer $ZATCA_TOOLS_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "standard",
"prepayment": true,
"prices_include_vat": true,
"customer": {
"name": "Al Madar Hosting Co.",
"vat_number": "311111111100003",
"short_address": "RRRD2929"
},
"lines": [
{
"name": "Advance payment — website project",
"quantity": 1,
"unit_price": 1000
}
]
}'
const response = await fetch('https://zatcatools.com/api/v1/invoices', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"type": "standard",
"prepayment": true,
"prices_include_vat": true,
"customer": {
"name": "Al Madar Hosting Co.",
"vat_number": "311111111100003",
"short_address": "RRRD2929"
},
"lines": [
{
"name": "Advance payment — website project",
"quantity": 1,
"unit_price": 1000
}
]
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_KEY'))
->post('https://zatcatools.com/api/v1/invoices', [
'type' => 'standard',
'prepayment' => true,
'prices_include_vat' => true,
'customer' => [
'name' => 'Al Madar Hosting Co.',
'vat_number' => '311111111100003',
'short_address' => 'RRRD2929',
],
'lines' => [
[
'name' => 'Advance payment — website project',
'quantity' => 1,
'unit_price' => 1000,
],
],
]);
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/v1/invoices",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_KEY']}"},
json={
"type": "standard",
"prepayment": True,
"prices_include_vat": True,
"customer": {
"name": "Al Madar Hosting Co.",
"vat_number": "311111111100003",
"short_address": "RRRD2929"
},
"lines": [
{
"name": "Advance payment — website project",
"quantity": 1,
"unit_price": 1000
}
]
},
timeout=30,
)
data = response.json()
curl -X POST https://zatcatools.com/api/v1/invoices \
-H "Authorization: Bearer $ZATCA_TOOLS_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "standard",
"prices_include_vat": true,
"customer": {
"name": "Al Madar Hosting Co.",
"vat_number": "311111111100003",
"short_address": "RRRD2929"
},
"lines": [
{
"name": "Website design and build",
"quantity": 1,
"unit_price": 2000
}
],
"prepayments": [
{
"invoice": "ADV-2026-00001"
}
]
}'
const response = await fetch('https://zatcatools.com/api/v1/invoices', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"type": "standard",
"prices_include_vat": true,
"customer": {
"name": "Al Madar Hosting Co.",
"vat_number": "311111111100003",
"short_address": "RRRD2929"
},
"lines": [
{
"name": "Website design and build",
"quantity": 1,
"unit_price": 2000
}
],
"prepayments": [
{
"invoice": "ADV-2026-00001"
}
]
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_KEY'))
->post('https://zatcatools.com/api/v1/invoices', [
'type' => 'standard',
'prices_include_vat' => true,
'customer' => [
'name' => 'Al Madar Hosting Co.',
'vat_number' => '311111111100003',
'short_address' => 'RRRD2929',
],
'lines' => [
[
'name' => 'Website design and build',
'quantity' => 1,
'unit_price' => 2000,
],
],
'prepayments' => [
[
'invoice' => 'ADV-2026-00001',
],
],
]);
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/v1/invoices",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_KEY']}"},
json={
"type": "standard",
"prices_include_vat": True,
"customer": {
"name": "Al Madar Hosting Co.",
"vat_number": "311111111100003",
"short_address": "RRRD2929"
},
"lines": [
{
"name": "Website design and build",
"quantity": 1,
"unit_price": 2000
}
],
"prepayments": [
{
"invoice": "ADV-2026-00001"
}
]
},
timeout=30,
)
data = response.json()
{
"number": "INV-2026-00043",
"kind": "invoice",
"status": "accepted",
"totals": {
"taxable": "1739.13",
"vat": "260.87",
"grand": "2000.00",
"prepaid": "1000.00",
"payable": "1000.00",
"currency": "SAR"
},
"prepayments": [
{
"number": "ADV-2026-00001",
"amount": "1000.00",
"taxable": "869.57",
"vat": "130.43"
}
]
}
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": a debit note for exactly
what was written off (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.
curl -X POST https://zatcatools.com/api/v1/notes \
-H "Authorization: Bearer $ZATCA_TOOLS_KEY" \
-H "Content-Type: application/json" \
-d '{
"kind": "credit",
"invoice_uuid": "8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b",
"reason": "إلغاء الطلب",
"full": true,
"external_id": "cancel-5501"
}'
const response = await fetch('https://zatcatools.com/api/v1/notes', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"kind": "credit",
"invoice_uuid": "8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b",
"reason": "إلغاء الطلب",
"full": true,
"external_id": "cancel-5501"
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_KEY'))
->post('https://zatcatools.com/api/v1/notes', [
'kind' => 'credit',
'invoice_uuid' => '8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b',
'reason' => 'إلغاء الطلب',
'full' => true,
'external_id' => 'cancel-5501',
]);
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/v1/notes",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_KEY']}"},
json={
"kind": "credit",
"invoice_uuid": "8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b",
"reason": "إلغاء الطلب",
"full": True,
"external_id": "cancel-5501"
},
timeout=30,
)
data = response.json()
Retrieve the account
/v1/account
The establishment, its ZATCA connection, its branches with their devices, and what is left of the plan.
quota.remaining is null on an unlimited plan, so an integration can tell «no ceiling» from «a very high one».
curl https://zatcatools.com/api/v1/account \
-H "Authorization: Bearer $ZATCA_TOOLS_KEY"
const response = await fetch('https://zatcatools.com/api/v1/account', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_KEY}`,
},
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_KEY'))
->get('https://zatcatools.com/api/v1/account');
$data = $response->json();
import os
import requests
response = requests.get(
"https://zatcatools.com/api/v1/account",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_KEY']}"},
timeout=30,
)
data = response.json()
{
"name": "مؤسسة النخبة للتجارة",
"vat_number": "310000000000003",
"cr_number": "1010101010",
"cr_scheme": "CRN",
"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": 250,
"remaining": 238,
"unlimited": false
}
}
{
"error": {
"code": "unauthenticated",
"message": "Invalid or missing API key. Pass it as: Authorization: Bearer ztk_live_..."
}
}
Create an invoice
/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;
customers are matched by VAT number inside your establishment and created when new.
status. A 201 means the document was created, signed and submitted:
accepted, warnings (accepted, with notes in zatca.warnings) or
rejected (reasons in zatca.rejection). A rejected document keeps its number and its
place in the chain, as the regulation requires; issue a corrected one.
Body
-
typestring defaultsimplifiedstandard(B2B tax invoice, cleared) orsimplified(B2C, reported), in any case. Anything else answersinvalid_type. Each type must be enabled in Settings, or the call answerstype_disabled. -
linesarray of objects requiredAt least one line. Each is read in full or the request is refused — see Line items.-
namestring requiredWhat was sold. -
quantitynumber requiredGreater than zero; decimals allowed. -
unit_pricenumber requiredNet, or gross withprices_include_vat. -
descriptionstringPrinted under the item name. -
discountnumberFor the whole line, taken off the unit price. -
discount_typestring defaultamountamountorpercent. -
tax_categorystringS·Z·E·O. Defaults to the establishment's treatment. -
tax_reason_codestringOptional — a code of that category. Left out: the reason last used for that category, else none (ZATCA warning BR-KSA-69 on a zero-rated line, BR-KSA-23 on an exempt one).
-
-
customerobjectRequired forstandard. On a simplified invoice a customer with just anameis printed on the document. An incomplete buyer address is still cleared, with a ZATCA warning.-
namestring requiredThe buyer's legal name. -
vat_numberstring15 digits, starting and ending with 3. Or identify the buyer bycr_number+id_scheme— see Identifying the buyer. -
cr_numberstringThe buyer's other identifier. -
id_schemestring defaultCRNWhatcr_numberis: CRN, 700, NAT, IQA, MOM, MLS, SAG, GCC, PAS or OTH. -
short_addressstringThe national short address, e.g.RRRD2929. Street, building number, district, city and postal code are resolved from it. -
street · building_number · district · city · postal_zonestringThe address spelled out, when you hold it. What you send always wins over what the short address resolves to. -
contact_name · phone · emailstringKept with the buyer and reused on the next invoice. -
printed_detailsstringOne line of your own printed in the buyer box — a PO number, a department.
-
-
discountnumberA document-level discount in SAR. It must leave something to tax (invalid_discountotherwise). In the signed XML it is spread across the lines under ZATCA's rounding rules; the response reports it as you sent it. -
prices_include_vatboolean defaultfalseTrue when every amount you send is what the customer pays. See Prices including VAT. -
issue_datestring defaulttodayYYYY-MM-DD, today in Riyadh or earlier. A later date answersinvalid_date: ZATCA refuses a future-dated document (BR-KSA-04). -
supply_datestringStandard only,YYYY-MM-DD. Defaults to the issue date. -
due_datestringStandard only,YYYY-MM-DD. Printed on the document and used for receivables. -
notesstringFree text for the buyer, up to 2000 characters. Printed on the PDF; never part of the signed XML. -
external_idstringYour order id. Makes the call idempotent — see Idempotency. -
customer_emailstringThe buyer receives the PDF by email as soon as the document is accepted. -
branch_id · device_idintegerA sale from a branch names the branch and the device that rang it — always both. Omit both for the head office. -
prepaymentboolean defaultfalseTrue for the invoice of an advance received (ZATCA type 386, numberedADV-). See Advance payments. -
prepaymentsarrayAdvances this invoice takes off:[{"invoice": "ADV-2026-00001", "amount": 1000}]— number or uuid, amount VAT included and optional. -
prepaid_amountnumberThe most to take off in all, VAT included — for a system that knows only how much came from credit.
Response
-
uuid · numberstringThe document's identity. Numbers runINV-,ADV-(advances),CRN-,DBN-per year. -
statusstringaccepted·warnings·rejected·pending(signed, ZATCA gave no verdict yet — we send it again automatically). -
totalsobjectStrings with two decimals:lines − discount = taxableandtaxable + vat = grandto the halala.taxable,vatandgrandare exactly the signed figures;linesanddiscountshow a document discount as you sent it, where the XML spreads it into the lines.-
linesstringSum of the line nets. -
discountstringThe document discount — including a rounding halala on VAT-inclusive prices, which appears even when you sent none. -
taxablestringWhat VAT was charged on. -
vat · grand · currencystringVAT, total with VAT, and SAR. -
prepaid · payablestringAdvances taken off, VAT included, and what the buyer is asked for after them. Equal to0.00andgrandwhen nothing is deducted. -
line_discountsstringPresent when any line was discounted.
-
-
prepayments · prepayment_remainingarray · stringThe advances this invoice took off (number, amount, taxable, vat); on a prepayment invoice, what is left of it. -
linesarrayAs sent, plusnet_unit_priceon a discounted line and each line'stotal. -
zatcaobjectchannel(clearance or reporting),icv,submitted_at,warnings, andrejectionwhen refused. -
linksobjectviewis the one to put in front of a person: it opens in any browser with no key.xml,pdfandqrneed the bearer key.
curl -X POST https://zatcatools.com/api/v1/invoices \
-H "Authorization: Bearer $ZATCA_TOOLS_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "standard",
"external_id": "SO-2026-1188",
"customer": {
"name": "شركة المثال للتجارة",
"vat_number": "311111111101113",
"cr_number": "1010101010",
"id_scheme": "CRN",
"short_address": "RRRD2929",
"email": "[email protected]"
},
"lines": [
{
"name": "اشتراك سنوي",
"quantity": 1,
"unit_price": 1000
}
],
"due_date": "2026-10-25",
"notes": "السداد خلال 30 يومًا بالتحويل البنكي"
}'
const response = await fetch('https://zatcatools.com/api/v1/invoices', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"type": "standard",
"external_id": "SO-2026-1188",
"customer": {
"name": "شركة المثال للتجارة",
"vat_number": "311111111101113",
"cr_number": "1010101010",
"id_scheme": "CRN",
"short_address": "RRRD2929",
"email": "[email protected]"
},
"lines": [
{
"name": "اشتراك سنوي",
"quantity": 1,
"unit_price": 1000
}
],
"due_date": "2026-10-25",
"notes": "السداد خلال 30 يومًا بالتحويل البنكي"
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_KEY'))
->post('https://zatcatools.com/api/v1/invoices', [
'type' => 'standard',
'external_id' => 'SO-2026-1188',
'customer' => [
'name' => 'شركة المثال للتجارة',
'vat_number' => '311111111101113',
'cr_number' => '1010101010',
'id_scheme' => 'CRN',
'short_address' => 'RRRD2929',
'email' => '[email protected]',
],
'lines' => [
[
'name' => 'اشتراك سنوي',
'quantity' => 1,
'unit_price' => 1000,
],
],
'due_date' => '2026-10-25',
'notes' => 'السداد خلال 30 يومًا بالتحويل البنكي',
]);
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/v1/invoices",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_KEY']}"},
json={
"type": "standard",
"external_id": "SO-2026-1188",
"customer": {
"name": "شركة المثال للتجارة",
"vat_number": "311111111101113",
"cr_number": "1010101010",
"id_scheme": "CRN",
"short_address": "RRRD2929",
"email": "[email protected]"
},
"lines": [
{
"name": "اشتراك سنوي",
"quantity": 1,
"unit_price": 1000
}
],
"due_date": "2026-10-25",
"notes": "السداد خلال 30 يومًا بالتحويل البنكي"
},
timeout=30,
)
data = response.json()
curl -X POST https://zatcatools.com/api/v1/invoices \
-H "Authorization: Bearer $ZATCA_TOOLS_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "simplified",
"external_id": "order-5501",
"prices_include_vat": true,
"customer_email": "[email protected]",
"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"
}
]
}'
const response = await fetch('https://zatcatools.com/api/v1/invoices', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"type": "simplified",
"external_id": "order-5501",
"prices_include_vat": true,
"customer_email": "[email protected]",
"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"
}
]
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_KEY'))
->post('https://zatcatools.com/api/v1/invoices', [
'type' => 'simplified',
'external_id' => 'order-5501',
'prices_include_vat' => true,
'customer_email' => '[email protected]',
'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',
],
],
]);
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/v1/invoices",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_KEY']}"},
json={
"type": "simplified",
"external_id": "order-5501",
"prices_include_vat": True,
"customer_email": "[email protected]",
"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"
}
]
},
timeout=30,
)
data = response.json()
{
"uuid": "8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b",
"number": "INV-2026-00042",
"kind": "invoice",
"type": "standard",
"status": "accepted",
"issue_date": "2026-09-25",
"created_at": "2026-09-25T09:30:00+03:00",
"totals": {
"lines": "1000.00",
"discount": "0.00",
"taxable": "1000.00",
"vat": "150.00",
"grand": "1150.00",
"currency": "SAR"
},
"customer": {
"name": "شركة المثال للتجارة",
"vat_number": "311111111101113",
"cr_number": "1010101010",
"id_scheme": "CRN"
},
"lines": [
{
"name": "اشتراك سنوي",
"quantity": 1,
"unit_price": 1000,
"tax_category": "S",
"total": 1000
}
],
"due_date": "2026-10-25",
"notes": "السداد خلال 30 يومًا بالتحويل البنكي",
"zatca": {
"channel": "clearance",
"icv": 42,
"submitted_at": "2026-09-25T09:30:02+03:00",
"warnings": []
},
"links": {
"view": "https://zatcatools.com/invoice/{uuid}/{token}",
"xml": "https://zatcatools.com/api/v1/invoices/8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b/xml",
"pdf": "https://zatcatools.com/api/v1/invoices/8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b/pdf",
"qr": "https://zatcatools.com/api/v1/invoices/8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b/qr.svg"
}
}
{
"uuid": "8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b",
"number": "INV-2026-00042",
"status": "accepted",
"…": "the invoice already issued for this external_id"
}
{
"error": {
"code": "invalid_lines",
"message": "lines[0].tax_reason_code VATEX-SA-29 belongs to category E, not Z."
}
}
{
"error": {
"code": "trial_exhausted",
"message": "Trial allowance used up. Connect your real VAT number to issue live invoices."
}
}
List invoices
/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 parameters
-
kindstring defaultinvoiceinvoice·credit·debit·all -
statusstringaccepted·warnings·rejected·pending -
typestringstandard·simplified -
sincestringAn ISO 8601 timestamp: only documents created after it. Feed back the newestcreated_atyou have seen. An unreadable value returns nothing, never everything. -
pageinteger default1Page number. -
per_pageinteger default251 to 100.
curl https://zatcatools.com/api/v1/invoices?kind=all&since=2026-09-25T09:30:00Z&per_page=50 \
-H "Authorization: Bearer $ZATCA_TOOLS_KEY"
const response = await fetch('https://zatcatools.com/api/v1/invoices?kind=all&since=2026-09-25T09:30:00Z&per_page=50', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_KEY}`,
},
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_KEY'))
->get('https://zatcatools.com/api/v1/invoices?kind=all&since=2026-09-25T09:30:00Z&per_page=50');
$data = $response->json();
import os
import requests
response = requests.get(
"https://zatcatools.com/api/v1/invoices?kind=all&since=2026-09-25T09:30:00Z&per_page=50",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_KEY']}"},
timeout=30,
)
data = response.json()
{
"data": [
{
"uuid": "8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b",
"number": "INV-2026-00042",
"kind": "invoice",
"type": "standard",
"status": "accepted",
"created_at": "2026-09-25T09:30:00+03:00",
"…": "…"
}
],
"meta": {
"page": 1,
"per_page": 50,
"total": 128,
"last_page": 3
}
}
Retrieve an invoice
/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.
curl https://zatcatools.com/api/v1/invoices/8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b \
-H "Authorization: Bearer $ZATCA_TOOLS_KEY"
const response = await fetch('https://zatcatools.com/api/v1/invoices/8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_KEY}`,
},
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_KEY'))
->get('https://zatcatools.com/api/v1/invoices/8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b');
$data = $response->json();
import os
import requests
response = requests.get(
"https://zatcatools.com/api/v1/invoices/8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_KEY']}"},
timeout=30,
)
data = response.json()
{
"uuid": "8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b",
"number": "INV-2026-00042",
"kind": "invoice",
"type": "standard",
"status": "accepted",
"issue_date": "2026-09-25",
"created_at": "2026-09-25T09:30:00+03:00",
"totals": {
"lines": "1000.00",
"discount": "0.00",
"taxable": "1000.00",
"vat": "150.00",
"grand": "1150.00",
"currency": "SAR"
},
"customer": {
"name": "شركة المثال للتجارة",
"vat_number": "311111111101113",
"cr_number": "1010101010",
"id_scheme": "CRN"
},
"lines": [
{
"name": "اشتراك سنوي",
"quantity": 1,
"unit_price": 1000,
"tax_category": "S",
"total": 1000
}
],
"due_date": "2026-10-25",
"notes": "السداد خلال 30 يومًا بالتحويل البنكي",
"zatca": {
"channel": "clearance",
"icv": 42,
"submitted_at": "2026-09-25T09:30:02+03:00",
"warnings": []
},
"links": {
"view": "https://zatcatools.com/invoice/{uuid}/{token}",
"xml": "https://zatcatools.com/api/v1/invoices/8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b/xml",
"pdf": "https://zatcatools.com/api/v1/invoices/8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b/pdf",
"qr": "https://zatcatools.com/api/v1/invoices/8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b/qr.svg"
}
}
{
"error": {
"code": "not_found",
"message": "No invoice with this uuid."
}
}
Download XML · PDF · QR
/v1/invoices/{uuid}/pdf
Every issued document has four downloads, under the same bearer key:
/v1/invoices/{uuid}/xml
The e-invoice XML — for a B2B invoice or note, the copy ZATCA cleared and stamped
/v1/invoices/{uuid}/pdf
PDF/A-3, Arabic and English, with the QR code and the XML embedded
/v1/invoices/{uuid}/qr.svg
The QR code alone, as SVG
/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 instead: it carries its own per-document token and never exposes your key.
curl https://zatcatools.com/api/v1/invoices/8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b/pdf \
-H "Authorization: Bearer $ZATCA_TOOLS_KEY" \
-o INV-2026-00042.pdf
import { writeFile } from 'node:fs/promises';
const response = await fetch('https://zatcatools.com/api/v1/invoices/8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b/pdf', {
headers: { Authorization: `Bearer ${process.env.ZATCA_TOOLS_KEY}` },
});
await writeFile('INV-2026-00042.pdf', Buffer.from(await response.arrayBuffer()));
use Illuminate\Support\Facades\Http;
$pdf = Http::withToken(env('ZATCA_TOOLS_KEY'))
->get('https://zatcatools.com/api/v1/invoices/8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b/pdf')
->body();
file_put_contents('INV-2026-00042.pdf', $pdf);
import os
import requests
response = requests.get(
"https://zatcatools.com/api/v1/invoices/8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b/pdf",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_KEY']}"},
timeout=30,
)
open("INV-2026-00042.pdf", "wb").write(response.content)
Email an invoice
/v1/invoices/{uuid}/email
Emails the PDF. to is optional and defaults to the account owner's address.
Body
-
tostringThe recipient. Defaults to the account owner.
curl -X POST https://zatcatools.com/api/v1/invoices/8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b/email \
-H "Authorization: Bearer $ZATCA_TOOLS_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]"
}'
const response = await fetch('https://zatcatools.com/api/v1/invoices/8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b/email', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"to": "[email protected]"
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_KEY'))
->post('https://zatcatools.com/api/v1/invoices/8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b/email', [
'to' => '[email protected]',
]);
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/v1/invoices/8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b/email",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_KEY']}"},
json={
"to": "[email protected]"
},
timeout=30,
)
data = response.json()
{
"sent": true,
"to": "bu…@example.com"
}
{
"error": {
"code": "invalid_recipient",
"message": "Provide a valid `to` address, or add an email to your account."
}
}
{
"error": {
"code": "send_failed",
"message": "The invoice could not be emailed right now."
}
}
Create a credit or debit note
/v1/notes
A credit note (credit) or a debit note (debit) against an issued invoice named by
invoice_uuid. Lines take the same shape as on an invoice 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 carries its invoice's VAT
treatment and is issued from the same branch. To cancel a whole invoice, see
Cancel & reinstate.
Body
-
kindstring requiredcreditordebit. -
invoice_uuidstring requiredThe issued invoice the note adjusts. -
reasonstring requiredWhy the invoice is adjusted (BR-KSA-17). Printed on the note. -
linesarray of objectsWhat is credited or charged. Omit withfullorreverses. -
fullbooleanCredit the whole open balance, or (debit) restore everything written off. -
reversesstringDebit only: the uuid of one credit note to undo. -
external_idstringIdempotency key, namespaced by kind. -
device_idintegerOptional: the device of the invoice's branch that signs the note.
curl -X POST https://zatcatools.com/api/v1/notes \
-H "Authorization: Bearer $ZATCA_TOOLS_KEY" \
-H "Content-Type: application/json" \
-d '{
"kind": "credit",
"invoice_uuid": "8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b",
"reason": "إرجاع جزئي للبضاعة",
"external_id": "return-5501-1",
"lines": [
{
"name": "أدوات تحضير",
"quantity": 1,
"unit_price": 108
}
]
}'
const response = await fetch('https://zatcatools.com/api/v1/notes', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"kind": "credit",
"invoice_uuid": "8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b",
"reason": "إرجاع جزئي للبضاعة",
"external_id": "return-5501-1",
"lines": [
{
"name": "أدوات تحضير",
"quantity": 1,
"unit_price": 108
}
]
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_KEY'))
->post('https://zatcatools.com/api/v1/notes', [
'kind' => 'credit',
'invoice_uuid' => '8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b',
'reason' => 'إرجاع جزئي للبضاعة',
'external_id' => 'return-5501-1',
'lines' => [
[
'name' => 'أدوات تحضير',
'quantity' => 1,
'unit_price' => 108,
],
],
]);
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/v1/notes",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_KEY']}"},
json={
"kind": "credit",
"invoice_uuid": "8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b",
"reason": "إرجاع جزئي للبضاعة",
"external_id": "return-5501-1",
"lines": [
{
"name": "أدوات تحضير",
"quantity": 1,
"unit_price": 108
}
]
},
timeout=30,
)
data = response.json()
{
"uuid": "c1f0b8d2-6a4e-4f7b-9d21-5e8a3c7b1f90",
"number": "CRN-2026-00007",
"kind": "credit",
"type": "simplified",
"status": "accepted",
"issue_date": "2026-09-25",
"totals": {
"lines": "108.00",
"discount": "0.00",
"taxable": "108.00",
"vat": "16.20",
"grand": "124.20",
"currency": "SAR"
},
"origin_invoice": {
"uuid": "8d9f6e0a-4b7c-4f2e-9a1d-3c5b7e9f1a2b",
"number": "INV-2026-00042"
},
"zatca": {
"channel": "reporting",
"icv": 43,
"warnings": []
}
}
{
"error": {
"code": "exceeds_open_balance",
"message": "Note total (500.00 SAR) exceeds the invoice open balance after previous notes (108.00 SAR)."
}
}
Create a branch
/v1/branches
Adds an outlet under the establishment's VAT number. It cannot issue until a device of its own is connected.
Body
-
namestring requiredThe branch name, e.g. «فرع الملز». -
short_addressstring requiredThe branch's national short address. Street, district, city and postal code resolve from it. -
cr_numberstringThe branch's own 10-digit commercial registration. -
street · building_number · district · city · postal_zonestringOnly when the short address cannot be resolved (address_unresolved).
curl -X POST https://zatcatools.com/api/v1/branches \
-H "Authorization: Bearer $ZATCA_TOOLS_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "فرع الملز",
"short_address": "RMLA1234",
"cr_number": "1010303030"
}'
const response = await fetch('https://zatcatools.com/api/v1/branches', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"name": "فرع الملز",
"short_address": "RMLA1234",
"cr_number": "1010303030"
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_KEY'))
->post('https://zatcatools.com/api/v1/branches', [
'name' => 'فرع الملز',
'short_address' => 'RMLA1234',
'cr_number' => '1010303030',
]);
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/v1/branches",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_KEY']}"},
json={
"name": "فرع الملز",
"short_address": "RMLA1234",
"cr_number": "1010303030"
},
timeout=30,
)
data = response.json()
{
"branch": {
"id": 3,
"name": "فرع الملز",
"cr_number": "1010303030",
"short_address": "RMLA1234",
"city": "الرياض",
"devices": []
},
"next": "Onboard the branch's device: POST /v1/branches/3/devices with the OTP from fatoora.zatca.gov.sa."
}
Add a device
/v1/branches/{id}/devices
Activates a device for the branch with its Fatoora otp. Answers 202 at once; the
CSR, the certificate and the six compliance checks complete in the background — poll the device until it
is connected, typically in under a minute.
Body
-
otpstring requiredA fresh six-digit code from the Fatoora portal — one per device, single-use, valid one hour. -
namestringWhat the device is called, e.g. «كاشير 1».
curl -X POST https://zatcatools.com/api/v1/branches/3/devices \
-H "Authorization: Bearer $ZATCA_TOOLS_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "كاشير 1",
"otp": "123456"
}'
const response = await fetch('https://zatcatools.com/api/v1/branches/3/devices', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"name": "كاشير 1",
"otp": "123456"
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_KEY'))
->post('https://zatcatools.com/api/v1/branches/3/devices', [
'name' => 'كاشير 1',
'otp' => '123456',
]);
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/v1/branches/3/devices",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_KEY']}"},
json={
"name": "كاشير 1",
"otp": "123456"
},
timeout=30,
)
data = response.json()
{
"device": {
"id": 7,
"branch_id": 3,
"name": "كاشير 1",
"status": "pending",
"connected": false,
"connecting": true
},
"next": "Poll GET /v1/devices/7 until status is `connected`."
}
Retrieve a device
/v1/devices/{id}
status moves from pending to connected. failed with error: otp_invalid means the code was wrong, spent or expired — generate a fresh one and reconnect.
curl https://zatcatools.com/api/v1/devices/7 \
-H "Authorization: Bearer $ZATCA_TOOLS_KEY"
const response = await fetch('https://zatcatools.com/api/v1/devices/7', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_KEY}`,
},
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_KEY'))
->get('https://zatcatools.com/api/v1/devices/7');
$data = $response->json();
import os
import requests
response = requests.get(
"https://zatcatools.com/api/v1/devices/7",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_KEY']}"},
timeout=30,
)
data = response.json()
{
"device": {
"id": 7,
"branch_id": 3,
"name": "كاشير 1",
"status": "connected",
"connected": true,
"connecting": false,
"compliance_passed": 6,
"error": null,
"usable": true,
"trial": false,
"certificate_expires_at": "2027-09-25"
}
}
{
"error": {
"code": "device_not_found",
"message": "No such device under this company."
}
}
Reconnect a device
/v1/devices/{id}/connect
Re-activates a device that failed, or one left behind by a trial graduation — with a new otp. On success it signs with a fresh certificate and its chain starts again at ICV 1.
curl -X POST https://zatcatools.com/api/v1/devices/7/connect \
-H "Authorization: Bearer $ZATCA_TOOLS_KEY" \
-H "Content-Type: application/json" \
-d '{
"otp": "654321"
}'
const response = await fetch('https://zatcatools.com/api/v1/devices/7/connect', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"otp": "654321"
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_KEY'))
->post('https://zatcatools.com/api/v1/devices/7/connect', [
'otp' => '654321',
]);
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/v1/devices/7/connect",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_KEY']}"},
json={
"otp": "654321"
},
timeout=30,
)
data = response.json()
{
"device": {
"id": 7,
"status": "pending",
"connecting": true
},
"next": "Poll GET /v1/devices/7 until status is `connected`."
}
{
"error": {
"code": "connect_in_progress",
"message": "This device is onboarding right now — poll GET /v1/devices/7."
}
}
Retire a device
/v1/devices/{id}
The documents it issued stay exactly as they are. Revoke its certificate from the Fatoora portal's device list as well.
curl -X DELETE https://zatcatools.com/api/v1/devices/7 \
-H "Authorization: Bearer $ZATCA_TOOLS_KEY"
const response = await fetch('https://zatcatools.com/api/v1/devices/7', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_KEY}`,
},
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_KEY'))
->delete('https://zatcatools.com/api/v1/devices/7');
$data = $response->json();
import os
import requests
response = requests.delete(
"https://zatcatools.com/api/v1/devices/7",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_KEY']}"},
timeout=30,
)
data = response.json()
{
"deleted": true
}