ZATCA Clearance API

Integration guide · ERPNext + KSA Compliance (ZATCA Phase 2)

ZATCA Clearance API

How an external ERP sends tax invoices to ERPNext, which signs them through the KSA Compliance app, clears or reports them with ZATCA (Fatoora), and returns the signed XML and Phase 2 QR code for printing and sharing.

Base URL https://erpnext-3aim.k.frappe.cloud Framework Frappe / ERPNext v16 · REST API v1 ZATCA app ksa_compliance v0.61 Format JSON, UTF-8

01Overview & flow

ERPNext is the e-invoice generation solution (EGS) registered with ZATCA. The KSA Compliance app owns the CSID, invoice counter (ICV) and previous-invoice hash chain (PIH). The client ERP stays the customer-facing system and calls ERPNext for every tax invoice.

  1. Sync masters Client ERPMake sure the customer, address and items exist in ERPNext. Upsert before each invoice or sync on a schedule.
  2. Create and submit the invoice Client ERPPOST a Sales Invoice with docstatus: 1. Submission triggers the ZATCA process.
  3. Sign, hash and send to ZATCA ERPNextThe app builds UBL 2.1 XML, signs it, embeds the QR, stores everything in a Sales Invoice Additional Fields record, and calls the clearance API (B2B) or reporting API (B2C).
  4. Read the result Client ERPPoll the Additional Fields record or receive a webhook. Wait for Accepted or Accepted with warnings.
  5. Print and share Client ERPRender the invoice using ERPNext's invoice number, UUID and QR. For B2B, attach the XML or the ZATCA PDF/A-3.

ZATCA results are not on the Sales Invoice. The app writes them to a separate DocType, Sales Invoice Additional Fields (SIAF), one record per send attempt, linked by sales_invoice. Always read the record with is_latest = 1.

02Authentication

Use token authentication with a dedicated API user. Generate keys in ERPNext under User → API Access → Generate Keys. Give the user a restricted role that can create and read Customers, Addresses, Items and Sales Invoices, and read Sales Invoice Additional Fields and ZATCA Integration Log.

Header on every request
Authorization: token <api_key>:<api_secret>
Content-Type: application/json
Accept: application/json
GET/api/method/frappe.auth.get_logged_user

Check that the credentials work. Call this once at startup.

Response 200
{ "message": "zatca-api@client.sa" }

03Conventions

Every DocType is exposed at /api/resource/{DocType}. URL-encode spaces in DocType names (Sales%20Invoice). Resource calls return {"data": …}; method calls return {"message": …}.

Query parameterUseExample
fieldsColumns to return in a list["name","grand_total"]
filtersAND conditions[["custom_vat_registration_number","=","3101..."]]
order_bySortcreation desc
limit_start / limitPaginationlimit_start=0&limit=100

ZATCA field names on this site

Confirmed against ksa_compliance v0.61.11. Re-check after an app upgrade with GET /api/method/frappe.desk.form.load.getdoctype?doctype=<DocType>.

DocTypeFields used in this guide
Customercustom_vat_registration_number, custom_additional_ids (table: type_name, type_code, value)
Addresscustom_building_number, custom_area
Sales Invoicecustom_return_reason, custom_return_against_additional_references, custom_zatca_discount_reason
Sales Invoice Itemcustom_zatca_item_tax_category, custom_zatca_discount_reason
Sales Invoice Additional Fieldssales_invoice, is_latest, integration_status, uuid, qr_code, invoice_hash, previous_invoice_hash, invoice_counter, invoice_type_code, invoice_type_transaction, invoice_xml, validation_errors, validation_messages, last_attempt
ZATCA Integration Loginvoice_reference, invoice_additional_fields_reference, status, zatca_status, zatca_message, zatca_http_status_code

