Developer documentation
Partner API Reference
The Partner API is JSON over HTTPS, with two surfaces behind your one partner key:
| /api/partner/* | Your merchants and their subscriptions: create, update, connect to ZATCA, order plans, upload receipts, release. |
| /api/v1/* | Acting as a merchant — branches, devices, invoices, notes, documents — with the X-Merchant-Id header. Fully documented in the invoice API reference. |
Send Arabic content as it is: names and addresses end up inside signed XML exactly as you provide them. Amounts are SAR with two decimals. A conceptual walkthrough in Arabic lives in the Partner guide.
Quickstart
- 1. Get a partner key. Sign up at /partner/register, then issue the key on the portal's Account page. It starts with
ztkp_live_. - 2. Create a merchant.
POST /merchantswith its name and VAT number; the owner's email is optional. Keep the returnedid— it goes into everyX-Merchant-Id. - 3. Connect it. Pass the Fatoora code the merchant generates to
POST /merchants/{id}/connect, and poll the status untilconnected. - 4. Subscribe it, then issue.
POST /ordersfor its plan, and issue invoices on/api/v1/invoiceswithX-Merchant-Id.
curl -X POST https://zatcatools.com/api/partner/merchants \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "مطعم المثال",
"vat_number": "310122393500003",
"email": "[email protected]",
"short_address": "RRRD2929"
}'
const response = await fetch('https://zatcatools.com/api/partner/merchants', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"name": "مطعم المثال",
"vat_number": "310122393500003",
"email": "[email protected]",
"short_address": "RRRD2929"
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->post('https://zatcatools.com/api/partner/merchants', [
'name' => 'مطعم المثال',
'vat_number' => '310122393500003',
'email' => '[email protected]',
'short_address' => 'RRRD2929',
]);
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/partner/merchants",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}"},
json={
"name": "مطعم المثال",
"vat_number": "310122393500003",
"email": "[email protected]",
"short_address": "RRRD2929"
},
timeout=30,
)
data = response.json()
Authentication
Every request carries your partner key as a bearer token. Keys start with ztkp_live_, are shown once when issued, and can be rotated any time from the partner portal — the old key stops at once. Keep it server-side, never in an app or a browser.
A missing or wrong key answers 401 unauthenticated; a merchant key (ztk_) sent here is named as such, so nobody debugs the wrong thing. A suspended partnership answers 403 partner_suspended.
curl https://zatcatools.com/api/partner/account \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY"
const response = await fetch('https://zatcatools.com/api/partner/account', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
},
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->get('https://zatcatools.com/api/partner/account');
$data = $response->json();
import os
import requests
response = requests.get(
"https://zatcatools.com/api/partner/account",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}"},
timeout=30,
)
data = response.json()
Acting as a merchant
There are no per-merchant keys. Your partner key acts for any of your merchants on /api/v1 when the request names the merchant with X-Merchant-Id.
| 400 merchant_required | The header is missing — a partner key alone names no merchant. |
| 404 not_found | No such merchant under your partnership. Other partners' merchants are invisible. |
| 403 seat_released | The merchant was released — reads still work; reinstate it to issue again. |
curl https://zatcatools.com/api/v1/account \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY" \
-H "X-Merchant-Id: 34"
const response = await fetch('https://zatcatools.com/api/v1/account', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
'X-Merchant-Id': '34',
},
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->withHeaders([
'X-Merchant-Id' => '34',
])
->get('https://zatcatools.com/api/v1/account');
$data = $response->json();
import os
import requests
response = requests.get(
"https://zatcatools.com/api/v1/account",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}", "X-Merchant-Id": "34"},
timeout=30,
)
data = response.json()
The Fatoora code
fatoora.zatca.gov.sa — it never
reaches us, and it never reaches you. This is a ZATCA constraint on the whole market, not a limit of this API.
Only the source of the code is fixed. Entering it happens in your screens: ask your merchant for the code from the Fatoora portal, pass what they type to connect, and poll status. Every branch device later needs its own code — on the portal the merchant picks Onboard new solution unit/device, one code per device, each single-use and valid for one hour.
Errors
Every error uses one envelope. The code is stable and meant for your program; the message explains, and usually names the fix.
{
"error": {
"code": "seat_limit_reached",
"message": "All 100 seats are in use. Release a merchant, or contact [email protected] to add seats."
}
}
{
"error": {
"code": "price_changed",
"message": "The price changed since your quote. Fetch POST /api/partner/quote again and confirm the new total."
}
}
| HTTP | code | Meaning |
|---|---|---|
| 401 | unauthenticated | The key is missing, malformed or rotated. |
| 402 | seat_limit_reached | Your account holds as many merchants as it may. Release one, or ask us to raise the limit. |
| 403 | partner_suspended · seat_released | The partnership is suspended, or this merchant was released. |
| 404 | not_found | No such merchant, order or request under your partnership. |
| 409 | already_connected · connect_in_progress | The merchant is connected already, or a connection run is still going. |
| 409 | identity_locked | Name, VAT and CR are frozen once connected — the certificate binds them. |
| 409 | price_changed | The order's total no longer matches expected_total. Quote again and confirm. |
| 409 | quote_request_open | You already have an open request for a custom offer. |
| 422 | validation_failed | A field is missing or malformed — the message names it. |
| 409 | email_taken · vat_taken | That email already has an account, or that VAT number belongs to a connected establishment. |
| 409 | email_locked | The merchant already has a sign-in email; only we can change it. |
| 422 | merchant_incomplete | Connect refused before spending the code; missing lists the fields to PATCH first. |
| 422 | idempotency_key_required | Placing an order needs an Idempotency-Key header. |
| 422 | device_required · branch_required | On /api/v1: a branch invoice names both branch_id and device_id. |
| 402 | quota_exceeded | On /api/v1: a merchant's free trial is used up, or its subscription ended. Subscribe it. |
Idempotency
Orders require an Idempotency-Key header: the same key again returns the order already placed — 200 instead of 201 — never a second one, so a timeout is always safe to retry. Invoices and notes on /api/v1 take the same header (or external_id); your own order id is the natural choice.
Rate limits
/api/partner: 120 requests per minute./api/v1: 60 requests per minute per client address — enough for hundreds of tills. Above that the answer is429with aRetry-Afterheader; write to [email protected] to raise it.
How subscriptions work
Trial. Every merchant you create gets a free trial of 7 days or 5 invoices, whichever comes first, counted from the day it connects to ZATCA — not from the day you created it.
Order. Subscribe merchants with one order: a plan and a period per merchant, priced from the published plans. Any discount comes from the ladder in GET /plans: the highest step the order reaches applies to every line. Price it first with POST /quote — the order is priced the same way.
Changing your mind. Until you upload the receipt, an order is yours to reshape. Take a merchant out with DELETE /orders/{id}/items/{merchant}, or simply order it again: a merchant that sits in one of your unpaid orders moves to the new order, and the old one is priced again without it — or cancelled, once empty. A merchant never sits in two unpaid orders. Once a receipt is in, that order waits for our review.
Payment. Bank transfer: the order answers with the bank details; transfer, then upload the receipt. We confirm the payment, usually within working hours. Card and Apple Pay are coming.
Start. A paid line starts its merchant's term when the merchant is connected to ZATCA in production — at once if it already is, otherwise the day it connects. A renewal is added after the running term ends, so renewing early never loses a day.
| Order status | Line status |
|---|---|
| awaiting_payment | pending — not paid yet |
| payment_review | awaiting_connection — paid; starts when the merchant connects |
| paid | applied — the term started or was extended (term_from → term_to) |
| cancelled | cancelled |
The merchant object
-
idintegerGoes into everyX-Merchant-Id. -
statusstringThe connection:awaiting_onboarding,trial,connected,failedorreleased. -
summaryobjectThe one answer the portal leads with.status:not_connected,connect_failed,simulation,connecting,trial,trial_ended,payment_pending,payment_review,active,expiring,expiredorreleased.next_action:link,open,subscribe,renew,payor null. -
subscriptionobjectThe paid term:started_at,expires_at,days_left(negative once ended) andplan. -
trialobject|nullThe free trial while nothing paid covers the merchant:started,days_left,invoices_left,ended. -
pending_orderobject|nullAn order for this merchant that is not settled:id,number,status.
{
"id": 34,
"name": "مطعم المثال",
"vat_number": "310122393500003",
"cr_number": "1010203040",
"cr_scheme": "CRN",
"email": "[email protected]",
"status": "connected",
"zatca_environment": "production",
"certificate_expires_at": "2029-01-09",
"invoices_issued": 1240,
"seat": "active",
"subscription": {
"status": "active",
"started_at": "2026-09-01",
"expires_at": "2027-09-01",
"days_left": 334,
"plan": "growth"
},
"summary": {
"status": "active",
"next_action": null
},
"trial": null,
"pending_order": null,
"created_at": "2026-08-28T09:24:32+03:00"
}
Your partner account
/api/partner/account
Your partnership at a glance: status, how you are billed (orders for self-service, agreement for a contract with us), the merchant counters, and any custom offer.
curl https://zatcatools.com/api/partner/account \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY"
const response = await fetch('https://zatcatools.com/api/partner/account', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
},
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->get('https://zatcatools.com/api/partner/account');
$data = $response->json();
import os
import requests
response = requests.get(
"https://zatcatools.com/api/partner/account",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}"},
timeout=30,
)
data = response.json()
{
"partner": {
"name": "Smart POS Co.",
"status": "active",
"billing": "orders",
"seats": {
"limit": 100,
"used": 37,
"remaining": 63
},
"custom_discount_percent": null,
"order_limit": 100
}
}
Plans, prices & discounts
/api/partner/plans
The plans you can order, their published prices per period, the discount ladder, your custom offer if you have one, and the merchant trial. Read it rather than hard-coding prices: the ladder is set by us and can change; an order already placed keeps its price.
curl https://zatcatools.com/api/partner/plans \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY"
const response = await fetch('https://zatcatools.com/api/partner/plans', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
},
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->get('https://zatcatools.com/api/partner/plans');
$data = $response->json();
import os
import requests
response = requests.get(
"https://zatcatools.com/api/partner/plans",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}"},
timeout=30,
)
data = response.json()
{
"currency": "SAR",
"plans": [
{
"key": "growth",
"name": "Growth",
"prices": {
"monthly": 49,
"yearly": 490
}
},
{
"key": "business",
"name": "Business",
"prices": {
"monthly": 149,
"yearly": 1490
}
}
],
"periods": [
"monthly",
"yearly"
],
"discount": {
"tiers": [
{
"min_merchants": 5,
"percent": 10
},
{
"min_merchants": 20,
"percent": 15
}
],
"custom_percent": null,
"basis": "order",
"count_renewals": true,
"order_limit": 100
},
"trial": {
"days": 7,
"invoices": 5
}
}
Create a merchant
/api/partner/merchants
Creates the establishment with its owner. It costs nothing until it connects; then its free trial starts. The response carries the onboarding_url for the no-build path.
Body
-
namestring requiredEstablishment name as registered with ZATCA. -
emailstringOptional. The owner's sign-in email, so the merchant can open their dashboard themselves. Leave it out and you run the merchant from your side; add it later withPATCH. An address that already has an account is refused, never adopted. -
vat_numberstring required15 digits, starting and ending with 3. -
short_addressstringSaudi national short address (RRRD2929); street, district, city and postal code resolve from it. Explicit fields win. -
cr_numberstringOptional here, required before connecting. -
cr_schemestring defaultCRNCRN,MOM,MLS,SAG,700orOTH— what kind of numbercr_numberis. -
street, building_number, subdivision, city, postal_zonestringExplicit address details.
curl -X POST https://zatcatools.com/api/partner/merchants \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "مطعم المثال",
"vat_number": "310122393500003",
"email": "[email protected]",
"short_address": "RRRD2929"
}'
const response = await fetch('https://zatcatools.com/api/partner/merchants', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"name": "مطعم المثال",
"vat_number": "310122393500003",
"email": "[email protected]",
"short_address": "RRRD2929"
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->post('https://zatcatools.com/api/partner/merchants', [
'name' => 'مطعم المثال',
'vat_number' => '310122393500003',
'email' => '[email protected]',
'short_address' => 'RRRD2929',
]);
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/partner/merchants",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}"},
json={
"name": "مطعم المثال",
"vat_number": "310122393500003",
"email": "[email protected]",
"short_address": "RRRD2929"
},
timeout=30,
)
data = response.json()
{
"merchant": {
"id": 34,
"status": "awaiting_onboarding",
"summary": {
"status": "not_connected",
"next_action": "link"
},
"trial": {
"started": false,
"days": 7,
"invoices": 5
}
},
"onboarding_url": "https://zatcatools.com/partner/onboarding/34?expires=…&signature=…",
"seats": {
"limit": 100,
"used": 38,
"remaining": 62
}
}
{
"error": {
"code": "seat_limit_reached",
"message": "All 100 seats are in use. Release a merchant, or contact [email protected] to add seats."
}
}
{
"error": {
"code": "email_taken",
"message": "That email already has a ZATCA Tools account. Ask the merchant to connect it themselves, or use a different address."
}
}
List & retrieve merchants
/api/partner/merchants · /merchants/{id}
All your merchants, newest first, or one — as merchant objects. summary.next_action tells your screen what to offer next.
curl https://zatcatools.com/api/partner/merchants/34 \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY"
const response = await fetch('https://zatcatools.com/api/partner/merchants/34', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
},
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->get('https://zatcatools.com/api/partner/merchants/34');
$data = $response->json();
import os
import requests
response = requests.get(
"https://zatcatools.com/api/partner/merchants/34",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}"},
timeout=30,
)
data = response.json()
{
"merchant": {
"id": 34,
"name": "مطعم المثال",
"summary": {
"status": "trial",
"next_action": "subscribe"
},
"trial": {
"started": true,
"days_left": 4,
"invoices_left": 3,
"ended": false
}
}
}
Update a merchant
/api/partner/merchants/{id}
Completes or corrects details — any subset of the create fields. Identity fields (name, vat_number, cr_number, cr_scheme) lock once the merchant connects (409 identity_locked); address fields stay editable. email can be added to a merchant created without one, never changed (409 email_locked).
curl -X PATCH https://zatcatools.com/api/partner/merchants/34 \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY" \
-H "Content-Type: application/json" \
-d '{
"cr_number": "1010203040",
"short_address": "RRRD2929"
}'
const response = await fetch('https://zatcatools.com/api/partner/merchants/34', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"cr_number": "1010203040",
"short_address": "RRRD2929"
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->patch('https://zatcatools.com/api/partner/merchants/34', [
'cr_number' => '1010203040',
'short_address' => 'RRRD2929',
]);
$data = $response->json();
import os
import requests
response = requests.patch(
"https://zatcatools.com/api/partner/merchants/34",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}"},
json={
"cr_number": "1010203040",
"short_address": "RRRD2929"
},
timeout=30,
)
data = response.json()
Connect to ZATCA
/api/partner/merchants/{id}/connect
Onboards the merchant to Fatoora from inside your screen — CSR, certificate, the six compliance checks. Completeness is checked before the single-use code is spent: missing fields answer merchant_incomplete with a missing list.
Body
-
otpstring requiredThe 6-digit code the merchant generated on fatoora.zatca.gov.sa.
curl -X POST https://zatcatools.com/api/partner/merchants/34/connect \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY" \
-H "Content-Type: application/json" \
-d '{
"otp": "123456"
}'
const response = await fetch('https://zatcatools.com/api/partner/merchants/34/connect', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"otp": "123456"
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->post('https://zatcatools.com/api/partner/merchants/34/connect', [
'otp' => '123456',
]);
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/partner/merchants/34/connect",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}"},
json={
"otp": "123456"
},
timeout=30,
)
data = response.json()
{
"status": "connecting",
"message": "CSR → certificate → six compliance checks. Usually under two minutes.",
"poll": "https://zatcatools.com/api/partner/merchants/34/status"
}
{
"error": {
"code": "merchant_incomplete",
"message": "Complete these fields first (PATCH /merchants/{id}): cr_number.",
"missing": [
"cr_number"
]
}
}
Connection status
/api/partner/merchants/{id}/status
What your onboarding checklist polls: settled in under two minutes as a rule. error: otp_invalid means the code was wrong, spent or expired — ask the merchant for a fresh one.
curl https://zatcatools.com/api/partner/merchants/34/status \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY"
const response = await fetch('https://zatcatools.com/api/partner/merchants/34/status', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
},
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->get('https://zatcatools.com/api/partner/merchants/34/status');
$data = $response->json();
import os
import requests
response = requests.get(
"https://zatcatools.com/api/partner/merchants/34/status",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}"},
timeout=30,
)
data = response.json()
{
"zatca_status": "connected",
"connected": true,
"connecting": false,
"stalled": false,
"compliance_passed": 6,
"error": null,
"certificate_expires_at": "2029-01-09"
}
Onboarding link
/api/partner/merchants/{id}/onboarding-link
The no-build path: a signed link, valid 72 hours, that takes the merchant straight to the step only they can do — entering the Fatoora code. It finishes onboarding and nothing else: a connected merchant answers 409 already_connected, and the merchant signs into a live account with their own email.
curl -X POST https://zatcatools.com/api/partner/merchants/34/onboarding-link \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY"
const response = await fetch('https://zatcatools.com/api/partner/merchants/34/onboarding-link', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
},
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->post('https://zatcatools.com/api/partner/merchants/34/onboarding-link');
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/partner/merchants/34/onboarding-link",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}"},
timeout=30,
)
data = response.json()
{
"onboarding_url": "https://zatcatools.com/partner/onboarding/34?expires=…&signature=…",
"expires_in_hours": 72
}
Open the merchant's dashboard
/api/partner/merchants/{id}/dashboard-link
A one-time link, valid five minutes, that opens this merchant's dashboard in a browser for someone on your support team — the portal's «open dashboard» button. The session is fenced (the merchant's API key, users, store connections, billing and deletions stay closed), ends after two hours, and every opening is recorded.
curl -X POST https://zatcatools.com/api/partner/merchants/34/dashboard-link \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY"
const response = await fetch('https://zatcatools.com/api/partner/merchants/34/dashboard-link', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
},
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->post('https://zatcatools.com/api/partner/merchants/34/dashboard-link');
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/partner/merchants/34/dashboard-link",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}"},
timeout=30,
)
data = response.json()
{
"url": "https://zatcatools.com/partner/access/…",
"expires_in_seconds": 300,
"single_use": true
}
Release & reinstate
/api/partner/merchants/{id}/release · /reinstate
A merchant that left your platform is released: nothing is deleted, every read keeps answering, issuing through your key stops with 403 seat_released, and its place on your account comes back. Reinstate puts it back, and its invoice chain resumes where it left off.
curl -X POST https://zatcatools.com/api/partner/merchants/34/release \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY"
const response = await fetch('https://zatcatools.com/api/partner/merchants/34/release', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
},
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->post('https://zatcatools.com/api/partner/merchants/34/release');
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/partner/merchants/34/release",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}"},
timeout=30,
)
data = response.json()
Price an order
/api/partner/quote
What an order would cost, computed exactly as POST /orders will — nothing is saved. next_tier says how many more merchants reach the next discount; requires_custom_offer is true past your order limit; moves_from lists the merchants that would leave one of your unpaid orders for this one.
Body
-
linesarray of objects requiredOne per merchant, each merchant once.-
merchant_idinteger requiredOne of your merchants, not released and not in another unpaid order. -
planstring requiredA key fromGET /plans. -
periodstring requiredmonthlyoryearly.
-
curl -X POST https://zatcatools.com/api/partner/quote \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY" \
-H "Content-Type: application/json" \
-d '{
"lines": [
{
"merchant_id": 34,
"plan": "growth",
"period": "yearly"
},
{
"merchant_id": 35,
"plan": "business",
"period": "monthly"
}
]
}'
const response = await fetch('https://zatcatools.com/api/partner/quote', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"lines": [
{
"merchant_id": 34,
"plan": "growth",
"period": "yearly"
},
{
"merchant_id": 35,
"plan": "business",
"period": "monthly"
}
]
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->post('https://zatcatools.com/api/partner/quote', [
'lines' => [
[
'merchant_id' => 34,
'plan' => 'growth',
'period' => 'yearly',
],
[
'merchant_id' => 35,
'plan' => 'business',
'period' => 'monthly',
],
],
]);
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/partner/quote",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}"},
json={
"lines": [
{
"merchant_id": 34,
"plan": "growth",
"period": "yearly"
},
{
"merchant_id": 35,
"plan": "business",
"period": "monthly"
}
]
},
timeout=30,
)
data = response.json()
{
"quote": {
"currency": "SAR",
"lines": [
{
"merchant_id": 34,
"plan": "growth",
"period": "yearly",
"kind": "renewal",
"unit_price": 490,
"discount_amount": 0,
"total": 490
},
{
"merchant_id": 35,
"plan": "business",
"period": "monthly",
"kind": "new",
"unit_price": 149,
"discount_amount": 0,
"total": 149
}
],
"counted_merchants": 2,
"discount_percent": 0,
"discount_source": null,
"subtotal": 639,
"discount_amount": 0,
"total": 639,
"next_tier": {
"min": 5,
"percent": 10,
"needed": 3
},
"requires_custom_offer": false,
"moves_from": []
}
}
Place an order
/api/partner/orders
Places the order and answers with the bank details to pay it. Requires an Idempotency-Key header. Send expected_total — the total your quote showed — and the order is refused with 409 price_changed if the server's figure differs, so you never charge your customer one price and pay another.
Body
-
linesarray of objects requiredAs for POST /quote. -
expected_totalnumberThe total you confirmed.
curl -X POST https://zatcatools.com/api/partner/orders \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY" \
-H "Idempotency-Key: erp-order-881" \
-H "Content-Type: application/json" \
-d '{
"lines": [
{
"merchant_id": 34,
"plan": "growth",
"period": "yearly"
},
{
"merchant_id": 35,
"plan": "business",
"period": "monthly"
}
],
"expected_total": 639
}'
const response = await fetch('https://zatcatools.com/api/partner/orders', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
'Idempotency-Key': 'erp-order-881',
'Content-Type': 'application/json',
},
body: JSON.stringify({
"lines": [
{
"merchant_id": 34,
"plan": "growth",
"period": "yearly"
},
{
"merchant_id": 35,
"plan": "business",
"period": "monthly"
}
],
"expected_total": 639
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->withHeaders([
'Idempotency-Key' => 'erp-order-881',
])
->post('https://zatcatools.com/api/partner/orders', [
'lines' => [
[
'merchant_id' => 34,
'plan' => 'growth',
'period' => 'yearly',
],
[
'merchant_id' => 35,
'plan' => 'business',
'period' => 'monthly',
],
],
'expected_total' => 639,
]);
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/partner/orders",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}", "Idempotency-Key": "erp-order-881"},
json={
"lines": [
{
"merchant_id": 34,
"plan": "growth",
"period": "yearly"
},
{
"merchant_id": 35,
"plan": "business",
"period": "monthly"
}
],
"expected_total": 639
},
timeout=30,
)
data = response.json()
{
"order": {
"id": 12,
"number": "PO-00012",
"status": "awaiting_payment",
"currency": "SAR",
"items_count": 2,
"subtotal": 639,
"discount_percent": 0,
"discount_amount": 0,
"total": 639,
"has_receipt": false,
"note": null,
"created_at": "2026-10-02T10:12:40+03:00",
"paid_at": null,
"items": [
{
"merchant_id": 34,
"merchant_name": "مطعم المثال",
"plan": "growth",
"period": "yearly",
"kind": "renewal",
"unit_price": 490,
"discount_amount": 0,
"total": 490,
"status": "pending",
"term_from": null,
"term_to": null
},
{
"merchant_id": 35,
"merchant_name": "صيدلية النور",
"plan": "business",
"period": "monthly",
"kind": "new",
"unit_price": 149,
"discount_amount": 0,
"total": 149,
"status": "pending",
"term_from": null,
"term_to": null
}
],
"payment": {
"method": "bank_transfer",
"bank": "البنك الأهلي السعودي",
"beneficiary": "مؤسسة التنظيم الانسيابي للتجارة والتسويق",
"iban": "SA3710000070500000712608",
"transfer_note": "PO-00012"
}
}
}
{
"error": {
"code": "price_changed",
"message": "The price changed since your quote. Fetch POST /api/partner/quote again and confirm the new total."
}
}
List & retrieve orders
/api/partner/orders · /orders/{id}
Your orders, newest first — filter with ?status=awaiting_payment, payment_review, paid or cancelled — or one, with its lines and each line's term.
curl https://zatcatools.com/api/partner/orders/12 \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY"
const response = await fetch('https://zatcatools.com/api/partner/orders/12', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
},
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->get('https://zatcatools.com/api/partner/orders/12');
$data = $response->json();
import os
import requests
response = requests.get(
"https://zatcatools.com/api/partner/orders/12",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}"},
timeout=30,
)
data = response.json()
Upload the transfer receipt
/api/partner/orders/{id}/receipt
Multipart form. The order moves to payment_review; once we confirm the transfer it is paid and its lines start their terms. A receipt we send back returns the order to awaiting_payment with our reason in note.
Form
-
receiptfile requiredPNG, JPG, WebP or PDF, up to 4 MB. -
referencestringThe bank's transaction number. -
transferred_atdateYYYY-MM-DD, not in the future.
curl -X POST https://zatcatools.com/api/partner/orders/12/receipt \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY" \
-F "[email protected]" \
-F "reference=TRX-55120" \
-F "transferred_at=2026-10-02"
import { readFile } from 'node:fs/promises';
const form = new FormData();
form.append('receipt', new Blob([await readFile('transfer.pdf')]), 'transfer.pdf');
form.append('reference', 'TRX-55120');
const response = await fetch('https://zatcatools.com/api/partner/orders/12/receipt', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}` },
body: form,
});
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->attach('receipt', file_get_contents('transfer.pdf'), 'transfer.pdf')
->post('https://zatcatools.com/api/partner/orders/12/receipt', ['reference' => 'TRX-55120']);
import os
import requests
response = requests.post(
"https://zatcatools.com/api/partner/orders/12/receipt",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}"},
files={"receipt": open("transfer.pdf", "rb")},
data={"reference": "TRX-55120"},
timeout=30,
)
Remove a merchant from an order
/api/partner/orders/{id}/items/{merchant}
Takes one merchant out of an order that has no receipt yet. The rest of the order is priced again — the discount step may change with its size — and the last merchant out cancels the order. After the receipt, the order stays as sent.
curl -X DELETE https://zatcatools.com/api/partner/orders/12/items/35 \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY"
const response = await fetch('https://zatcatools.com/api/partner/orders/12/items/35', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
},
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->delete('https://zatcatools.com/api/partner/orders/12/items/35');
$data = $response->json();
import os
import requests
response = requests.delete(
"https://zatcatools.com/api/partner/orders/12/items/35",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}"},
timeout=30,
)
data = response.json()
{
"order": {
"id": 12,
"number": "PO-00012",
"status": "awaiting_payment",
"items_count": 1,
"total": 490
}
}
Cancel an order
/api/partner/orders/{id}/cancel
Withdraws an order still waiting for payment. Once its receipt is uploaded, write to us instead — money has moved.
curl -X POST https://zatcatools.com/api/partner/orders/12/cancel \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY"
const response = await fetch('https://zatcatools.com/api/partner/orders/12/cancel', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
},
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->post('https://zatcatools.com/api/partner/orders/12/cancel');
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/partner/orders/12/cancel",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}"},
timeout=30,
)
data = response.json()
Ask for a custom offer
/api/partner/quote-requests
More merchants than one order holds, or a size that deserves its own price: say how many, and we answer with a discount and an order limit written onto your account — your next order is priced by them. GET on the same path lists your requests and our answers.
Body
-
merchants_countinteger requiredHow many merchants. -
planstring defaultmixedgrowth,businessormixed. -
messagestringAnything we should know.
curl -X POST https://zatcatools.com/api/partner/quote-requests \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY" \
-H "Content-Type: application/json" \
-d '{
"merchants_count": 240,
"plan": "mixed",
"message": "POS chain, live in November"
}'
const response = await fetch('https://zatcatools.com/api/partner/quote-requests', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"merchants_count": 240,
"plan": "mixed",
"message": "POS chain, live in November"
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->post('https://zatcatools.com/api/partner/quote-requests', [
'merchants_count' => 240,
'plan' => 'mixed',
'message' => 'POS chain, live in November',
]);
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/partner/quote-requests",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}"},
json={
"merchants_count": 240,
"plan": "mixed",
"message": "POS chain, live in November"
},
timeout=30,
)
data = response.json()
Account & quota
/api/v1/account + X-Merchant-Id
The merchant's profile, connection, quota and branches with their devices — where your POS screens read branch_id and device_id from. During the trial quota counts down; under a paid subscription it is unlimited.
curl https://zatcatools.com/api/v1/account \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY" \
-H "X-Merchant-Id: 34"
const response = await fetch('https://zatcatools.com/api/v1/account', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
'X-Merchant-Id': '34',
},
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->withHeaders([
'X-Merchant-Id' => '34',
])
->get('https://zatcatools.com/api/v1/account');
$data = $response->json();
import os
import requests
response = requests.get(
"https://zatcatools.com/api/v1/account",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}", "X-Merchant-Id": "34"},
timeout=30,
)
data = response.json()
Branches & devices
/api/v1/branches + X-Merchant-Id
Outlets live under the merchant's one VAT number. A branch stamps its own address and CR on what it sells, and cannot issue until one of its devices is connected — each device with its own Fatoora code: POST /v1/branches/{id}/devices with otp. Full details in the invoice API reference.
curl -X POST https://zatcatools.com/api/v1/branches \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY" \
-H "X-Merchant-Id: 34" \
-H "Content-Type: application/json" \
-d '{
"name": "فرع النخيل مول",
"short_address": "RRRD2929"
}'
const response = await fetch('https://zatcatools.com/api/v1/branches', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
'X-Merchant-Id': '34',
'Content-Type': 'application/json',
},
body: JSON.stringify({
"name": "فرع النخيل مول",
"short_address": "RRRD2929"
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->withHeaders([
'X-Merchant-Id' => '34',
])
->post('https://zatcatools.com/api/v1/branches', [
'name' => 'فرع النخيل مول',
'short_address' => 'RRRD2929',
]);
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/v1/branches",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}", "X-Merchant-Id": "34"},
json={
"name": "فرع النخيل مول",
"short_address": "RRRD2929"
},
timeout=30,
)
data = response.json()
Create an invoice
/api/v1/invoices + X-Merchant-Id
Builds, signs and submits the merchant's invoice to ZATCA in one call — the response carries the verdict. A branch sale names both branch_id and device_id (device_required / branch_required otherwise). Every field is in the invoice API reference.
curl -X POST https://zatcatools.com/api/v1/invoices \
-H "Authorization: Bearer $ZATCA_TOOLS_PARTNER_KEY" \
-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.5
}
]
}'
const response = await fetch('https://zatcatools.com/api/v1/invoices', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZATCA_TOOLS_PARTNER_KEY}`,
'X-Merchant-Id': '34',
'Idempotency-Key': 'order-18821',
'Content-Type': 'application/json',
},
body: JSON.stringify({
"type": "simplified",
"branch_id": 3,
"device_id": 7,
"prices_include_vat": true,
"lines": [
{
"name": "وجبة مشاوي",
"quantity": 2,
"unit_price": 57.5
}
]
}),
});
const data = await response.json();
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('ZATCA_TOOLS_PARTNER_KEY'))
->withHeaders([
'X-Merchant-Id' => '34',
'Idempotency-Key' => 'order-18821',
])
->post('https://zatcatools.com/api/v1/invoices', [
'type' => 'simplified',
'branch_id' => 3,
'device_id' => 7,
'prices_include_vat' => true,
'lines' => [
[
'name' => 'وجبة مشاوي',
'quantity' => 2,
'unit_price' => 57.5,
],
],
]);
$data = $response->json();
import os
import requests
response = requests.post(
"https://zatcatools.com/api/v1/invoices",
headers={"Authorization": f"Bearer {os.environ['ZATCA_TOOLS_PARTNER_KEY']}", "X-Merchant-Id": "34", "Idempotency-Key": "order-18821"},
json={
"type": "simplified",
"branch_id": 3,
"device_id": 7,
"prices_include_vat": True,
"lines": [
{
"name": "وجبة مشاوي",
"quantity": 2,
"unit_price": 57.5
}
]
},
timeout=30,
)
data = response.json()
{
"number": "INV-2026-00184",
"status": "accepted",
"totals": {
"taxable": "100.00",
"vat": "15.00",
"grand": "115.00"
},
"zatca": {
"channel": "reporting",
"icv": 42
}
}