BOT Solutions

BOT Solutions

ZATCA Gateway Developer Integration Guide

Everything a developer needs to integrate — field reference, error handling, and real captured examples.

Prefer an offline copy, or need something to attach to a ticket?

Download PDF version

ZATCA 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

  1. Authentication
  2. POST submit_invoice
  3. POST submit_credit_note
  4. GET invoice_status
  5. GET invoice_pdf
  6. GET invoice_xml
  7. Response fields explained
  8. Error handling — how you find out what’s wrong
  9. Idempotency
  10. 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.xml

Response 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

  1. Your invoice is created and submitted in ERPNext immediately — that part never depends on ZATCA being reachable.
  2. The gateway tries ZATCA, bounded by a timeout (25 seconds by default). If it doesn’t respond, you get 502 right away — never left hanging.
  3. Nothing is lost or duplicated. The invoice sits ready to send.
  4. A background job retries automatically, once per hour, with zero action needed from you.
  5. Poll GET invoice_status any time; once ZATCA responds you get the same full result shape as a normal submit call.
  6. However long the outage lasts, this keeps retrying. You never need to resubmit the same invoice.

Idempotency

external_invoice_id is your idempotency key:

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.