One field to add yourself. custom_external_ref (the client ERP's invoice number) doesn't exist yet. Create it on Sales Invoice as a Data custom field with Unique checked, so a retried POST can't create a duplicate invoice and break the hash chain.

04Reference data

Read these once and cache the names. They are used as link values in invoices.

EndpointWhat you need from it
GET /api/resource/CompanySeller company name. One company per ZATCA VAT registration.
GET /api/resource/ZATCA Business SettingsWhich companies are onboarded, the Fatoora server (Sandbox / Simulation / Production) and sync mode (Live / Batches)
GET /api/resource/Sales Taxes and Charges TemplateTemplate for standard VAT 15%, zero-rated and exempt sales, e.g. KSA VAT 15% - Demo, KSA VAT 15% - Standard Rated - 3AIM
GET /api/resource/UOMUnits of measure used on items
GET /api/resource/Mode of PaymentPayment means (cash, bank transfer, card)
GET /api/resource/Customer GroupRequired when creating customers

Companies on this site

Demo CompanyVAT 300000000000003
ZATCA: Sandbox · Live sync. Use for integration testing.
شركة الإتجاه الصحيح للتجارةVAT 311753411100003
ZATCA: Production · Live sync. Every submit goes to ZATCA for real.
3 AI Minds · Rawasi · Spare7PlusNot onboarded
No ZATCA Business Settings. Invoices for these companies are not sent to ZATCA.

With Live sync the result is written right after submit. With Batches the scheduler sends invoices later, so expect Ready For Batch for a while.

05Customers & items

B2B (standard) invoices need the buyer's VAT number and full Saudi national address. B2C (simplified) invoices can use a walk-in customer.

Customer

GET/api/resource/Customer?filters=[["custom_vat_registration_number","=","310122393500003"]]

Look up a customer by VAT number. An empty data array means the customer needs to be created.

POST/api/resource/Customer
FieldNotes
customer_namerequiredLegal name as registered
customer_typeCompany for B2B, Individual for B2C
customer_groupFrom reference data
custom_vat_registration_number15-digit VAT number. Required for B2B. This is the field the KSA Compliance app reads.
tax_idSet to the same VAT number, for standard ERPNext reports
custom_additional_idsTable of other buyer IDs (CRN, MOM, MLS, 700, SAG, NAT, GCC, IQA, PAS, OTH) when there is no VAT number. Row: type_name, type_code, value
Request
{
  "customer_name": "Al Noor Trading Co.",
  "customer_type": "Company",
  "customer_group": "Commercial",
  "custom_vat_registration_number": "310122393500003",
  "tax_id": "310122393500003",
  "custom_additional_ids": [
    { "type_name": "Commercial Registration number", "type_code": "CRN", "value": "1010123456" }
  ]
}

The type_name values must match the rows the app pre-fills on a new Customer. Open one Customer in the desk to copy the exact labels.

PUT/api/resource/Customer/{name}

Update changed fields only. Send just the keys that changed.

Address

POST/api/resource/Address

Create the buyer's billing address and link it to the customer. ZATCA rejects B2B invoices without a building number, street, district, city and 5-digit postal code.

Request
{
  "address_title": "Al Noor Trading Co.",
  "address_type": "Billing",
  "address_line1": "King Fahd Road",
  "custom_building_number": "7765",
  "custom_area": "Al Olaya",
  "city": "Riyadh",
  "pincode": "12214",
  "country": "Saudi Arabia",
  "is_primary_address": 1,
  "links": [
    { "link_doctype": "Customer", "link_name": "Al Noor Trading Co." }
  ]
}

Item

GET/api/resource/Item?filters=[["item_code","=","SKU-1042"]]

Check whether the item exists.

POST/api/resource/Item
Request
{
  "item_code": "SKU-1042",
  "item_name": "Thermal paper roll 80mm",
  "item_group": "Products",
  "stock_uom": "Nos",
  "is_stock_item": 0
}

Set is_stock_item: 0 if stock is managed in the client ERP, so ERPNext doesn't block invoices for missing stock.

06Tax invoice

One endpoint covers standard invoices, simplified invoices, credit notes and debit notes. Submitting the document (docstatus: 1) starts the ZATCA process.

Create & submit

POST/api/resource/Sales Invoice
FieldNotes
customerrequiredCustomer name from step 5
companyrequiredSeller company. Must have ZATCA Business Settings.
posting_date / posting_timeSet set_posting_time: 1 to use your own values. Must not be in the future.
currencySAR
itemsrequireditem_code, qty, rate, uom
items[].custom_zatca_item_tax_categoryStandard rate, zero rated, exempt or out of scope. Zero-rated and exempt lines also need an exemption reason.
items[].custom_zatca_discount_reasonReason code for a line discount, if any
taxes_and_chargesVAT template name. ERPNext fills the taxes table from it.
custom_zatca_discount_reasonReason for a document-level discount, if any
custom_external_refClient ERP's invoice number, unique (create this field first, see Conventions)
docstatus1 submits immediately; 0 saves a draft
Request · standard (B2B) invoice
{
  "customer": "Al Noor Trading Co.",
  "company": "Demo Company",
  "set_posting_time": 1,
  "posting_date": "2026-10-17",
  "posting_time": "10:24:00",
  "currency": "SAR",
  "custom_external_ref": "INV-ERP-88213",
  "taxes_and_charges": "KSA VAT 15% - Demo",
  "items": [
    { "item_code": "SKU-1042", "qty": 20, "rate": 12.50, "uom": "Nos",
      "custom_zatca_item_tax_category": "Standard rate" },
    { "item_code": "SKU-2210", "qty": 2, "rate": 340.00, "uom": "Nos",
      "custom_zatca_item_tax_category": "Standard rate" }
  ],
  "docstatus": 1
}
Response 200 (trimmed)
{
  "data": {
    "name": "Demo-SINV-2026-00009",
    "docstatus": 1,
    "net_total": 930.0,
    "total_taxes_and_charges": 139.5,
    "grand_total": 1069.5,
    "custom_external_ref": "INV-ERP-88213"
  }
}

Store data.name. This is the invoice number ZATCA receives and must appear on the printed invoice. See Sharing.

The response has no ZATCA status. Read it from Sales Invoice Additional Fields as shown in section 7.

PUT/api/resource/Sales Invoice/{name}

Submit a draft

If you created the invoice with docstatus: 0 to review totals first, submit it with this body:

{ "docstatus": 1 }

Simplified (B2C) invoice

Same call. Use a walk-in customer of type Individual with no VAT number. With Let the system decide (both) in ZATCA Business Settings, the app detects it as simplified (invoice_type_transaction starting 02) and reports it instead of clearing it. It can be shared with the buyer straight away and must be reported to ZATCA within 24 hours.

Credit note

POST/api/resource/Sales Invoice

Use for returns, discounts after invoicing and corrections. Quantities are negative. A reason is required by ZATCA. For a note against several invoices, list the extra ones in custom_return_against_additional_references.

Request
{
  "customer": "Al Noor Trading Co.",
  "company": "Demo Company",
  "is_return": 1,
  "return_against": "Demo-SINV-2026-00009",
  "custom_return_reason": "Damaged goods returned",
  "taxes_and_charges": "KSA VAT 15% - Demo",
  "items": [
    { "item_code": "SKU-1042", "qty": -5, "rate": 12.50 }
  ],
  "docstatus": 1
}

Debit note

POST/api/resource/Sales Invoice

Use to charge extra against an issued invoice. Positive quantities, with is_debit_note: 1 and return_against set to the original invoice.

Request
{
  "customer": "Al Noor Trading Co.",
  "company": "Demo Company",
  "is_debit_note": 1,
  "return_against": "Demo-SINV-2026-00009",
  "custom_return_reason": "Freight charge not billed",
  "taxes_and_charges": "KSA VAT 15% - Demo",
  "items": [
    { "item_code": "FREIGHT", "qty": 1, "rate": 150.00 }
  ],
  "docstatus": 1
}

07ZATCA result & QR

Treat an invoice as issued only when ZATCA has accepted it. The status is the integration_status field of the latest Sales Invoice Additional Fields record.

Ready For Batch/ Resend→ Acceptedor Accepted with warningsor Rejected
integration_statusMeaningClient ERP action
Ready For BatchSigned, waiting to be sentWait. Don't print or send a B2B invoice.
ResendSend failed (ZATCA unreachable or server error); will be retriedWait. Alert if it stays for long.
AcceptedCleared (B2B) or reported (B2C)Fetch the XML and QR, then share with the buyer.
Accepted with warningsAccepted; ZATCA returned warningsSame as Accepted. Log validation_messages and fix the data for next time.
RejectedZATCA refused it (data error)Read validation_errors, fix the data, issue a new invoice. Never share it.
CorrectedA rejected record that was corrected; a newer record existsRead the record with is_latest = 1 instead.
DuplicateZATCA already has this invoiceCheck the earlier record before doing anything else.
Clearance switched offClearance disabled for the taxpayerTreat as reported; confirm the company's setup.

B2B or B2C: invoice_type_transaction starting 01 is standard (B2B), 02 is simplified (B2C). invoice_type_code is 388 for an invoice, 381 for a credit note and 383 for a debit note.

GET/api/resource/Sales Invoice Additional Fields?filters=…&fields=…

Read one invoice's status, UUID and QR

Request
GET /api/resource/Sales Invoice Additional Fields
  ?filters=[["sales_invoice","=","Demo-SINV-2026-00009"],["is_latest","=",1]]
  &fields=["name","integration_status","uuid","qr_code","invoice_hash",
           "invoice_counter","invoice_type_code","invoice_type_transaction",
           "validation_errors","validation_messages","last_attempt"]
Response 200 (trimmed)
{
  "data": [{
    "name": "Demo-SINV-2026-00009-AdditionalFields-8",
    "integration_status": "Accepted",
    "uuid": "7065bbcd-b2f5-4dd8-9aa2-9aa823132794",
    "qr_code": "AQxEZW1vIENvbXBhbnkCDzMwMDAw…",
    "invoice_counter": 8,
    "invoice_type_code": "388",
    "invoice_type_transaction": "0100000"
  }]
}

qr_code is the base64 TLV string (around 500 characters). Render it as a QR image on the printed invoice. Phase 2 QRs hold 9 tags including hash, signature and public key, so print them at least 30 × 30 mm.

GET/api/resource/Sales Invoice Additional Fields?filters=…&fields=…

Poll invoices still waiting or rejected

Example
GET /api/resource/Sales Invoice Additional Fields
  ?filters=[["is_latest","=",1],
            ["integration_status","in",["Ready For Batch","Resend","Rejected"]]]
  &fields=["name","sales_invoice","integration_status","last_attempt","modified"]
  &order_by=modified asc
  &limit=100

Map sales_invoice back to your order through custom_external_ref. Poll every 1–2 minutes, or use the webhook instead.

GET/api/resource/Sales Invoice Additional Fields/{name}

Get the signed XML

The full UBL 2.1 XML is stored in the invoice_xml field of the record, not as a File attachment. Request it only when needed, since it is large:

Example
GET /api/resource/Sales Invoice Additional Fields
  ?filters=[["sales_invoice","=","Demo-SINV-2026-00009"],["is_latest","=",1]]
  &fields=["name","invoice_xml"]

The QR also sits inside the XML at cac:AdditionalDocumentReference[cbc:ID='QR']/…/cbc:EmbeddedDocumentBinaryObject, which is a reliable fallback.

For a PDF/A-3 with the XML embedded, use the Download ZATCA PDF button on the record in the desk. Its whitelisted method can be called from the API too; find its path in the app's source before relying on it.

For B2B clearance, ZATCA returns its own stamped copy. Check the e_invoice_file on the matching ZATCA Integration Log entry and confirm in Sandbox which copy (that file or invoice_xml) carries ZATCA's stamp before you choose what to send.

08Retry

If ZATCA was unreachable, the record goes to Resend and the app sends it again on its schedule. Don't create a new invoice, or the hash chain will break.

GET/api/resource/ZATCA Integration Log?filters=…&fields=…

See every attempt and ZATCA's raw answer for an invoice:

Example
GET /api/resource/ZATCA Integration Log
  ?filters=[["invoice_reference","=","Demo-SINV-2026-00009"]]
  &fields=["name","status","zatca_status","zatca_http_status_code","zatca_message","creation"]
  &order_by=creation desc

To force a resend sooner, use the resend action on the record in the desk, or call the app's whitelisted method behind it (find its path in the app's source by searching for @frappe.whitelist).

