Developers Use cases

Laravel e-invoicing integration

Issue Saudi tax invoices from a Laravel application with the built-in HTTP client: configuration, a service class, a queued job and error handling.

No package is needed: Laravel's HTTP client is enough. This guide assumes Laravel 10 or later.

1. Configuration

// .env
WONDERFATOORA_API_URL=https://fatoora.tw.sa/api/v1
WONDERFATOORA_API_KEY=wbk_test_xxxxxxxxxxxxxxxxxxxxxxxx

// config/services.php
'wonderfatoora' => [
    'url' => env('WONDERFATOORA_API_URL'),
    'key' => env('WONDERFATOORA_API_KEY'),
],

2. A service class

<?php

namespace App\Billing;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;

class WonderFatoora
{
    private function http(): PendingRequest
    {
        return Http::baseUrl(config('services.wonderfatoora.url'))
            ->withToken(config('services.wonderfatoora.key'))
            ->acceptJson()
            ->timeout(60)
            // Retry network errors and 5xx only; external_reference keeps
            // a retried call from issuing a second invoice.
            ->retry(3, 2000, fn ($e) => ! $e instanceof \Illuminate\Http\Client\RequestException
                || $e->response->serverError(), throw: false);
    }

    /** @return array the invoice, as the API returns it */
    public function issueForOrder(Order $order): array
    {
        $response = $this->http()->post('invoices', [
            'external_reference' => 'order-'.$order->id,
            'issue_date'         => $order->paid_at->toDateString(),
            'customer'           => ['id' => $order->customer->wonderfatoora_id],
            'items'              => $order->lines->map(fn ($line) => [
                'description' => $line->name,
                'quantity'    => $line->quantity,
                'unit_price'  => $line->unit_price,
            ])->all(),
        ]);

        if (! $response->json('ok')) {
            throw new \RuntimeException($response->json('error.code', 'http_'.$response->status()));
        }

        return $response->json('data.invoice');
    }
}

Retries cover network errors and 5xx responses only; a 4xx is a request to fix, not to repeat. Because every call carries external_reference, a retry never issues a second invoice.

3. Issue from a queued job

<?php
// Issue from a queued job, so a slow or failed call never blocks checkout.
class IssueOrderInvoice implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;
    public array $backoff = [10, 60, 300, 900];

    public function __construct(public Order $order) {}

    public function handle(WonderFatoora $api): void
    {
        $invoice = $api->issueForOrder($this->order); // idempotent per order

        $this->order->update([
            'invoice_id'  => $invoice['id'],
            'invoice_url' => $invoice['zatca']['deliverable'] ? $invoice['public_url'] : null,
        ]);
    }
}

Dispatch the job once the order is paid, for example from an event listener: IssueOrderInvoice::dispatch($order)->afterCommit();

4. Handling errors

error.code In your app
validation_failed A mapping bug: log error.details and alert a developer.
invoice_not_issuable A data problem (address, VAT number, exemption reason): show error.message to an admin; do not retry.
insufficient_scope, forbidden Key or user permissions: fix the key; do not retry.
rate_limited Release the job back to the queue after Retry-After seconds.
token_expired The key expired: alert an admin with error.details.renew_url.
unauthorized The key was rotated or revoked: alert an admin.

5. Testing

Fake the API in your tests with Http::fake() and check the payload with Http::assertSent(). Then point the client at a sandbox company with a test key (wbk_test_…): it issues for real, numbering and simulated ZATCA results included, without creating tax invoices.

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.