Endpoints
/api/v1/invoices
/api/v1/invoices/{id}
/api/v1/invoices
/api/v1/invoices/{id}/status
/api/v1/invoices/{id}/validate
/api/v1/invoices/{id}/issue
/api/v1/invoices/{id}/pdf
/api/v1/invoices/{id}/xml
/api/v1/invoices/{id}/notes
/api/v1/invoices/{id}/payments
/api/v1/invoices/{id}/payments
Draft or issued
A create call issues the invoice unless you send "issue": false.
| Draft (`"issue": false`) | Issued (default) | |
|---|---|---|
| Number | DRAFT-… reference, number_is_final: false |
The next number in your sequence, number_is_final: true |
| Scopes | invoices:write |
invoices:write and invoices:issue |
| ZATCA | Nothing is sent | Cleared or reported automatically when the company is linked to ZATCA |
| Changes | Issue it with POST /invoices/{id}/issue, or edit, issue or cancel it in the dashboard |
Final; corrected only with a credit or debit note |
When an invoice is issued, its issue date becomes the current business day in Saudi Arabia, and a due date that would fall before it moves forward by the same payment term. Numbers are assigned only at issue, so drafts never leave gaps in your legal sequence.
POST /invoices/{id}/validate (nothing changes), then call POST /invoices/{id}/issue with a key holding invoices:issue. Issuing an invoice that is already issued answers 200 with already_issued: true.
Request fields
The full field list is in the API reference. The essentials:
| Field | Notes |
|---|---|
issue_date |
Required. YYYY-MM-DD. |
items |
Required, 1 to 200 lines. Each needs description, quantity (greater than 0) and unit_price (before VAT). |
items[].vat_rate |
Percentage, default 15. Or tax_rate_id to use one of your saved tax rates. |
items[].discount |
An amount off the line, before VAT. |
customer |
Optional: {"id": …} for an existing customer, or inline details. See Customers. |
external_reference |
Your own id for the sale. Makes creation safe to retry. |
currency, exchange_rate |
SAR (default), USD or AED. Another currency needs exchange_rate, in SAR per 1 unit. |
document_discount_type, document_discount_value |
A whole-invoice discount (amount or percent), spread across the lines before VAT. |
due_date, notes, terms, locale |
Optional. locale (ar or en) is the language of the invoice document. |
Paid already: an invoice that arrives paid
If your site takes the payment through its own gateway and only then makes the invoice, send the payment with it in payment. The invoice is issued and the payment recorded in the same call, so it arrives paid (or partial for part of the total), with a numbered receipt.
| Field | Notes |
|---|---|
payment.method |
cash (default), bank_transfer, mada, visa_mastercard, apple_pay, stc_pay, cheque, other, or a payment method code your company added. |
payment.reference |
Your gateway transaction id. Shown on the receipt. |
payment.amount |
Optional. Defaults to the invoice total; never more than it. |
payment.paid_on |
Optional. Defaults to today; never a later day. |
payment.notes, payment.account_id |
Optional. account_id is the cash or bank account the money went into. |
- The key needs the
payments:writescope, and the invoice must be issued (not"issue": false). - If the payment is refused (
payment_exceeds_balance,validation_failed), nothing is created: no invoice is issued without its payment. - With
external_reference, a retry returns the same invoice and records no second payment. - Money received later (a bank transfer, a late gateway callback) is recorded with
POST /invoices/{id}/payments. Each payment sends theinvoice.payment_recordedwebhook.
# Your gateway confirmed the payment: issue the invoice already paid.
curl -X POST https://fatoora.tw.sa/api/v1/invoices \
-H "Authorization: Bearer wbk_test_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"external_reference": "ORDER-1001",
"issue_date": "2026-11-12",
"customer": {"name": "Walk-in customer"},
"items": [{"description": "Annual plan", "quantity": 1, "unit_price": 100}],
"payment": {"method": "mada", "reference": "GW-TXN-55"}
}'
// After your gateway's success callback, never before.
$response = $client->post('invoices', [
'json' => [
'external_reference' => $order->id, // safe to retry
'issue_date' => date('Y-m-d'),
'items' => $order->lines,
'payment' => [
'method' => 'mada', // or visa_mastercard, apple_pay, stc_pay …
'reference' => $gateway->transactionId, // shown on the receipt
// 'amount' defaults to the invoice total
],
],
'http_errors' => false,
]);
$body = json_decode((string) $response->getBody(), true);
// $body['data']['invoice']['status'] === 'paid'
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.id, // safe to retry
issue_date: new Date().toISOString().slice(0, 10),
items: order.items,
payment: { method: "mada", reference: gatewayTxn.id }, // amount defaults to the total
}),
});
const body = await res.json();
// body.data.invoice.status === "paid", body.data.payment.number is the receipt
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["id"], # safe to retry
"issue_date": order["date"],
"items": order["items"],
"payment": {"method": "mada", "reference": txn["id"]}, # amount defaults to the total
},
timeout=60,
)
body = res.json()
# body["data"]["invoice"]["status"] == "paid"
Safe retries with external_reference
Networks fail. When a create call times out you cannot tell whether the invoice was made, and issuing twice creates two tax invoices that cannot be withdrawn. Send your own unique external_reference with every create call:
- The first call answers
201 Createdwith the new invoice. - Any later call with the same reference answers
200 OKwith the invoice already created and"replayed": true. Nothing new is made, and the repeat does not count against your plan. - In a replay, a key without
invoices:readreceives onlyid,numberandstatus.
import os, time, uuid
import requests
API = "https://fatoora.tw.sa/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['WONDERFATOORA_API_KEY']}",
"Accept": "application/json"}
def create_invoice(order):
payload = {
# Your own order id: a retry after a timeout returns the invoice
# the first attempt created instead of issuing a second one.
"external_reference": order["id"],
"issue_date": order["date"],
"items": order["items"],
}
for attempt in range(5):
try:
res = requests.post(f"{API}/invoices", json=payload, headers=HEADERS, timeout=60)
except requests.RequestException:
time.sleep(2 ** attempt) # network error: same payload, try again
continue
if res.status_code == 429:
time.sleep(int(res.headers.get("Retry-After", "5")))
continue
if res.status_code >= 500:
time.sleep(2 ** attempt)
continue
return res.json() # 200/201, or a 4xx to fix, not retry
raise RuntimeError("WonderFatoora API unavailable, will retry later")
async function createInvoice(order) {
const payload = {
external_reference: order.id, // safe to retry
issue_date: order.date,
items: order.items,
};
for (let attempt = 0; attempt < 5; attempt++) {
let res;
try {
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(payload),
signal: AbortSignal.timeout(60_000),
});
} catch {
await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
continue;
}
if (res.status === 429) {
await new Promise((r) => setTimeout(r, Number(res.headers.get("Retry-After") ?? 5) * 1000));
continue;
}
if (res.status >= 500) {
await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
continue;
}
return res.json(); // 200/201, or a 4xx to fix rather than retry
}
throw new Error("WonderFatoora API unavailable, retry later");
}
Idempotency-Key header (8 to 255 visible ASCII characters): a retry with the same key and body returns the first answer with Idempotent-Replayed: true, and the same key with a different body is refused with 422 idempotency_key_reused. Keys are kept for 24 hours, per credential: only the API key (or the app's connection) that sent a request gets its answer replayed; another key of the company sending the same Idempotency-Key makes a request of its own, and so does a key's replacement after a rotation.
Foreign currency and VAT exemptions
For an export or any other zero-rated or exempt line, set vat_rate to 0 and give the ZATCA reason code in vat_exemption_code, for example VATEX-SA-32 (export of goods), VATEX-SA-33 (export of services) or VATEX-SA-35 (medicines and medical equipment). For a company linked to ZATCA, such a line needs its reason before the invoice can be issued, unless the sale itself shows it (an export).
{
"issue_date": "2026-11-03",
"currency": "USD",
"exchange_rate": 3.75,
"customer": { "name": "Example Inc.", "country": "US" },
"items": [{
"description": "Software licence (export of services)",
"quantity": 1,
"unit_price": 400,
"vat_rate": 0,
"vat_exemption_code": "VATEX-SA-33"
}]
}
ZATCA status
Every invoice carries a zatca object that says where it stands with ZATCA:
| Field | Meaning |
|---|---|
flow |
clearance for standard (B2B) invoices, reporting for simplified (B2C) invoices, or null. |
status |
pending, cleared, reported, rejected, retrying, not_submitted or unknown; null when nothing is submitted (a draft, or a company not linked to ZATCA). |
with_warnings |
true when ZATCA accepted the invoice with warnings. |
uuid |
The invoice UUID, once submitted. |
deliverable |
true when the invoice may be given to the buyer. A standard invoice is held until ZATCA clears it. |
errors |
ZATCA's own messages when it rejected the invoice (up to 5). |
| Status | Meaning | What to do |
|---|---|---|
pending |
Submitted or queued; no answer yet. | Read the invoice again after a short wait. |
retrying |
ZATCA could not be reached; retried automatically. | Nothing; read it again later. |
cleared, reported |
Accepted by ZATCA. | Deliver it (public_url). |
rejected |
Refused by ZATCA; errors says why. |
Fix the cause (often company or buyer data) in the dashboard. |
not_submitted |
Held back before reaching ZATCA, for example while the company's ZATCA link is incomplete. | Check the ZATCA settings in the dashboard. |
unknown |
Sent, but the outcome was not recorded. | Our support team reconciles it; contact us if it persists. |
Subscribe to the invoice.zatca_status_changed webhook to be told when this changes. Without webhooks, read GET /invoices/{id} with a gentle backoff (for example after 5 seconds, 30 seconds, 2 minutes and 10 minutes) until deliverable is true or the status is final.
Sharing the invoice
public_url is a page your customer can open, with a PDF download. While a standard invoice awaits clearance, the page shows a notice instead of the document.
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.