Partner API · POS, ERP & SaaS

One key for every merchant you serve.

Create each Saudi merchant, connect it to ZATCA with its own certificate, subscribe it at partner prices, and issue its Phase 2 invoices — all from your own product, with one partner key.

Merchant onboarding
Each with its own ZATCA certificate
Subscriptions
Many merchants in one order
Invoices on their behalf
One key + X-Merchant-Id
Support access
Open any merchant's dashboard
POST /api/partner/merchants 201 · created
{
  "name": "مطعم المثال",
  "vat_number": "310122393500003",
  "email": "[email protected]",
  "short_address": "RRRD2929"
}
Response
{
  "merchant": {
    "id": 34,
    "summary": {
      "status": "not_connected",
      "next_action": "link"
    }
  },
  "onboarding_url": "https://zatcatools.com/partner/onboarding/34?…"
}

Everything the partner portal does, from your code

The same services sit behind the portal and this API, so a merchant, an order or a subscription is the same thing through either door.

One key, many merchants

Create merchants and act as any of them on the invoice API with an X-Merchant-Id header. One secret to keep, one rotation.

Onboarding inside your screen

Collect the Fatoora code in your product and pass it to connect. Certificate and compliance checks run in the background.

One order, many merchants

Subscribe merchants in one order, a different plan per merchant if you like. Large portfolios get a custom offer.

Terms start at connection

A paid subscription waits for its merchant to connect to ZATCA in production. Nothing is counted against you before.

Open a merchant's dashboard

A one-time link puts your support team inside the merchant's account — fenced, time-boxed and recorded.

Nothing is ever deleted

A merchant that leaves is released: its archive stays readable for the six years ZATCA requires, and it can come back.

Live in three steps

  1. 1

    Sign up as a partner

    An email and a one-time code at /partner/register. Issue your partner key from the portal's Account page; it starts with ztkp_live_ and is shown once.

  2. 2

    Create and connect merchants

    POST each merchant, then pass the Fatoora code it generates to connect — or send it the onboarding link. Its free trial starts the day it connects.

  3. 3

    Subscribe and issue

    Order subscriptions for your merchants, transfer and upload the receipt, and issue their invoices on /api/v1 with X-Merchant-Id.

Questions platforms ask

What does the Partner API do?

It lets a POS, ERP, booking or SaaS company give every Saudi merchant it serves ZATCA Phase 2 e-invoicing through one integration. One partner key (ztkp_live_) manages merchants and their subscriptions on /api/partner/*, and acts as any of them on the /api/v1 invoice endpoints by adding an X-Merchant-Id header. There are no per-merchant keys.

How do I become a partner?

Sign up at zatcatools.com/partner/register with your email and a one-time code, then issue your partner key from the portal's Account page. Everything the portal does — merchants, subscriptions, orders — is also on this API.

Does every merchant need its own ZATCA onboarding?

Yes. Each merchant is the issuer, with its own VAT number, its own certificate and its own invoice chain, and every branch device gets its own too. The one step no API can automate is the one-time code the merchant generates on the Fatoora portal: your screen collects it and passes it to the connect endpoint, or you send the merchant a signed onboarding link, valid for 72 hours, that takes them through the step on our page.

How is the Partner API priced?

You subscribe merchants at the published plan prices, as many as you like in one order — GET /api/partner/plans returns the plans and any volume pricing that applies today. Every merchant first gets a free trial of 7 days or 5 invoices from the day it connects. A paid subscription starts the day its merchant connects to ZATCA in production, never before, and a renewal is added after the running one ends. For more merchants than one order holds, ask for a custom offer.

What happens when a merchant leaves our platform?

Release deletes nothing: the establishment, its documents and its owner's login remain, because ZATCA requires a six-year archive and an unbroken invoice chain. Every read keeps answering for a released merchant; issuing through your key stops with 403 seat_released until you reinstate it, and the chain resumes where it left off.

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.

BASE URL https://zatcatools.com/api/partner

Quickstart

  1. 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. 2. Create a merchant. POST /merchants with its name and VAT number; the owner's email is optional. Keep the returned id — it goes into every X-Merchant-Id.
  3. 3. Connect it. Pass the Fatoora code the merchant generates to POST /merchants/{id}/connect, and poll the status until connected.
  4. 4. Subscribe it, then issue. POST /orders for its plan, and issue invoices on /api/v1/invoices with X-Merchant-Id.
Create your first merchant
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.

Authenticated request
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_requiredThe header is missing — a partner key alone names no merchant.
404 not_foundNo such merchant under your partnership. Other partners' merchants are invisible.
403 seat_releasedThe merchant was released — reads still work; reinstate it to issue again.
Act as merchant 34
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

One step in the whole journey cannot be automated by any API, ours or anyone's: the one-time code 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 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."
  }
}
HTTPcodeMeaning
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 is 429 with a Retry-After header; 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 statusLine status
awaiting_paymentpending — not paid yet
payment_reviewawaiting_connection — paid; starts when the merchant connects
paidapplied — the term started or was extended (term_from → term_to)
cancelledcancelled

The merchant object

  • id integer
    Goes into every X-Merchant-Id.
  • status string
    The connection: awaiting_onboarding, trial, connected, failed or released.
  • summary object
    The one answer the portal leads with. status: not_connected, connect_failed, simulation, connecting, trial, trial_ended, payment_pending, payment_review, active, expiring, expired or released. next_action: link, open, subscribe, renew, pay or null.
  • subscription object
    The paid term: started_at, expires_at, days_left (negative once ended) and plan.
  • trial object|null
    The free trial while nothing paid covers the merchant: started, days_left, invoices_left, ended.
  • pending_order object|null
    An 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

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

Request
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

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

Request
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

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

  • name string required
    Establishment name as registered with ZATCA.
  • email string
    Optional. 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 with PATCH. An address that already has an account is refused, never adopted.
  • vat_number string required
    15 digits, starting and ending with 3.
  • short_address string
    Saudi national short address (RRRD2929); street, district, city and postal code resolve from it. Explicit fields win.
  • cr_number string
    Optional here, required before connecting.
  • cr_scheme string default CRN
    CRN, MOM, MLS, SAG, 700 or OTH — what kind of number cr_number is.
  • street, building_number, subdivision, city, postal_zone string
    Explicit address details.
Request
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

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

Request
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

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

Request
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

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

  • otp string required
    The 6-digit code the merchant generated on fatoora.zatca.gov.sa.
Request
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

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

Request
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"
}

Release & reinstate

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

Request
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

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

  • lines array of objects required
    One per merchant, each merchant once.
    • merchant_id integer required
      One of your merchants, not released and not in another unpaid order.
    • plan string required
      A key from GET /plans.
    • period string required
      monthly or yearly.
Request
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

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

  • lines array of objects required
    As for POST /quote.
  • expected_total number
    The total you confirmed.
Request
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

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

Request
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

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

  • receipt file required
    PNG, JPG, WebP or PDF, up to 4 MB.
  • reference string
    The bank's transaction number.
  • transferred_at date
    YYYY-MM-DD, not in the future.
Request
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

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

Request
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

POST /api/partner/orders/{id}/cancel

Withdraws an order still waiting for payment. Once its receipt is uploaded, write to us instead — money has moved.

Request
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

POST /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_count integer required
    How many merchants.
  • plan string default mixed
    growth, business or mixed.
  • message string
    Anything we should know.
Request
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

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

Request
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

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

Request
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

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

Request
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
  }
}
Questions about an integration? Write to [email protected] — we answer during business hours.