Developers

Quick start: your first invoice through the API

From an API key to a draft invoice in five minutes, with copyable cURL, PHP, Node.js and Python examples.

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.
No account yet? Send the person who will own it one link: 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

  1. Sign in and open Settings → API keys.
  2. Name the key after the system that will use it, for example "Online store".
  3. Choose its scopes. For this guide: invoices:write and invoices:read. Add invoices:issue only when you are ready to issue real tax invoices.
  4. Choose a lifetime (30, 90, 180 or 365 days) and create the key.
  5. Copy the key now. It is shown once, and WonderFatoora keeps only a fingerprint of it.
Keep the key on your server, in an environment variable or a secrets manager. Never put it in a browser, a mobile app or a code repository.

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,
)
Always send 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

Examples on this site use the placeholder key 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.