Introduction
The Partner API is organized around REST. It has two surfaces, both authenticated by your single partner key:
| /api/partner/* | Managing merchants: create, update, connect to ZATCA, release seats. |
| /api/v1/* | Acting as a merchant — branches, devices, invoices, notes, documents — by adding the X-Merchant-Id header. |
All requests and responses are JSON, UTF-8. Send Arabic content as-is — names and addresses end up inside signed XML documents, exactly as you provide them. A conceptual walkthrough in Arabic lives in the Partner Guide; the merchant-facing invoice API has its own page at /docs/api.
Authentication
Authenticate every request with your partner key as a bearer token. Keys start with
ztkp_live_, are shown once when generated, and can be
rotated any time from the partner portal.
Keep the key server-side — never ship it in an app or a browser.
curl https://zatcatools.com/api/partner/account \
-H "Authorization: Bearer ztkp_live_..."
A missing or wrong key answers 401 unauthenticated.
Using a merchant key (ztk_) here is called out
explicitly — the error says it is a merchant key, so nobody debugs the wrong thing.
A suspended partnership answers 403 partner_suspended.
Acting as a merchant
There are no per-merchant keys. Your one partner key acts for any of your merchants on the /api/v1 surface by naming the merchant in a header:
curl https://zatcatools.com/api/v1/account \
-H "Authorization: Bearer ztkp_live_..." \
-H "X-Merchant-Id: 34"
| 400 merchant_required | The header is missing — a partner key alone names no merchant. |
| 404 not_found | No such merchant under your partnership. Merchants of other partners are invisible. |
| 403 seat_released | The merchant's seat was released — reinstate it to resume issuing. |
The Fatoora OTP
The constraint is only on the source of the code. Entering it happens inside your screens: show your merchant "enter the OTP from the Fatoora portal", pass what they type to connect, and poll status. The same rule applies to every branch device later: on the portal the merchant picks Onboard new solution unit/device, chooses how many codes to generate — one OTP per device — and each code is single-use and valid for one hour.
Errors
Errors use conventional HTTP status codes and one envelope. The
code is stable and machine-readable; the
message explains, and often names the exact fix.
{ "error": { "code": "seat_limit_reached", "message": "..." } }
| Code | Meaning |
|---|---|
| validation | A field is missing or malformed — the message names it. |
| seat_limit_reached | Every paid seat is in use. Release one, or contact us to raise the limit. |
| email_taken · vat_taken | That email / VAT number already belongs to an establishment. |
| merchant_incomplete | Connect refused before spending the OTP; missing lists the fields to PATCH first. |
| identity_locked | Name, VAT and CR are frozen once connected — the certificate binds them. |
| device_required · branch_required | A branch invoice must name both branch_id and device_id. |
| device_not_found · device_not_usable | The named device is not under that branch, or cannot sign right now. |
| quota_exceeded | Free-plan ceiling reached. Partner merchants are unlimited, so this signals a plan mismatch. |
Idempotency
Invoice and note creation accept an Idempotency-Key
header (or an external_id field) — your order id is the
natural choice. Retrying the same key returns the original document instead of
creating a second one, so network timeouts are always safe to retry. Keys are
namespaced per document kind: a refund can reuse its order's id.
Rate limits
/api/partner: 120 requests/minute. /api/v1: 60 requests/minute per key — enough for hundreds of tills in practice. Above that, write [email protected] and we raise it.
The merchant object
A merchant is a complete invoicing establishment with the Authority: its own certificate, its own invoice chain, unlimited issuing. Every merchant occupies one paid seat while active.
{
"id": 34,
"name": "مطعم البيك",
"vat_number": "310122393500003",
"email": "[email protected]",
"status": "connected", // awaiting_onboarding | trial | connected | failed | released
"zatca_environment": "production",
"certificate_expires_at": "2029-01-09",
"invoices_issued": 1240,
"seat": "active", // active | released
"created_at": "2026-09-01T09:24:32+03:00"
}
Your partner account
/api/partner/account
Your partnership at a glance — primarily the seat counters your billing screen needs.
{
"partner": {
"name": "Smart POS Co.",
"status": "active",
"seats": { "limit": 50, "used": 37, "remaining": 13 }
}
}
Create a merchant
/api/partner/merchants
Creates the establishment and occupies one seat. The seat cap is enforced atomically — concurrent creates can never oversell your limit.
| Field | Description |
|---|---|
| name REQUIRED | Establishment name as registered with ZATCA. |
| email REQUIRED | The owner's login email (passwordless sign-in to their own dashboard). |
| vat_number REQUIRED | 15 digits, starts and ends with 3 — the merchant's tax identity. |
| short_address optional | Saudi national short address (RRRD2929). We resolve street, district, city and postal code from it. Or spell them out: street, building_number, subdivision, city, postal_zone — explicit fields win over the resolution. |
| cr_number, street, building_number, city, postal_zone optional | Explicit details always win over what the short address resolves to. |
curl -X POST https://zatcatools.com/api/partner/merchants \
-H "Authorization: Bearer ztkp_live_..." \
-H "Content-Type: application/json" \
-d '{
"name": "مطعم البيك",
"email": "[email protected]",
"vat_number": "310122393500003",
"short_address": "RRRD2929"
}'
201
{
"merchant": { "id": 34, "status": "awaiting_onboarding", ... },
"onboarding_url": "https://zatcatools.com/partner/onboarding/34?...",
"seats": { "limit": 50, "used": 38, "remaining": 12 }
}
List merchants
/api/partner/merchants
/api/partner/merchants/{id}
All your merchants (newest first), or one. Returns
{ "merchants": [ ... ] } /
{ "merchant": { ... } } of
merchant objects.
Update a merchant
/api/partner/merchants/{id}
Completes or corrects establishment details before connecting. Send any subset of
the create fields (plus subdivision). Sending a
short_address re-resolves the full address from it.
Identity fields — name, vat_number, cr_number —
lock the moment the merchant connects (409 identity_locked):
the certificate the Authority issues binds them. Address fields stay editable; shops move.
Connect to ZATCA
/api/partner/merchants/{id}/connect
Onboards the merchant's establishment to Fatoora — CSR, certificate, the six
compliance checks — from inside your screen. The one thing you collect from
the merchant is the OTP.
Completeness is checked before the single-use code is spent: missing fields
answer merchant_incomplete with a
missing array, costing nothing.
| Field | Description |
|---|---|
| otp REQUIRED | The 6-digit code the merchant generated on fatoora.zatca.gov.sa. |
202
{ "status": "connecting", "message": "CSR → certificate → six compliance checks...", "poll": ".../status" }
already_connected and
connect_in_progress (409) protect against double
submits — a second OTP would be wasted while the first run may still succeed.
The no-build alternative remains the
onboarding link.
Connection status
/api/partner/merchants/{id}/status
What your checklist polls — typically settled in under two minutes.
{
"zatca_status": "compliance_passed", // pending | csr_generated | ccsid_issued | compliance_passed | connected | failed
"connected": false,
"connecting": true,
"stalled": false,
"compliance_passed": 4, // of the 6 ZATCA compliance documents
"error": null, // otp_invalid | compliance_failed | api_error | interrupted
"certificate_expires_at": null
}
Onboarding link
/api/partner/merchants/{id}/onboarding-link
A signed URL (valid 72 hours) that drops the merchant onto our onboarding page at
exactly the step they're on — the zero-code alternative to building the connect
screen yourself. Returns { "onboarding_url": "...", "expires_in_hours": 72 }.
Release & reinstate
/api/partner/merchants/{id}/release
/api/partner/merchants/{id}/reinstate
A merchant left your platform? Release frees the seat for someone else — and
deletes nothing: ZATCA mandates a six-year archive and an unbroken invoice
chain, so the establishment, its documents and its owner's login all remain.
Reads survive too: every GET endpoint keeps answering for a released
merchant, so exports and audits never break — what the released seat blocks is
acting anew (issuing, emailing, wiring devices), with
403 seat_released.
Reinstate puts a returning merchant back on a free seat and the chain resumes
where it left off.
Account & quota
/api/v1/account
+ X-Merchant-Id
The merchant's profile, connection state, quota (partner merchants:
"unlimited": true) — and their branches with each
branch's devices, which is where your POS screens read
branch_id / device_id from.
{
"name": "مطعم البيك", "vat_number": "310122393500003",
"zatca": { "connected": true, "environment": "production", "certificate_expires_at": "2029-01-09" },
"branches": [
{ "id": 3, "name": "فرع النخيل مول", "city": "الرياض", "connected": true,
"devices": [ { "id": 7, "name": "كاشير 1", "status": "connected", "usable": true } ] }
],
"quota": { "used": 0, "limit": null, "remaining": null, "unlimited": true }
}
Branches
/api/v1/branches
+ X-Merchant-Id
All outlets live under the merchant's single VAT number; a branch stamps its own address and CR onto documents sold from it, while the legal name and VAT stay the establishment's. A branch cannot issue until one of its devices is connected.
| Field | Description |
|---|---|
| name REQUIRED | The branch name, e.g. «فرع النخيل مول». |
| cr_number optional | The branch's own 10-digit CR; omitted, documents carry the establishment's. |
| short_address REQUIRED | The branch's Saudi national short address — street, district, city and postal code all resolve from it. Nothing else to collect. |
| street, building_number, district, city, postal_zone optional | For one case only: the code could not be resolved (422 address_unresolved). Explicit values always win when sent. |
201
{ "branch": { "id": 3, "name": "فرع النخيل مول", "cr_number": "1010777333", "devices": [] },
"next": "Onboard the branch's device: POST /v1/branches/3/devices ..." }
Devices
A device is a ZATCA EGS unit: authorized by its own single-use Fatoora OTP, holding its own certificate and its own ICV/PIH invoice chain. One branch runs up to 10 — tills sign in parallel, each on its own chain. The establishment's head office needs none; its unit is the main onboarding itself.
/api/v1/branches/{id}/devices
| Field | Description |
|---|---|
| otp REQUIRED | One code per device from the portal's Onboard new solution unit/device. Single-use, 1-hour validity. |
| name optional | A label your staff recognize — «كاشير 1». Defaults to a numbered name. |
Answers 202 immediately; onboarding completes in the
background. Poll the device until connected —
failed with otp_invalid
means the code was wrong, spent or expired: have the merchant generate a fresh one and POST again.
/api/v1/devices/{id}
{
"device": {
"id": 7, "branch_id": 3, "name": "كاشير 1",
"status": "connected", // pending | csr_generated | ccsid_issued | compliance_passed | connected | failed
"connected": true, "connecting": false,
"compliance_passed": 6, "error": null,
"usable": true, // can it sign the establishment's documents right now
"trial": false, "certificate_expires_at": "2029-01-09"
}
}
/api/v1/devices/{id}/connect
Re-onboards an existing unit with a fresh OTP — a failed run, or a trial-era
device after the establishment graduated. Same body and 202-then-poll flow as
creating one; a run already in flight answers
409 connect_in_progress.
On success the unit signs with a fresh certificate and its chain restarts at ICV 1.
/api/v1/devices/{id}
Retires a unit. Its issued documents survive untouched; the merchant can revoke the CSID from the Fatoora portal's device list.
Create an invoice
/api/v1/invoices
+ X-Merchant-Id
Builds, signs and submits the document to ZATCA synchronously — the response already carries the verdict. Simplified (B2C) invoices are reported; standard (B2B) are cleared.
| Field | Description |
|---|---|
| type REQUIRED | simplified (B2C) or standard (B2B). |
| lines[] REQUIRED | Each: name, quantity, unit_price, and optionally tax_category (S default 15% · Z zero-rated · E exempt · O out of scope) with tax_reason_code (VATEX-SA-…), which ZATCA requires for non-standard lines. One invoice mixes categories freely. Also optional: description (printed under the item name, up to 500 chars) and a per-line discount — an amount by default, or a percentage with discount_type: "percent" — taken off the unit price; the PDF prints the agreed price beside the discount. |
| branch_id + device_id optional | Together or not at all. A branch sale names its outlet and the till that rang it; the document is signed by exactly that device, on that device's chain. Omit both for the head office. One without the other: device_required / branch_required. |
| prices_include_vat optional | true = every amount (lines and discount) is what the customer actually pays; we derive the net per line at that line's own rate. Default: prices are net. |
| discount optional | Document-level amount, allocated across lines per ZATCA's rounding rules. |
| customer optional | Required for standard: name, vat_number, plus a national short_address (we resolve the rest) or the full address fields. Matched/created by VAT within the merchant. |
| issue_date, supply_date optional | Default: today (KSA time). |
| external_id optional | Or the Idempotency-Key header — see Idempotency. |
curl -X POST https://zatcatools.com/api/v1/invoices \
-H "Authorization: Bearer ztkp_live_..." \
-H "X-Merchant-Id: 34" \
-H "Idempotency-Key: order-18821" \
-H "Content-Type: application/json" \
-d '{
"type": "simplified",
"branch_id": 3,
"device_id": 7,
"prices_include_vat": true,
"lines": [
{ "name": "وجبة مشاوي", "quantity": 2, "unit_price": 57.50 },
{ "name": "دواء", "quantity": 1, "unit_price": 80,
"tax_category": "Z", "tax_reason_code": "VATEX-SA-35" }
]
}'
201
{
"uuid": "8ac37285-...", "number": "INV-2026-00184",
"kind": "invoice", "type": "simplified",
"status": "accepted", // accepted | warnings | rejected
"issue_date": "2026-09-01",
"totals": { "lines": "180.00", "discount": "0.00", "vat": "15.00", "grand": "195.00", "currency": "SAR" },
"zatca": { "channel": "reporting", "icv": 42, "submitted_at": "...", "warnings": [] },
"links": { "view": "...", "xml": "...", "pdf": "...", "qr": "..." }
}
links.view is the one URL safe to put in front of a
human — it carries its own per-invoice token. The xml/pdf/qr
links need the API key and are for server-to-server use.
List & retrieve invoices
/api/v1/invoices
/api/v1/invoices/{uuid}
Query parameters: kind
(invoice default · credit · debit · all),
status, type,
since (ISO timestamp — compared against
created_at, so pollers never miss a backdated document),
per_page (max 100). Feed the newest
created_at back as since
to poll incrementally.
XML, PDF, QR & email
| GET /v1/invoices/{uuid}/xml | The signed UBL XML exactly as ZATCA took it. |
| GET /v1/invoices/{uuid}/pdf | The invoice PDF, in the merchant's chosen template and branding. |
| GET /v1/invoices/{uuid}/html | Same document as embeddable HTML — print it inside your own page. |
| GET /v1/invoices/{uuid}/qr.svg | The ZATCA TLV QR as an SVG. |
| POST /v1/invoices/{uuid}/email | Emails the PDF — { "to": "[email protected]" }, or omit to send to the merchant's own address. |
Credit & debit notes
/api/v1/notes
+ X-Merchant-Id
An issued invoice can never be edited or deleted — refunds and corrections are
notes against it. A note automatically follows its origin's branch; name the
signing till with an optional device_id, or the
branch's most idle device signs.
| Field | Description |
|---|---|
| invoice_uuid REQUIRED | The issued origin invoice. |
| kind REQUIRED | credit (refund) or debit (charge back). |
| reason REQUIRED | Mandatory under ZATCA BR-KSA-17 — e.g. «إرجاع». |
| lines[] optional | Partial amounts, item by item. |
| full optional | true = the server computes the open balance: a credit writes off what remains; a debit restores what was written off. The Art. 40 ceiling is enforced — a note can never over-credit its origin. |
| reverses optional | Debit only: the uuid of ONE credit note to undo, precisely. |
| device_id optional | A device of the origin's branch to sign with. |
curl -X POST https://zatcatools.com/api/v1/notes \
-H "Authorization: Bearer ztkp_live_..." \
-H "X-Merchant-Id: 34" \
-d '{ "invoice_uuid": "8ac37285-...", "kind": "credit",
"reason": "إرجاع", "full": true }'