ZATCA API · Fatoora Phase 2

Saudi e-invoicing, handled in one API call.

Send a sale from your system and get back an invoice that is signed, cleared or reported with ZATCA, and ready to hand to your customer — XML, PDF and QR included. You build your product; the Fatoora integration stays our job.

Clearance & reporting
B2B cleared, B2C reported
Credit & debit notes
Tied to the invoice, Article 40 enforced
Branches & devices
One VAT number, many outlets
Signed files
XML, PDF/A-3 with QR, HTML
POST /v1/invoices 201 · accepted
{
  "type": "simplified",
  "prices_include_vat": true,
  "lines": [
    {
      "name": "قهوة مختصة 250غ",
      "quantity": 2,
      "unit_price": 55
    }
  ]
}
Response
{
  "number": "INV-2026-00043",
  "status": "accepted",
  "totals": {
    "taxable": "95.65",
    "vat": "14.35",
    "grand": "110.00"
  },
  "zatca": {
    "channel": "reporting",
    "icv": 43
  },
  "links": {
    "view": "https://zatcatools.com/invoice/…"
  }
}

Everything a compliant invoice needs, in the response

The engine behind the ZATCA Tools dashboard, the WooCommerce plugin and the Shopify app — the same one your code calls.

One call, the whole job

Builds the UBL 2.1 XML, signs it with the establishment's own certificate, submits it to ZATCA and stores the result. The response already carries the verdict.

Clearance and reporting

Standard (B2B) invoices are cleared before they reach the buyer; simplified (B2C) ones are reported. You read one status field.

Safe to retry

Pass your order id as external_id. A timeout followed by a retry returns the invoice already issued — never a duplicate.

Notes that follow the law

Credit and debit notes tied to their invoice, the reason mandatory, never over the open balance (Article 40). Cancel or reinstate in one request.

Every file your customer needs

The XML — for a B2B invoice, the copy ZATCA cleared and stamped — a bilingual PDF/A-3 with that XML inside and its QR code, an HTML version, and a public link that opens without your key.

Errors you can act on

Stable error codes and messages that name the field. Anything ZATCA says comes back as it said it, with a guide to every validation code.

Live in three steps

  1. 1

    Connect the establishment

    Create an account and connect it to Fatoora with the VAT number and a one-time code from the Fatoora portal. Or start in trial mode: real sandbox invoices, no VAT number needed.

  2. 2

    Generate an API key

    Under Settings → API. The key is shown once and carries the establishment's authority — keep it in an environment variable.

  3. 3

    POST your first invoice

    Send the lines, and the customer for a B2B invoice. The answer carries ZATCA's verdict, the number, the totals as signed and the files.

Your system, or one we already speak

Call the API from any backend — an ERP, a POS, a booking or billing system — or start from a ready integration.

Questions developers ask

Does ZATCA have its own API?

Yes. The Fatoora platform exposes compliance, onboarding, clearance and reporting APIs. Calling them directly means generating a CSR, obtaining a compliance CSID, passing the compliance checks, obtaining a production CSID, building UBL 2.1 XML, signing every invoice, keeping the invoice counter and the hash chain in order, and renewing the certificate before it expires. With this API you connect the establishment with a one-time code from the Fatoora portal, and each invoice after that is one POST /v1/invoices. What a direct integration involves.

Can I test the API without a VAT number?

Yes. Trial mode connects the account to ZATCA's sandbox under its test identity, so you can issue sandbox documents through the API before you have connected a real VAT number. Sandbox documents are not real invoices. When you are ready, you connect the establishment with its VAT number and a one-time code from the Fatoora portal.

What does the API cost?

It is included from the first day: one week free, no payment, from the day the establishment connects to ZATCA. After that the plans are very competitively priced: 49 SAR a month (490 a year) for Growth with unlimited invoices, below the 550 to 8,000 SAR a year that Saudi e-invoicing subscriptions usually cost, and 149 SAR a month for Business with several branches and devices. Software companies invoicing for many merchants use the Partner API, agreed per partner.

Which languages and platforms does it work with?

Anything that can send JSON over HTTPS. Every endpoint below has samples in cURL, Node.js, PHP and Python. If your sales run through WooCommerce, Shopify or WHMCS, a ready plugin, app or module calls this API for you, and an n8n node covers no-code workflows.

What happens when ZATCA rejects an invoice?

The call still answers 201, with status set to rejected and ZATCA's reasons in zatca.rejection, word for word. An invoice ZATCA accepted with remarks comes back as warnings. Every validation code is explained in the ZATCA validation code guide. A request we refuse before signing (a 4xx) leaves no trace and can be fixed and resent.

Who checks that the invoices are compliant?

