Developers

Invoices API

Create drafts and tax invoices, handle customers, currencies, discounts and VAT exemptions, and follow each invoice through ZATCA clearance or reporting.

Endpoints

POST /api/v1/invoices
Available Create a draft, or issue a tax invoice.
GET /api/v1/invoices/{id}
Available Read an invoice with its ZATCA status.
GET /api/v1/invoices
Available List and filter invoices.
GET /api/v1/invoices/{id}/status
Available A lightweight status: number, payment and ZATCA.
POST /api/v1/invoices/{id}/validate
Available Check whether a draft can be issued.
POST /api/v1/invoices/{id}/issue
Available Issue a draft created earlier.
GET /api/v1/invoices/{id}/pdf
Available Download the PDF.
GET /api/v1/invoices/{id}/xml
Available Download the legal XML of an issued invoice.
POST /api/v1/invoices/{id}/notes
Available Create a credit or debit note.
GET /api/v1/invoices/{id}/payments
Available The customer payments of an invoice and its balance.
POST /api/v1/invoices/{id}/payments
Available Record a customer payment on an issued invoice.

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.

To issue a draft created earlier, check it first with 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:write scope, 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 the invoice.payment_recorded webhook.
# 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 Created with the new invoice.
  • Any later call with the same reference answers 200 OK with 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:read receives only id, number and status.
A replay returns the original invoice even when the new body differs. Use a new reference for a genuinely new sale.
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");
}
Every write also accepts an 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.