How to Build a Wafeq API Integration

A developer guide to the Wafeq accounting API: authentication, invoices, contacts, error handling, and ZATCA webhooks for KSA.

GJGJ

GJ · Co-founder, Apideck

8 min read
How to Build a Wafeq API Integration

Saudi Arabia made e-invoicing mandatory in 2021 under ZATCA regulations. The UAE followed with its own FTA e-invoicing rollout. If you're building a product that handles money for businesses in the Gulf, you will run into customers on Wafeq.

Wafeq processed over 2 million invoices worth $400 million every month as of its November 2024 Series A, with roughly 90% of that volume coming from Saudi Arabia. That concentration tells you most of the traction is in the market with the strictest compliance requirements. Notable customers include Tabby and Platinumlist, category-defining companies in MENA fintech and ticketing. If your product touches their accounting layer, a Wafeq API integration will come up.

This guide covers authentication with the Wafeq accounting API, the core data model, contact and invoice creation, idempotency, webhooks, and where to route KSA Phase 2 traffic separately.

Authentication

Wafeq supports two authentication methods: API key and OAuth2.

For internal tools or direct integrations where your company manages its own Wafeq workspace, API key auth is the simpler path. You pass the key as a header on every request:

Authorization: Api-Key <YOUR_API_KEY>

To get your key, log into the Wafeq dashboard, navigate to API Keys under Developer in the sidebar, click "Create Key", give it a name, and click Create.

For multi-tenant applications where your users connect their own Wafeq accounts, you need OAuth2. Contact Wafeq directly to obtain client credentials. The header shifts to:

Authorization: Bearer <access_token>

All v1 endpoints live at https://api.wafeq.com/v1/. Saudi Arabia Phase 2 e-invoicing (ZATCA Phase 2) uses a completely separate service at https://zatca.wafeq.com. The standard invoicing API covers Phase 1 and countries that don't implement e-invoicing; for KSA Phase 2, Wafeq maintains dedicated ZATCA API docs. If you're building for KSA compliance, consult those separately before writing integration code — the data models diverge.

The Wafeq accounting API data model

A few structural things worth knowing before writing code.

Entity IDs are prefixed strings. A contact ID looks like cnt_7mWREbbgavfh6JCGsQCSkz, an account like acc_FSD8TuroF3ywN.... The prefix makes it easy to identify the object type from the ID itself when reading logs.

Pagination uses ?page=1&page_size=25 and returns responses shaped like:

{
  "count": 142,
  "next": "https://api.wafeq.com/v1/contacts/?page=2",
  "previous": null,
  "results": [...]
}

For idempotency on writes, pass a UUID v4 in the X-Wafeq-Idempotency-Key header. Wafeq caches the response for one hour and replays it for duplicate requests, returning X-Wafeq-Idempotent-Replayed: true on replays. Use this on invoice creation and payment recording, where duplicates are expensive to unwind.

Creating a contact

Before you can create an invoice, you need a contact to bill. The contacts endpoint is POST https://api.wafeq.com/v1/contacts/.

const res = await fetch("https://api.wafeq.com/v1/contacts/", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": "Api-Key " + process.env.WAFEQ_API_KEY
  },
  body: JSON.stringify({
    name: "Acme Trading LLC",
    email: "billing@acme.com",
    phone: "+971500000000"
  })
});

if (!res.ok) {
  const err = await res.text();
  throw new Error(`Create contact failed: ${res.status} ${err}`);
}

const contact = await res.json();

The response includes the contact's id (prefixed cnt_...), which you pass into invoice creation. You can extend the request body with address data and tax registration numbers per the Contacts API reference.

Resolving accounts

Wafeq uses a chart of accounts to categorize where money comes from and goes to. Before creating an invoice, you need two account IDs: a revenue account (where sales post in the P&L) and a payment account (where you received the money, typically a bank account).

const res = await fetch("https://api.wafeq.com/v1/accounts/", {
  headers: { "Authorization": "Api-Key " + process.env.WAFEQ_API_KEY }
});

const { results } = await res.json();

const revenueAccount =
  results.find(a => a.code === "4000") ||
  results.find(a => a.name?.toLowerCase() === "sales");

Filter by account code or exact name rather than classification — classification strings can vary between workspaces, but chart of account codes like 4000 for Sales are more consistent. For the payment account, use the is_payment_enabled filter:

GET https://api.wafeq.com/v1/accounts/?is_payment_enabled=true

Cache these IDs. They don't change between requests, and re-fetching them on every invoice creation adds unnecessary latency.

Creating an invoice

With a contact ID, revenue account ID, and payment account ID in hand, you can create an invoice. The bulk send endpoint is POST https://api.wafeq.com/v1/api-invoices/bulk_send/ and accepts an array of invoice objects.

