Before you start
- A WonderFatoora company on the Business or Enterprise plan; API access is part of both. Business works within its monthly invoice and user limits, Enterprise has none.
- A user who may manage settings (company admins can).
- A complete company profile (name, VAT number and address), so invoices can be issued.
https://fatoora.tw.sa/start?intent=api. They create an account (or sign in, with email or Google) and land on a page that creates their API key and shows the base URL and a first call; on a plan without API access it shows the plan that has it. Add &plan=enterprise to take them to that plan's payment first, and &lang=en for English.
1. Create an API key
- Sign in and open Settings → API keys.
- Name the key after the system that will use it, for example "Online store".
- Choose its scopes. For this guide:
invoices:writeandinvoices:read. Addinvoices:issueonly when you are ready to issue real tax invoices. - Choose a lifetime (30, 90, 180 or 365 days) and create the key.
- Copy the key now. It is shown once, and WonderFatoora keeps only a fingerprint of it.
2. Check the key
curl https://fatoora.tw.sa/api/v1/ping \
-H "Authorization: Bearer wbk_test_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Accept: application/json"
<?php
// composer require guzzlehttp/guzzle
$client = new GuzzleHttp\Client([
'base_uri' => 'https://fatoora.tw.sa/api/v1/',
'headers' => [
'Authorization' => 'Bearer ' . getenv('WONDERFATOORA_API_KEY'),
'Accept' => 'application/json',
],
'timeout' => 30,
]);
$response = $client->get('ping');
$body = json_decode((string) $response->getBody(), true);
echo $body['data']['tenant'];
// Node.js 18+ (built-in fetch)
const res = await fetch("https://fatoora.tw.sa/api/v1/ping", {
headers: {
Authorization: `Bearer ${process.env.WONDERFATOORA_API_KEY}`,
Accept: "application/json",
},
});
const body = await res.json();
console.log(body.ok, body.data?.tenant);
# pip install requests
import os
import requests
res = requests.get(
"https://fatoora.tw.sa/api/v1/ping",
headers={
"Authorization": f"Bearer {os.environ['WONDERFATOORA_API_KEY']}",
"Accept": "application/json",
},
timeout=30,
)
print(res.status_code, res.json())
A working key answers 200 with the company name:
{
"ok": true,
"data": {
"authenticated": true,
"tenant": "Example Trading Co.",
"time": "2026-11-03T09:12:44+03:00"
}
}
3. Create a draft invoice
A draft has a DRAFT-… reference and no legal number, and nothing is sent to ZATCA. It is the safe way to try the API with a live company: review the draft, then issue it with POST /invoices/{id}/issue or in the dashboard, or cancel it there. To try everything without touching your real company, use a sandbox.
curl -X POST https://fatoora.tw.sa/api/v1/invoices \
-H "Authorization: Bearer wbk_test_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"issue": false,
"issue_date": "2026-11-03",
"customer": {
"name": "Example Trading Co.",
"vat_number": "300000000000003",
"address_line1": "King Fahd Rd",
"city": "Riyadh",
"postal_code": "12345",
"country": "SA"
},
"items": [
{ "description": "Consulting", "quantity": 2, "unit_price": 500, "vat_rate": 15 }
]
}'
<?php
$response = $client->post('invoices', [
'json' => [
'issue' => false, // a draft: no legal number, nothing sent to ZATCA
'issue_date' => '2026-11-03',
'customer' => [
'name' => 'Example Trading Co.',
'vat_number' => '300000000000003',
'address_line1' => 'King Fahd Rd',
'city' => 'Riyadh',
'postal_code' => '12345',
'country' => 'SA',
],
'items' => [
['description' => 'Consulting', 'quantity' => 2, 'unit_price' => 500, 'vat_rate' => 15],
],
],
'http_errors' => false,
]);
$body = json_decode((string) $response->getBody(), true);
if ($body['ok']) {
echo $body['data']['invoice']['number']; // DRAFT-… until it is issued
} else {
echo $body['error']['code'];
}
const res = await fetch("https://fatoora.tw.sa/api/v1/invoices", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WONDERFATOORA_API_KEY}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
issue: false, // a draft: no legal number, nothing sent to ZATCA
issue_date: "2026-11-03",
customer: {
name: "Example Trading Co.",
vat_number: "300000000000003",
address_line1: "King Fahd Rd",
city: "Riyadh",
postal_code: "12345",
country: "SA",
},
items: [{ description: "Consulting", quantity: 2, unit_price: 500, vat_rate: 15 }],
}),
});
const body = await res.json();
if (!body.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
console.log(body.data.invoice.id, body.data.invoice.number);
res = requests.post(
"https://fatoora.tw.sa/api/v1/invoices",
headers={
"Authorization": f"Bearer {os.environ['WONDERFATOORA_API_KEY']}",
"Accept": "application/json",
},
json={
"issue": False, # a draft: no legal number, nothing sent to ZATCA
"issue_date": "2026-11-03",
"customer": {
"name": "Example Trading Co.",
"vat_number": "300000000000003",
"address_line1": "King Fahd Rd",
"city": "Riyadh",
"postal_code": "12345",
"country": "SA",
},
"items": [
{"description": "Consulting", "quantity": 2, "unit_price": 500, "vat_rate": 15}
],
},
timeout=30,
)
body = res.json()
print(res.status_code, body["data"]["invoice"]["number"] if body["ok"] else body["error"])
The response is 201 Created with the invoice. An issued invoice looks like this:
{
"ok": true,
"data": {
"invoice": {
"id": 1024,
"number": "INV-00045",
"number_is_final": true,
"status": "issued",
"source": "api",
"currency": "SAR",
"issue_date": "2026-11-03",
"due_date": null,
"customer": { "id": 381, "name": "Example Trading Co.", "vat_number": "300000000000003" },
"subtotal": 1000.00,
"discount_total": 0.00,
"vat_total": 150.00,
"total": 1150.00,
"items": [
{
"description": "Consulting",
"quantity": 2,
"unit": "",
"unit_price": 500.00,
"discount": 0.00,
"vat_rate": 15.00,
"vat_amount": 150.00,
"total": 1150.00
}
],
"public_url": "https://fatoora.tw.sa/i/…",
"zatca": {
"flow": "clearance",
"status": "pending",
"with_warnings": false,
"uuid": "…",
"deliverable": false,
"errors": []
},
"created_at": "2026-11-03T09:12:44+03:00"
}
}
}
4. Issue a tax invoice
Leave out "issue": false (issuing is the default) and give the key the invoices:issue scope. Issuing assigns the next number in your sequence, generates the QR code and, for a company linked to ZATCA, submits the invoice. An issued invoice cannot be edited or deleted; corrections are made with a credit or debit note (POST /invoices/{id}/notes, or in the dashboard).
curl -X POST https://fatoora.tw.sa/api/v1/invoices \
-H "Authorization: Bearer wbk_test_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"external_reference": "ORDER-10045",
"issue_date": "2026-11-03",
"customer": { "id": 381 },
"items": [
{ "description": "Annual subscription", "quantity": 1, "unit_price": 1200 }
]
}'
<?php
// Issuing is the default when "issue" is omitted. The key needs
// invoices:write AND invoices:issue. external_reference makes the call
// safe to retry: a repeat returns the invoice already created.
$response = $client->post('invoices', [
'json' => [
'external_reference' => 'ORDER-10045',
'issue_date' => '2026-11-03',
'customer' => ['id' => 381],
'items' => [
['description' => 'Annual subscription', 'quantity' => 1, 'unit_price' => 1200],
],
],
'http_errors' => false,
]);
const res = await fetch("https://fatoora.tw.sa/api/v1/invoices", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WONDERFATOORA_API_KEY}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
external_reference: "ORDER-10045", // safe to retry
issue_date: "2026-11-03",
customer: { id: 381 },
items: [{ description: "Annual subscription", quantity: 1, unit_price: 1200 }],
}),
});
const body = await res.json();
// 201 = created now, 200 with data.replayed = true = created by an earlier call
console.log(res.status, body.data?.replayed === true, body.data?.invoice?.zatca);
res = requests.post(
"https://fatoora.tw.sa/api/v1/invoices",
headers={"Authorization": f"Bearer {os.environ['WONDERFATOORA_API_KEY']}",
"Accept": "application/json"},
json={
"external_reference": "ORDER-10045", # safe to retry
"issue_date": "2026-11-03",
"customer": {"id": 381},
"items": [{"description": "Annual subscription", "quantity": 1, "unit_price": 1200}],
},
timeout=60,
)
external_reference (your order or payment id) when issuing. If a timeout makes you retry, the API returns the invoice the first call created (200, "replayed": true) instead of issuing a second one.
5. Read it back
curl https://fatoora.tw.sa/api/v1/invoices/1024 \
-H "Authorization: Bearer wbk_test_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Accept: application/json"
<?php
$response = $client->get('invoices/1024', ['http_errors' => false]);
$invoice = json_decode((string) $response->getBody(), true)['data']['invoice'] ?? null;
if ($invoice && $invoice['zatca']['deliverable']) {
// Safe to hand to the buyer: send $invoice['public_url'].
}
const res = await fetch("https://fatoora.tw.sa/api/v1/invoices/1024", {
headers: {
Authorization: `Bearer ${process.env.WONDERFATOORA_API_KEY}`,
Accept: "application/json",
},
});
const { ok, data, error } = await res.json();
if (ok && data.invoice.zatca.deliverable) {
// Safe to hand to the buyer: data.invoice.public_url
}
res = requests.get(
"https://fatoora.tw.sa/api/v1/invoices/1024",
headers={"Authorization": f"Bearer {os.environ['WONDERFATOORA_API_KEY']}",
"Accept": "application/json"},
timeout=30,
)
invoice = res.json().get("data", {}).get("invoice")
if invoice and invoice["zatca"]["deliverable"]:
print(invoice["public_url"])
Check zatca.deliverable before sending the invoice to your customer: a standard (B2B) invoice of a company linked to ZATCA is valid only once ZATCA clears it. See ZATCA status.
Next steps
- Authentication: scopes, rotation, and how a key acts for its creator.
- Invoices: every field, currencies, discounts and VAT exemptions.
- Errors & troubleshooting: every error code and what to do about it.
wbk_test_xxxxxxxxxxxxxxxxxxxxxxxx. Live keys start with wbk_; keys of a sandbox company start with wbk_test_.
Ready to build?
API access is part of the Business and Enterprise plans. Create a key in Settings → API keys and make your first call in minutes.