ZATCA does. A standard (B2B) invoice is cleared by ZATCA before it goes to the buyer, and a simplified (B2C) invoice is reported to ZATCA, which validates it; the API returns ZATCA's own verdict for every document. The accuracy of what you send, from prices to buyer details, stays with you as the issuer.

Which currencies does it support?

Saudi Riyal only. Amounts are sent and returned in SAR with two decimals.

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.

BASE URL https://zatcatools.com/api/v1
ZATCA validation codes →
Serving several merchants from one integration? Use the Partner API: one ztkp_ key acts for every merchant you create, and every endpoint below accepts it with an X-Merchant-Id header.

Quickstart

  1. 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. 2. Check the connection. GET /v1/account answers with the establishment and "zatca": {"connected": true} once it is ready to issue.
  3. 3. Issue an invoice. Post the lines. Read status in the answer: accepted or warnings means the invoice is issued, and links.view is the page to hand to your customer.
Issue your first invoice
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.

Authenticated request
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."
  }
}
HTTPcodeMeaning
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.
A 201 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, 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.

Retry-safe request
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 /v1 endpoints (120 on the Partner API's own). Beyond that the answer is 429 too_many_requests with a Retry-After header 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.

  • name string required
    What was sold. Printed as the item name. ZATCA expects at least three characters and warns below that.
  • quantity number required
    Decimal, greater than zero. quantity × unit_price must come to at least 0.01.
  • unit_price number required
    The list price of one unit — net, or gross with prices_include_vat. Send a shelf price as your system holds it, never divided by 1.15 on your side.
  • description string
    Up to 5000 characters, printed under the item name.
  • discount number
    A discount on this line: an amount in SAR for the whole line 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 5 off a line of two items at 50 prints 47.50 each. A discount that reaches the whole line is refused.
  • discount_type string default amount
    amount or percent.
  • tax_category string
    S, Z, E or O, 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_code string
    Optional. With Z, E or O it names the reason and must belong to that category — e.g. VATEX-SA-35 with Z. 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 warning BR-KSA-69 (zero-rated) or BR-KSA-23 (exempt). An out-of-scope line always carries VATEX-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_schemeWhat the number isShape
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_categoryMeaningtax_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.

1. The advance
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()
2. The supply, less the advance
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.

Cancel an invoice
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

GET /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».

Request
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

POST /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.

Always read 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

  • type string default simplified
    standard (B2B tax invoice, cleared) or simplified (B2C, reported), in any case. Anything else answers invalid_type. Each type must be enabled in Settings, or the call answers type_disabled.
  • lines array of objects required
    At least one line. Each is read in full or the request is refused — see Line items.
    • name string required
      What was sold.
    • quantity number required
      Greater than zero; decimals allowed.
    • unit_price number required
      Net, or gross with prices_include_vat.
    • description string
      Printed under the item name.
    • discount number
      For the whole line, taken off the unit price.
    • discount_type string default amount
      amount or percent.
    • tax_category string
      S · Z · E · O. Defaults to the establishment's treatment.
    • tax_reason_code string
      Optional — 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).
  • customer object
    Required for standard. On a simplified invoice a customer with just a name is printed on the document. An incomplete buyer address is still cleared, with a ZATCA warning.
    • name string required
      The buyer's legal name.
    • vat_number string
      15 digits, starting and ending with 3. Or identify the buyer by cr_number + id_scheme — see Identifying the buyer.
    • cr_number string
      The buyer's other identifier.
    • id_scheme string default CRN
      What cr_number is: CRN, 700, NAT, IQA, MOM, MLS, SAG, GCC, PAS or OTH.
    • short_address string
      The national short address, e.g. RRRD2929. Street, building number, district, city and postal code are resolved from it.
    • street · building_number · district · city · postal_zone string
      The address spelled out, when you hold it. What you send always wins over what the short address resolves to.
    • contact_name · phone · email string
      Kept with the buyer and reused on the next invoice.
    • printed_details string
      One line of your own printed in the buyer box — a PO number, a department.
  • discount number
    A document-level discount in SAR. It must leave something to tax (invalid_discount otherwise). 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_vat boolean default false
    True when every amount you send is what the customer pays. See Prices including VAT.
  • issue_date string default today
    YYYY-MM-DD, today in Riyadh or earlier. A later date answers invalid_date: ZATCA refuses a future-dated document (BR-KSA-04).
  • supply_date string
    Standard only, YYYY-MM-DD. Defaults to the issue date.
  • due_date string
    Standard only, YYYY-MM-DD. Printed on the document and used for receivables.
  • notes string
    Free text for the buyer, up to 2000 characters. Printed on the PDF; never part of the signed XML.
  • external_id string
    Your order id. Makes the call idempotent — see Idempotency.
  • customer_email string
    The buyer receives the PDF by email as soon as the document is accepted.
  • branch_id · device_id integer
    A sale from a branch names the branch and the device that rang it — always both. Omit both for the head office.
  • prepayment boolean default false
    True for the invoice of an advance received (ZATCA type 386, numbered ADV-). See Advance payments.
  • prepayments array
    Advances this invoice takes off: [{"invoice": "ADV-2026-00001", "amount": 1000}] — number or uuid, amount VAT included and optional.
  • prepaid_amount number
    The most to take off in all, VAT included — for a system that knows only how much came from credit.

