Developers Use cases

SaaS billing API for Saudi Arabia

Issue a Saudi tax invoice for every subscription payment: the flow, the field mapping, retries, business and foreign customers, and what to send your customer.

The flow

  1. Your billing system charges the customer with your payment provider, under your own rules.
  2. On success, a background job calls POST /invoices with the payment id as external_reference.
  3. When zatca.deliverable is true, send public_url with your receipt email.
  4. 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.