ZATCA Gateway Developer Integration Guide
Prefer an offline copy, or need something to attach to a ticket?
Download PDF versionZATCA Gateway
Developer Integration Guide
Sandbox environment — live and ready to test against
| Base URL | https://sandbox.botsolutions.tech |
| Sandbox Playground | https://playground.botsolutions.tech |
| API Key | 79fa027bb5dee4f |
| API Secret | 23f6cd13570e40b |
This sandbox runs over real HTTPS (Let’s Encrypt). The credentials above are sandbox-only and carry no real financial weight — test as much as you like.
Every request needs this header:
Authorization: token 79fa027bb5dee4f:23f6cd13570e40b
Content-Type: application/json
Try it right now
curl -X POST https://sandbox.botsolutions.tech/api/method/zatca_gateway.api.submit_invoice \
-H "Authorization: token 79fa027bb5dee4f:23f6cd13570e40b" \
-H "Content-Type: application/json" \
-d '{
"external_invoice_id": "YOUR-TEST-0001",
"invoice_type": "simplified",
"items": [{"description": "Test item", "qty": 1, "rate": 100, "tax_rate": 15}],
"payment_method": "cash"
}'You’ll get back a real ZATCA-cleared invoice — UUID, invoice hash, QR
code, and the signed XML — in the response. Change
external_invoice_id for each new test; it’s your
idempotency key (see below). Or skip the command line entirely and use
the interactive Sandbox Playground at
https://playground.botsolutions.tech — pick an endpoint,
edit the example, press Send.
What this API does
One request creates the customer (if needed), the invoice, and sends it to ZATCA for clearance/reporting — all in a single call. Your system never needs to manage ERPNext customers, items, or invoices directly.
Contents
- Authentication
POST submit_invoicePOST submit_credit_noteGET invoice_statusGET invoice_pdfGET invoice_xml- Response fields explained
- Error handling — how you find out what’s wrong
- Idempotency
- Payment method behavior
Authentication
Standard REST API token authentication — nothing custom. Every
request needs the
Authorization: token <api_key>:<api_secret>
header shown above.
What the API key does and doesn’t control. Any request with a valid key can call every endpoint successfully — this key isn’t scoped to a subset of actions. Its purpose is giving your integration a separate, independently revocable identity, distinct from any BOT Solutions admin account. If this key is ever compromised, or when this project moves to production, tell BOT Solutions and it will be regenerated (the old one is invalidated immediately).
Transport security: this sandbox already runs over
real HTTPS (sandbox.botsolutions.tech) — the token is
equivalent to a password, so always use the HTTPS URL, never a
plain-HTTP one.
POST
/api/method/zatca_gateway.api.submit_invoice
Creates and clears/reports a single invoice with ZATCA in one call.
Request fields
| Field | Required? | Type | Constraints |
|---|---|---|---|
external_invoice_id |
Required | string | 1–140 chars. No leading/trailing whitespace. This is your idempotency key. |
invoice_type |
Required | string | Exactly "standard" or "simplified". |
currency |
Optional | string | If present, must be exactly "SAR" — v1 supports SAR
only. |
posting_date |
Optional | string | Format YYYY-MM-DD. Defaults to today. Cannot be in the
future or more than 1 day in the past. |
customer |
Required for standard, optional for
simplified |
object | See below. |
items |
Required | array | At least 1 line. See below. |
payment_method |
Optional | string | One of "cash", "card",
"credit", "bank_transfer". Materially changes
how the invoice posts — see §10. |
include_xml |
Optional | boolean | Overrides your account’s XML-in-response default for this call only.
true forces xml into the response;
false omits it. Omit to use your account default (on by
default). |
customer object
| Field | Required? | Constraints |
|---|---|---|
name |
Required for standard |
Display name. |
vat_number |
Required for standard, optional for
simplified |
Exactly 15 digits, must start and end with 3. |
phone |
Optional | Used to match an existing customer on simplified
invoices. |
address |
Required for standard |
See below. |
customer.address object (only checked
for standard invoices)
| Field | Constraints |
|---|---|
street |
Free text, required |
building_number |
Exactly 4 digits, required |
city |
Free text, required |
district |
Free text, required |
postal_code |
Exactly 5 digits, required |
country_code |
Optional, e.g. "SA" |
For simplified invoices with no customer
block at all, the invoice bills to the sandbox’s default walk-in
customer — the normal path for anonymous, point-of-sale-style sales.
item object (each entry in
items)
| Field | Required? | Constraints |
|---|---|---|
description |
Required | Free text; becomes the invoice line description. |
qty |
Required | Must be > 0. |
rate |
Required | Must be >= 0. |
tax_rate |
Required | Must be 15 (the only rate configured on this sandbox).
All lines on one invoice must share the same tax_rate. |
discount_amount |
Optional | Defaults to 0. Must be >= 0 and
<= qty × rate. |
Example
{
"external_invoice_id": "POS-2026-00451",
"invoice_type": "simplified",
"items": [
{ "description": "Consulting service", "qty": 1, "rate": 250.00, "tax_rate": 15 }
],
"payment_method": "cash",
"include_xml": true
}POST
/api/method/zatca_gateway.api.submit_credit_note
Returns some or all lines of a previously successfully sent invoice.
| Field | Required? | Notes |
|---|---|---|
external_invoice_id |
Required | New, never-before-used ID for this credit note. |
original_external_invoice_id |
Required | Must be an invoice you previously sent with status Sent
and ZATCA status
Accepted/Accepted with warnings. |
reason |
Required | Free text. |
items |
Optional | Omit for a full return of every line. To return specific lines, list
them here — each is matched against the original invoice by
description + rate. |
include_xml |
Optional | Same per-request override as submit_invoice. |
Partial-return matching is validated, not silently
trusted: - A description/rate that
doesn’t match any line on the original → clean 400 naming
exactly which line failed. - A requested qty exceeding
what’s left on the matched line → clean 400 stating both
numbers.
One thing worth knowing: once a credit note’s
external_invoice_id is recorded, a partial-match failure
still consumes that ID. If you correct items and resend,
use a new external_invoice_id — resending
the same ID with different content returns 409, same as
everywhere else in this API.
Example — full return
{
"external_invoice_id": "POS-2026-00451-CN1",
"original_external_invoice_id": "POS-2026-00451",
"reason": "Customer returned goods"
}Example — partial return (real captured response)
Original invoice: Widget A (qty 3, rate 100),
Widget B (qty 1, rate 50). Returning 1 unit of Widget A
only:
{
"external_invoice_id": "CN-PARTIAL-0002",
"original_external_invoice_id": "ORIGINAL-INVOICE-ID",
"reason": "Only 1 Widget A was defective",
"items": [{ "description": "Widget A", "qty": 1, "rate": 100, "tax_rate": 15 }]
}The response correctly reflects only the returned unit
(grand_total: -115.00), never confused with the other
line.
GET
/api/method/zatca_gateway.api.invoice_status
Query param: external_invoice_id (required).
Returns the same response shape as the original submit call once
ZATCA has processed it, or {"status": "pending_zatca", ...}
if still waiting. 404 if the ID was never submitted. Use
this to poll after a 502 — never resubmit to “retry.”
GET
/api/method/zatca_gateway.api.invoice_pdf
Query param: external_invoice_id (required). Returns the
ZATCA-compliant PDF for a successfully sent invoice. 404
otherwise.
GET
/api/method/zatca_gateway.api.invoice_xml
Query param: external_invoice_id (required). Returns the
raw signed UBL 2.1 XML directly —
Content-Type: application/xml, downloadable as a file — for
an invoice that’s already been sent. This is the exact XML ZATCA
cleared, not a reconstruction. Works regardless of your account’s
include_xml setting — use this any time to re-fetch the XML
later (e.g. for an audit), even if you suppressed it inline at
submission time.
curl "https://sandbox.botsolutions.tech/api/method/zatca_gateway.api.invoice_xml?external_invoice_id=POS-2026-00451" \
-H "Authorization: token 79fa027bb5dee4f:23f6cd13570e40b" \
-o invoice.xmlResponse fields explained
| Field | Meaning |
|---|---|
status |
accepted, accepted_with_warnings,
rejected, or duplicate. rejected
still returns HTTP 200 — the gateway itself worked, ZATCA’s own
validation failed. |
erp_invoice |
Internal reference ID, useful for support requests. |
zatca_uuid, invoice_hash |
ZATCA’s own clearance identifiers. |
qr_code |
Base64 TLV QR data, ready to print. |
warnings |
ZATCA’s advisory notes, if any — not a failure signal. |
errors |
Only populated when status is
rejected. |
zatca_raw_message |
ZATCA’s complete raw diagnostic response, present whenever one is on
record — worth logging even when
errors/warnings look empty. |
xml |
Base64-encoded invoice XML, inline. On by default.
Override per-request with include_xml: true/false, or fetch
it any time later via GET invoice_xml. |
total, vat_total,
grand_total |
For your own reconciliation. |
accepted_with_warnings is a normal, successful
result — not a partial failure. Only rejected
means ZATCA actually declined the invoice.
Error handling
Every error response says exactly what was wrong. Real captured example — one request, four separate problems, all reported at once:
{
"error": "ValidationFailed",
"message": "Payload validation failed",
"details": [
{ "field": "external_invoice_id", "message": "Required" },
{ "field": "customer", "message": "Required for standard invoices" },
{ "field": "items[0].description", "message": "Required" },
{ "field": "items[0].qty", "message": "Must be > 0" }
]
}Every problem, exact field path, specific reason — not a generic error and not just the first issue found.
| HTTP Status | Meaning | What to do |
|---|---|---|
400 ValidationFailed |
Malformed request. Nothing was created. | Fix the fields in details and resend. |
409 ConflictError |
Reused external_invoice_id with different content. |
Use a new ID — this is an integration bug to fix. |
502 ZatcaUnavailable |
ZATCA itself timed out. Invoice still exists in ERPNext. | Do not resubmit. Poll invoice_status; a background
retry happens automatically. |
404 NotFound |
Unknown external_invoice_id on a status/PDF/XML
lookup. |
Check the ID. |
What actually happens if ZATCA is down
- Your invoice is created and submitted in ERPNext immediately — that part never depends on ZATCA being reachable.
- The gateway tries ZATCA, bounded by a timeout (25 seconds by
default). If it doesn’t respond, you get
502right away — never left hanging. - Nothing is lost or duplicated. The invoice sits ready to send.
- A background job retries automatically, once per hour, with zero action needed from you.
- Poll
GET invoice_statusany time; once ZATCA responds you get the same full result shape as a normal submit call. - However long the outage lasts, this keeps retrying. You never need to resubmit the same invoice.
Idempotency
external_invoice_id is your idempotency key:
- Same ID + same content, sent any number of times → same
200result every time. Safe to retry blindly. - Same ID + different content →
409. Fix your ID generation, don’t just try a new ID. - Always use a genuinely new ID for a genuinely new invoice.
This was verified under a simulated race condition (two identical requests at effectively the same instant) — still produced exactly one invoice, not a duplicate or a crash.
Payment method behavior
payment_method |
Effect |
|---|---|
"cash", "card",
"bank_transfer" |
Invoice posts as immediately paid. ZATCA’s payment means code is populated. |
"credit", or omitted |
Invoice posts as a normal outstanding receivable. No payment means code (ZATCA accepts this as a warning, not a rejection). |
This is a deliberate accounting choice: marking a credit sale as
instantly paid would be incorrect. If your system doesn’t yet know at
invoice time whether payment was collected, "credit" or
omitting the field is always the safe choice.
Questions during integration testing — reach out to your BOT Solutions contact directly.