Rejected is not the same as Resend. A rejected invoice has a data error. Resending it unchanged will be rejected again. Issue a credit note if it was already accepted, otherwise correct the data and create a new invoice.

09Webhook callback

Instead of polling, ERPNext can call the client ERP when the app updates the ZATCA status. Hook the Sales Invoice Additional Fields DocType, not Sales Invoice. Set it up once, in the UI or via the API.

POST/api/resource/Webhook
Request
{
  "webhook_doctype": "Sales Invoice Additional Fields",
  "webhook_docevent": "on_update_after_submit",
  "request_url": "https://client-erp.example.sa/hooks/zatca",
  "request_method": "POST",
  "request_structure": "JSON",
  "condition": "doc.is_latest and doc.integration_status in ('Accepted','Accepted with warnings','Rejected')",
  "enable_security": 1,
  "webhook_secret": "<shared-secret>",
  "webhook_json": "{\"invoice\": \"{{ doc.sales_invoice }}\", \"record\": \"{{ doc.name }}\", \"status\": \"{{ doc.integration_status }}\", \"uuid\": \"{{ doc.uuid }}\"}"
}

With Live sync the status may already be final when the record is first submitted. Add a second webhook with webhook_docevent: "on_submit" and the same condition so those invoices aren't missed.

With security enabled, ERPNext signs the body with an HMAC-SHA256 in the X-Frappe-Webhook-Signature header. Verify it before trusting the call, then fetch the full record with a GET.

