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.