const res = await fetch("https://api.wafeq.com/v1/api-invoices/bulk_send/", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": "Api-Key " + process.env.WAFEQ_API_KEY,
    "X-Wafeq-Idempotency-Key": crypto.randomUUID()
  },
  body: JSON.stringify([{
    reference: "ORDER-001",
    invoice_number: "INV-2024-001",
    invoice_date: "2024-11-01",
    currency: "AED",
    paid_through_account: "<payment_account_id>",
    language: "en",
    tax_amount_type: "TAX_INCLUSIVE",
    contact: { id: "<contact_id>" },
    channels: [{
      medium: "email",
      data: {
        subject: "Invoice from Acme",
        message: "<p>Please find your invoice attached.</p>",
        recipients: { to: ["customer@example.com"], cc: [], bcc: [] }
      }
    }],
    line_items: [{
      name: "Consulting services",
      description: "Q4 2024 advisory",
      account: "<revenue_account_id>",
      quantity: 1,
      price: 5000,
      tax_rate: "<tax_rate_id>"
    }]
  }])
});

A successful request queues the invoice and returns {"queued": 1}; Wafeq then delivers the invoice PDF as an email attachment.

The reference field is your internal identifier. Use it to check for duplicates before creating — Wafeq does not enforce uniqueness on invoice_number, so deduplication logic needs to run on reference.

For tax rates, fetch the list from GET https://api.wafeq.com/v1/tax-rates/ and match by name or code. In Saudi Arabia, the standard VAT rate is 15%. In the UAE, it's 5%.

Error handling

Wafeq returns standard HTTP status codes. A few patterns worth handling explicitly:

400 Bad Request usually means a missing required field or a malformed account ID. The response body includes field-level error detail — parse it and log it, as the message is specific enough to act on.

401 Unauthorized means the API key is wrong, expired, or missing from the header. Check that your environment variable is set and the Api-Key prefix (with the space) is included.

429 Too Many Requests means you've hit the rate limit. Implement exponential backoff with jitter. Wafeq's rate limits are not publicly documented, so treat any 429 as a signal to back off for at least 30 seconds before retrying.

500 Internal Server Error from Wafeq should be retried with idempotency keys in place. If the error persists across multiple retries, contact Wafeq support before assuming a code issue on your end.

Webhooks (ZATCA Phase 2)

Webhooks in the Wafeq ecosystem are currently specific to the ZATCA Phase 2 API, not the standard invoicing API. Every document submitted to ZATCA generates a webhook about its current reporting state, which your integration can consume to stay in sync with ZATCA's response.

The webhook payload arrives as a POST to your registered endpoint and carries an X-Wafeq-Webhook-Secret header that is unique per integration, which you use to verify the request came from Wafeq. If that secret is ever compromised, Wafeq lets you regenerate it.

A reported invoice payload looks like this:

{
  "payload": {
    "document": {
      "id": "61d7226e-c04a-4e55-a459-ad442812dbf0",
      "document_number": "INV-2023-001",
      "issue_datetime": "2023-09-04T13:05:38.113425Z",
      "supply_date": "2023-01-01"
    },
    "status": "reported",
    "response": { }
  },
  "event_id": "68158622-72be-4e53-8a2d-5e813c959abf",
  "event_type": "zatca.simplified_invoice.reported",
  "timestamp": "2023-09-04T13:05:44.887378Z"
}

The status field can be failed, pending, or reported. In your webhook handler, verify the secret first, then update your local record based on status. Return HTTP 200 promptly — processing should happen asynchronously.

For the standard invoicing API outside KSA Phase 2, Wafeq does not currently publish webhook events. Poll the invoices endpoint for status updates if your use case requires it.

When to use a unified API instead

Building a direct Wafeq integration takes meaningful engineering time, and Wafeq is one of several accounting platforms you'll need to support if you're selling across MENA and beyond. Apideck's accounting API normalizes Wafeq alongside QuickBooks, Xero, Sage, and 20+ other systems behind a single interface. You write the integration once and activate connectors per customer from the full accounting connectors list.

The trade-off is coverage: a unified layer gives you a common data model across platforms but cannot expose every Wafeq-specific field, particularly around ZATCA compliance details. For standard invoice creation, expense sync, and contact management, the unified model covers the common ground. For compliance-heavy workflows tied to KSA Phase 2 regulations, direct API access gives more control.

For teams integrating multiple accounting systems in parallel, starting with a unified accounting API and adding direct integration for edge cases is typically the faster path to production. If Wafeq is your only target and compliance depth matters, go direct.


To connect to Wafeq and 200+ other APIs without rebuilding the integration for each one, start a free trial of Apideck and get running within 30 days.

Ready to get started?

Scale your integration strategy and deliver the integrations your customers need in record time.

Ready to get started?
Talk to an expert

Trusted by fast-moving product & engineering teams

JobNimbus
Blue Zinc
Exact
Drata
Octa
Apideck Blog

Insights, guides, and updates from Apideck

Discover company news, API insights, and expert blog posts. Explore practical integration guides and tech articles to make the most of Apideck's platform.