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.
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.
- Sync masters Client ERPMake sure the customer, address and items exist in ERPNext. Upsert before each invoice or sync on a schedule.
- Create and submit the invoice Client ERPPOST a Sales Invoice with
docstatus: 1. Submission triggers the ZATCA process. - 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 Fieldsrecord, and calls the clearance API (B2B) or reporting API (B2C). - Read the result Client ERPPoll the Additional Fields record or receive a webhook. Wait for Accepted or Accepted with warnings.
- 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.
Authorization: token <api_key>:<api_secret>
Content-Type: application/json
Accept: application/jsonCheck that the credentials work. Call this once at startup.
{ "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 parameter | Use | Example |
|---|---|---|
| fields | Columns to return in a list | ["name","grand_total"] |
| filters | AND conditions | [["custom_vat_registration_number","=","3101..."]] |
| order_by | Sort | creation desc |
| limit_start / limit | Pagination | limit_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>.
| DocType | Fields used in this guide |
|---|---|
| Customer | custom_vat_registration_number, custom_additional_ids (table: type_name, type_code, value) |
| Address | custom_building_number, custom_area |
| Sales Invoice | custom_return_reason, custom_return_against_additional_references, custom_zatca_discount_reason |
| Sales Invoice Item | custom_zatca_item_tax_category, custom_zatca_discount_reason |
| Sales Invoice Additional Fields | sales_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 Log | invoice_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.
| Endpoint | What you need from it |
|---|---|
| GET /api/resource/Company | Seller company name. One company per ZATCA VAT registration. |
| GET /api/resource/ZATCA Business Settings | Which companies are onboarded, the Fatoora server (Sandbox / Simulation / Production) and sync mode (Live / Batches) |
| GET /api/resource/Sales Taxes and Charges Template | Template for standard VAT 15%, zero-rated and exempt sales, e.g. KSA VAT 15% - Demo, KSA VAT 15% - Standard Rated - 3AIM |
| GET /api/resource/UOM | Units of measure used on items |
| GET /api/resource/Mode of Payment | Payment means (cash, bank transfer, card) |
| GET /api/resource/Customer Group | Required when creating customers |
Companies on this site
ZATCA: Sandbox · Live sync. Use for integration testing.
ZATCA: Production · Live sync. Every submit goes to ZATCA for real.
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
Look up a customer by VAT number. An empty data array means the customer needs to be created.
| Field | Notes |
|---|---|
| customer_namerequired | Legal name as registered |
| customer_type | Company for B2B, Individual for B2C |
| customer_group | From reference data |
| custom_vat_registration_number | 15-digit VAT number. Required for B2B. This is the field the KSA Compliance app reads. |
| tax_id | Set to the same VAT number, for standard ERPNext reports |
| custom_additional_ids | Table 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 |
{
"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.
Update changed fields only. Send just the keys that changed.
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.
{
"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
Check whether the item exists.
{
"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
| Field | Notes |
|---|---|
| customerrequired | Customer name from step 5 |
| companyrequired | Seller company. Must have ZATCA Business Settings. |
| posting_date / posting_time | Set set_posting_time: 1 to use your own values. Must not be in the future. |
| currency | SAR |
| itemsrequired | item_code, qty, rate, uom |
| items[].custom_zatca_item_tax_category | Standard rate, zero rated, exempt or out of scope. Zero-rated and exempt lines also need an exemption reason. |
| items[].custom_zatca_discount_reason | Reason code for a line discount, if any |
| taxes_and_charges | VAT template name. ERPNext fills the taxes table from it. |
| custom_zatca_discount_reason | Reason for a document-level discount, if any |
| custom_external_ref | Client ERP's invoice number, unique (create this field first, see Conventions) |
| docstatus | 1 submits immediately; 0 saves a draft |
{
"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
}{
"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.
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
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.
{
"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
Use to charge extra against an issued invoice. Positive quantities, with is_debit_note: 1 and return_against set to the original invoice.
{
"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.
| integration_status | Meaning | Client ERP action |
|---|---|---|
| Ready For Batch | Signed, waiting to be sent | Wait. Don't print or send a B2B invoice. |
| Resend | Send failed (ZATCA unreachable or server error); will be retried | Wait. Alert if it stays for long. |
| Accepted | Cleared (B2B) or reported (B2C) | Fetch the XML and QR, then share with the buyer. |
| Accepted with warnings | Accepted; ZATCA returned warnings | Same as Accepted. Log validation_messages and fix the data for next time. |
| Rejected | ZATCA refused it (data error) | Read validation_errors, fix the data, issue a new invoice. Never share it. |
| Corrected | A rejected record that was corrected; a newer record exists | Read the record with is_latest = 1 instead. |
| Duplicate | ZATCA already has this invoice | Check the earlier record before doing anything else. |
| Clearance switched off | Clearance disabled for the taxpayer | Treat 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.
Read one invoice's status, UUID and QR
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"]{
"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.
Poll invoices still waiting or rejected
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=100Map sales_invoice back to your order through custom_external_ref. Poll every 1–2 minutes, or use the webhook instead.
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:
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.
See every attempt and ZATCA's raw answer for an invoice:
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 descTo 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.
{
"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).
| HTTP | exc_type | Typical cause |
|---|---|---|
| 401 / 403 | AuthenticationError, PermissionError | Wrong keys or the API user's role is missing a permission |
| 404 | DoesNotExistError | Wrong document name or a linked record (customer, item, template) not created yet |
| 409 | DuplicateEntryError | Record already exists, or the same custom_external_ref was sent twice. Fetch it instead of creating it. |
| 417 | ValidationError | Business 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.
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_refcustom 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