Response

  • uuid · number string
    The document's identity. Numbers run INV-, ADV- (advances), CRN-, DBN- per year.
  • status string
    accepted · warnings · rejected · pending (signed, ZATCA gave no verdict yet — we send it again automatically).
  • totals object
    Strings with two decimals: lines − discount = taxable and taxable + vat = grand to the halala. taxable, vat and grand are exactly the signed figures; lines and discount show a document discount as you sent it, where the XML spreads it into the lines.
    • lines string
      Sum of the line nets.
    • discount string
      The document discount — including a rounding halala on VAT-inclusive prices, which appears even when you sent none.
    • taxable string
      What VAT was charged on.
    • vat · grand · currency string
      VAT, total with VAT, and SAR.
    • prepaid · payable string
      Advances taken off, VAT included, and what the buyer is asked for after them. Equal to 0.00 and grand when nothing is deducted.
    • line_discounts string
      Present when any line was discounted.
  • prepayments · prepayment_remaining array · string
    The advances this invoice took off (number, amount, taxable, vat); on a prepayment invoice, what is left of it.
  • lines array
    As sent, plus net_unit_price on a discounted line and each line's total.
  • zatca object
    channel (clearance or reporting), icv, submitted_at, warnings, and rejection when refused.
  • links object
    view is the one to put in front of a person: it opens in any browser with no key. xml, pdf and qr need the bearer key.
Standard (B2B)
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()
Simplified (B2C)
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

GET /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

  • kind string default invoice
    invoice · credit · debit · all
  • status string
    accepted · warnings · rejected · pending
  • type string
    standard · simplified
  • since string
    An ISO 8601 timestamp: only documents created after it. Feed back the newest created_at you have seen. An unreadable value returns nothing, never everything.
  • page integer default 1
    Page number.
  • per_page integer default 25
    1 to 100.
Request
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

GET /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.

Request
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

GET /v1/invoices/{uuid}/pdf

Every issued document has four downloads, under the same bearer key:

GET /v1/invoices/{uuid}/xml The e-invoice XML — for a B2B invoice or note, the copy ZATCA cleared and stamped
GET /v1/invoices/{uuid}/pdf PDF/A-3, Arabic and English, with the QR code and the XML embedded
GET /v1/invoices/{uuid}/qr.svg The QR code alone, as SVG
GET /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.

Save the PDF
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

POST /v1/invoices/{uuid}/email

Emails the PDF. to is optional and defaults to the account owner's address.

Body

  • to string
    The recipient. Defaults to the account owner.
Request
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

POST /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

  • kind string required
    credit or debit.
  • invoice_uuid string required
    The issued invoice the note adjusts.
  • reason string required
    Why the invoice is adjusted (BR-KSA-17). Printed on the note.
  • lines array of objects
    What is credited or charged. Omit with full or reverses.
  • full boolean
    Credit the whole open balance, or (debit) restore everything written off.
  • reverses string
    Debit only: the uuid of one credit note to undo.
  • external_id string
    Idempotency key, namespaced by kind.
  • device_id integer
    Optional: the device of the invoice's branch that signs the note.
Partial refund
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

POST /v1/branches

Adds an outlet under the establishment's VAT number. It cannot issue until a device of its own is connected.

Body

  • name string required
    The branch name, e.g. «فرع الملز».
  • short_address string required
    The branch's national short address. Street, district, city and postal code resolve from it.
  • cr_number string
    The branch's own 10-digit commercial registration.
  • street · building_number · district · city · postal_zone string
    Only when the short address cannot be resolved (address_unresolved).
Request
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

POST /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

  • otp string required
    A fresh six-digit code from the Fatoora portal — one per device, single-use, valid one hour.
  • name string
    What the device is called, e.g. «كاشير 1».
Request
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

GET /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.

Request
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

POST /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.

Request
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

DELETE /v1/devices/{id}

The documents it issued stay exactly as they are. Revoke its certificate from the Fatoora portal's device list as well.

Request
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
}
Questions about an integration? Contact us — we answer during business hours.