10Errors

Frappe returns the error type in exc_type and readable messages in _server_messages (a JSON-encoded list).

HTTPexc_typeTypical cause
401 / 403AuthenticationError, PermissionErrorWrong keys or the API user's role is missing a permission
404DoesNotExistErrorWrong document name or a linked record (customer, item, template) not created yet
409DuplicateEntryErrorRecord already exists, or the same custom_external_ref was sent twice. Fetch it instead of creating it.
417ValidationErrorBusiness rule failed: missing VAT number, incomplete address, future posting date, closed period, missing tax category
500—Server or ZATCA app error. Retry with backoff and alert if it persists.

ZATCA's own rejection messages (for example BR-KSA-… rule codes) are not returned as HTTP errors. Read them from validation_errors and validation_messages on the Additional Fields record, or zatca_message on the ZATCA Integration Log.

11Sharing with customers

The client ERP can print and send invoices itself, as long as what it sends matches what ZATCA accepted.

  • B2B: share only after status is Accepted or Accepted with warnings. Deliver electronically as XML, or PDF/A-3 with the XML embedded.
  • B2C: share immediately at the point of sale with the Phase 2 QR printed on it.
  • QR: always use qr_code from ERPNext. Never generate one in the client ERP.
  • Content: number, date, seller and buyer details, lines, VAT and totals must match the XML exactly.
  • Invoice number: print ERPNext's number (e.g. Demo-SINV-2026-00009) as the tax invoice number, with the client ERP number as a reference. Alternatively configure ERPNext naming to use the client ERP's sequential number.
  • Cancellation: accepted invoices can't be cancelled. Issue a credit note.

12Go-live checklist

  • ZATCA onboarding completed for each VAT registration: ZATCA Business Settings, CLI set up, CSR, OTP from Fatoora portal, compliance checks, production CSID
  • 3 AI Minds, Rawasi and Spare7Plus onboarded if they will issue invoices (none has ZATCA Business Settings yet; 3 AI Minds has no VAT number)
  • Seller company has VAT number, CR and full national address
  • custom_external_ref custom field created on Sales Invoice, marked Unique
  • Field names in Conventions re-checked after any ksa_compliance upgrade
  • Dedicated API user with a restricted role; keys stored in a secrets vault
  • Invoices submitted in a single queue, one at a time, so the hash chain stays in order
  • Integration tested on Demo Company (Sandbox) before any call to the Production company
  • Rejected and Resend handling, and alerting, tested in Sandbox or Simulation
  • Print layout shows ERPNext invoice number, UUID and a readable Phase 2 QR
  • B2B delivery includes the XML or PDF/A-3
  • Credit and debit notes tested end to end
  • Delivery and format rules re-checked against ZATCA's latest implementation guidelines