For software companies & POS providers

Partner API Reference

One key, many merchants. Create ZATCA Phase-2 invoicing accounts for your customers programmatically, onboard them to Fatoora from inside your own screens, wire their branches and devices, and issue compliant invoices on their behalf.

BASE URL https://zatcatools.com/api
الدليل بالعربية ←

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_requiredThe header is missing — a partner key alone names no merchant.
404 not_foundNo such merchant under your partnership. Merchants of other partners are invisible.
403 seat_releasedThe merchant's seat was released — reinstate it to resume issuing.

The Fatoora OTP

One step in the whole journey cannot be automated by any API, ours or anyone's: the one-time password from the Fatoora portal. ZATCA issues it to the taxpayer, inside fatoora.zatca.gov.sa — it never reaches us, and it never reaches you. This is a ZATCA constraint on the whole market, not a limitation of this API.

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": "..." } }
CodeMeaning
validationA field is missing or malformed — the message names it.
seat_limit_reachedEvery paid seat is in use. Release one, or contact us to raise the limit.
email_taken · vat_takenThat email / VAT number already belongs to an establishment.
merchant_incompleteConnect refused before spending the OTP; missing lists the fields to PATCH first.
identity_lockedName, VAT and CR are frozen once connected — the certificate binds them.
device_required · branch_requiredA branch invoice must name both branch_id and device_id.
device_not_found · device_not_usableThe named device is not under that branch, or cannot sign right now.
quota_exceededFree-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

GET /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

POST /api/partner/merchants

Creates the establishment and occupies one seat. The seat cap is enforced atomically — concurrent creates can never oversell your limit.

FieldDescription
name REQUIREDEstablishment name as registered with ZATCA.
email REQUIREDThe owner's login email (passwordless sign-in to their own dashboard).
vat_number REQUIRED15 digits, starts and ends with 3 — the merchant's tax identity.
short_address optionalSaudi 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 optionalExplicit 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

GET /api/partner/merchants
GET /api/partner/merchants/{id}

All your merchants (newest first), or one. Returns { "merchants": [ ... ] } / { "merchant": { ... } } of merchant objects.

Update a merchant

PATCH /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

POST /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.

FieldDescription
otp REQUIREDThe 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

GET /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
}

Release & reinstate

POST /api/partner/merchants/{id}/release
POST /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

GET /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

POST /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.

FieldDescription
name REQUIREDThe branch name, e.g. «فرع النخيل مول».
cr_number optionalThe branch's own 10-digit CR; omitted, documents carry the establishment's.
short_address REQUIREDThe 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 optionalFor 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.

POST /api/v1/branches/{id}/devices
FieldDescription
otp REQUIREDOne code per device from the portal's Onboard new solution unit/device. Single-use, 1-hour validity.
name optionalA label your staff recognize — «كاشير 1». Defaults to a numbered name.

Answers 202 immediately; onboarding completes in the background. Poll the device until connectedfailed with otp_invalid means the code was wrong, spent or expired: have the merchant generate a fresh one and POST again.

GET /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"
  }
}
POST /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.

DELETE /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

POST /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.

FieldDescription
type REQUIREDsimplified (B2C) or standard (B2B).
lines[] REQUIREDEach: 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 optionalTogether 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 optionaltrue = 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 optionalDocument-level amount, allocated across lines per ZATCA's rounding rules.
customer optionalRequired 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 optionalDefault: today (KSA time).
external_id optionalOr 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

GET /api/v1/invoices
GET /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}/xmlThe signed UBL XML exactly as ZATCA took it.
GET /v1/invoices/{uuid}/pdfThe invoice PDF, in the merchant's chosen template and branding.
GET /v1/invoices/{uuid}/htmlSame document as embeddable HTML — print it inside your own page.
GET /v1/invoices/{uuid}/qr.svgThe ZATCA TLV QR as an SVG.
POST /v1/invoices/{uuid}/emailEmails the PDF — { "to": "[email protected]" }, or omit to send to the merchant's own address.

Credit & debit notes

POST /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.

FieldDescription
invoice_uuid REQUIREDThe issued origin invoice.
kind REQUIREDcredit (refund) or debit (charge back).
reason REQUIREDMandatory under ZATCA BR-KSA-17 — e.g. «إرجاع».
lines[] optionalPartial amounts, item by item.
full optionaltrue = 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 optionalDebit only: the uuid of ONE credit note to undo, precisely.
device_id optionalA 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 }'
Questions, higher limits, or a seat change? [email protected] — the Arabic walkthrough lives in the Partner Guide.