The flow
- Your billing system charges the customer with your payment provider, under your own rules.
- On success, a background job calls
POST /invoiceswith the payment id asexternal_reference. - When
zatca.deliverableistrue, sendpublic_urlwith your receipt email. - For refunds or plan changes after issue, raise a credit note with
POST /invoices/{id}/notes(or in the WonderFatoora dashboard).
Mapping a subscription to an invoice
| Your data | API field |
|---|---|
| Payment or billing-period id | external_reference |
| Payment date | issue_date |
| Plan and period | items[].description, for example "Pro plan, November 2026" |
| Price before VAT | items[].unit_price |
| A coupon on the whole order | document_discount_type and document_discount_value |
| Customer | customer.id (stored after the first invoice) or inline details |
| Currency | currency and exchange_rate |
Business and consumer customers
A business customer with a valid Saudi VAT number gets a standard invoice, cleared by ZATCA before you send it. Create such customers once with POST /customers (or in the dashboard) with their full national address and reuse the id. A consumer, or a customer without a Saudi VAT number, gets a simplified invoice that you can send straight away.
Customers outside Saudi Arabia
Set customer.country to their ISO code. Services supplied to a customer outside Saudi Arabia are often zero-rated; in that case send vat_rate: 0 with vat_exemption_code: "VATEX-SA-33". Whether a supply qualifies is a question for your tax adviser.
{
"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"
}]
}
Never invoice twice
Billing jobs retry. With external_reference set to the payment id, a retried job gets back the invoice the first attempt created (200, "replayed": true).
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");
}
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.