ZatcaX
Home Documentation Laravel Integration

Laravel Integration

Use the auto-discovered Facade, inject ZatcaClient, and build typed invoice and compliance payloads.

1. Configure Laravel

php artisan vendor:publish --tag=zatca-config

Add the ZATCAX_* variables described on the Configuration page. The Zatca alias and service provider are registered through package discovery.

2. Certificate lifecycle

Create, renew, or import a certificate through the Laravel Facade. Keep the owner UUID stable because it scopes the certificate and every invoice submitted by that client.

Generate a fresh six-digit OTP in the ZATCA Fatoora portal. CSR text fields must contain English characters. Choose 1000 for standard invoices, 0100 for simplified invoices, or 1100 for both.

use ZatcaX\SDK\Builders\CredenceBuilder;
use ZatcaX\SDK\Enums\Environment;
use ZatcaX\SDK\Laravel\Facades\Zatca;

$credence = CredenceBuilder::make()
    ->ownerUuid($ownerUuid)
    ->organizationIdentifier('300000000000003')
    ->solutionName('Acme ERP')
    ->model('Cloud Server')
    ->serialNumber('ACME-ERP-001')
    ->commonName('Acme Trading')
    ->countryName('SA')
    ->organizationName('Acme Trading Company')
    ->organizationalUnitName('Riyadh Branch')
    ->address('King Fahd Road Riyadh')
    ->invoiceType('1100')
    ->production(false)
    ->environment(Environment::Simulation)
    ->businessCategory('Retail')
    ->otp($otp)
    ->webhookUrl('https://erp.example.com/webhooks/zatca');

$response = Zatca::compliance()->credence($credence);
$requestId = $response->json('data.request_id');

What the webhook receives

After certificate onboarding and compliance invoice checks finish, ZatcaX sends one HTTP POST request with a JSON body to the URL passed to webhookUrl().

Method

POST

Content type

application/json

Expected response

HTTP 2xx

Example completion payload:

{
  "status": true,
  "message": "Success",
  "data": {
    "request_id": "2de7c6c5-1a2b-4df5-9b44-8f8e7e57d937",
    "owner_uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "target": "credence",
    "status": "completed",
    "certificate": {
      "certificate": "MIIC...base64-certificate...",
      "private_key": "MHcC...private-key...",
      "authorizations": "base64-token-and-secret"
    },
    "invoices": [
      {
        "number": 1,
        "status": "completed",
        "submitted": "reported",
        "production": false,
        "notes": null
      }
    ]
  }
}
  • This callback reports completion of the certificate onboarding workflow. It is not a callback for every invoice submitted later through invoice()->submit().
  • data.status is completed when all compliance invoices are reported; otherwise it is failed.
  • data.certificate contains sensitive credentials. Use HTTPS, keep the endpoint server-side, and never write the private key or authorization value to application logs.
  • The delivery timeout is four seconds. Transport failures are attempted up to three times with a three-second delay and are recorded in the ZatcaX server log if delivery still fails.
  • Return an HTTP 2xx response promptly. Move slow processing to your own queue.

Laravel webhook receiver

Register a public CSRF-exempt API route and use data.request_id as an idempotency key before dispatching your own processing job.

use App\Jobs\ProcessZatcaCertificateWebhook;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('/webhooks/zatca', function (Request $request) {
    $payload = $request->validate([
        'status' => ['required', 'boolean'],
        'data.request_id' => ['required', 'uuid'],
        'data.owner_uuid' => ['required', 'uuid'],
        'data.target' => ['required', 'in:credence'],
        'data.status' => ['required', 'in:completed,failed'],
        'data.certificate' => ['required', 'array'],
        'data.invoices' => ['nullable', 'array'],
    ]);

    ProcessZatcaCertificateWebhook::dispatch($payload);

    return response()->json(['received' => true]);
});

3. Build and submit an invoice

use ZatcaX\SDK\Builders\InvoiceBuilder;
use ZatcaX\SDK\Builders\ItemBuilder;
use ZatcaX\SDK\Builders\PartyBuilder;
use ZatcaX\SDK\Enums\IdentificationType;
use ZatcaX\SDK\Enums\PaymentCode;

$invoice = InvoiceBuilder::make()
    ->ownerUuid($ownerUuid)
    ->id(1001)
    ->simplified() // Use standard() for a standard tax invoice.
    ->paymentCode(PaymentCode::BankCard)
    ->supplier(fn (PartyBuilder $party) => $party
        ->identificationId('1010000000')
        ->identificationType(IdentificationType::CRN)
        ->registrationName('Acme Trading Company')
        ->taxId('300000000000003')
        ->address(fn ($address) => $address
            ->street('King Fahd Road')->buildingNumber('1234')
            ->subdivision('Al Olaya')->city('Riyadh')
            ->postalZone('12345')->country('SA')))
    ->customer(fn (PartyBuilder $party) => $party
        ->identificationId('1010111111')
        ->registrationName('Customer Company')
        ->taxId('300000000000010')
        ->address(fn ($address) => $address
            ->street('Olaya Street')->buildingNumber('5678')
            ->city('Riyadh')->postalZone('54321')->country('SA')))
    ->addItem(fn (ItemBuilder $item) => $item
        ->name('Software subscription')->unitCode('PCE')
        ->quantity(1)->unitPrice(1000)->discount(0))
    ->notes('Order #SO-1001');

$response = Zatca::invoice()->submit($invoice);

if ($response->isSuccessful()) {
    $data = $response->getData();
}

The example above adds one invoice line. To add a dynamic number of lines, call addItem() once per item as shown below. Callback items receive sequential IDs automatically, and the builder totals all lines using quantity × unit price − discount.

Add items from a Laravel collection

$invoice = InvoiceBuilder::make()
    ->ownerUuid($ownerUuid)
    ->id($order->invoice_number)
    ->simplified()
    ->supplier($supplier)
    ->customer($customer);

foreach ($order->items as $line) {
    $invoice->addItem(fn (ItemBuilder $item) => $item
        ->name($line->name)
        ->unitCode($line->unit_code ?? 'PCE')
        ->quantity((float) $line->quantity)
        ->unitPrice((float) $line->unit_price)
        ->discount((float) ($line->discount ?? 0))
    );
}

$response = Zatca::invoice()->submit($invoice);

Do not call items() after addItem() unless you intend to replace the entire list. When passing arrays to items([...]), supply an explicit unique id for every line.

Invoice hash chain, credit notes, and debit notes

Every invoice, credit note, and debit note generated by one E-Invoice Generation Solution unit belongs to one ordered chain. The previous_hash embedded in a new document must be the hash of the immediately preceding document in that chain.

Regular invoice

Records a new sale or supply. It advances the same hash chain as adjustment notes.

Credit note

Reduces or cancels a previously invoiced amount, for example a return, cancellation, or post-sale discount.

Debit note

Increases a previously invoiced amount, for example an undercharge or an additional charge discovered later.

Where $previousInvoiceHash comes from

It is returned by ZatcaX after the preceding document is generated. Read data.invoice_hash from the response to invoice()->submit(). Do not use the invoice UUID, QR value, authorization token, or a locally invented hash.

{
  "status": true,
  "data": {
    "invoice_uuid": "7ddf27b8-4ee0-49b4-bf1a-9de0b44946c4",
    "invoice_hash": "Base64EncodedSha256Hash...",
    "invoice_type": "standard",
    "invoice_case": "invoice",
    "submitted_status": "reported"
  }
}
// $invoice is the InvoiceBuilder created in the previous example.
$chain = $chainStore->findByOwnerUuid($ownerUuid); // Your durable database store.

if ($chain !== null) {
    $invoice->previousHash($chain->last_invoice_hash);
}
// For the first document only, omit previousHash(). The builder supplies
// the initial base64-encoded SHA-256 seed automatically.

$response = Zatca::invoice()->submit($invoice);
$data = $response->getData();
$generatedHash = $data['invoice_hash'] ?? null;

if (!$response->isSuccessful() || $generatedHash === null) {
    throw new RuntimeException('The document was not generated; do not advance the chain.');
}

$chainStore->save(
    ownerUuid: $ownerUuid,
    lastInvoiceHash: $generatedHash,
    lastInvoiceUuid: $data['invoice_uuid'],
    lastDocumentId: $documentId,
);
Important: advance the chain when a new signed document returns a non-null invoice_hash. Reporting or clearance status and hash generation are separate concerns. When retrying the same UUID, do not create a second chain entry.

Where to store the hash

Store the current chain head in the same durable database as your invoices. Use one row per EGS unit (represented by owner_uuid in this integration), and also keep the hash on every invoice record for audit and recovery.

CREATE TABLE zatca_invoice_chains (
    owner_uuid CHAR(36) PRIMARY KEY,
    last_invoice_hash TEXT NOT NULL,
    last_invoice_uuid CHAR(36) NOT NULL,
    last_document_id VARCHAR(100) NOT NULL,
    updated_at TIMESTAMP NOT NULL
);
  • Do not store the chain head only in .env, a session, memory, or an expiring cache.
  • Serialize submissions for the same owner_uuid with a FIFO queue, database row lock, or distributed lock. Two concurrent invoices must not read the same previous hash.
  • Do not maintain separate chains for customers, branches, or document types when they are issued by the same EGS unit.
  • Back up the chain table together with invoice XML and submission records.

Use adjustment notes correctly

A note is a new document with its own sequential id and UUID. Call billingReferenceId() with the number of the original invoice being adjusted. Its previousHash() still points to the latest document in the EGS chain, which may be different from the original invoice.

This example credits SAR 200 from original invoice INV-1001. The items and values represent only the reduction, not the full original invoice unless it is being cancelled completely.

use ZatcaX\SDK\Builders\InvoiceBuilder;
use ZatcaX\SDK\Builders\ItemBuilder;

$creditNote = InvoiceBuilder::make()
    ->ownerUuid($ownerUuid)
    ->id(1002) // New, unique sequential document ID.
    ->standard() // Use the same profile as the original invoice.
    ->creditNote()
    ->billingReferenceId('INV-1001') // Original invoice number.
    ->previousHash($currentChainHash) // Latest EGS document, not necessarily INV-1001.
    ->supplier($supplier)
    ->customer($customer)
    ->addItem(fn (ItemBuilder $item) => $item
        ->name('Returned item')
        ->unitCode('PCE')
        ->quantity(1)
        ->unitPrice(200)
        ->discount(0))
    ->notes('Customer returned one item from invoice INV-1001.');

$response = Zatca::invoice()->submit($creditNote);
$creditNoteHash = $response->json('data.invoice_hash');

// Persist this as the chain head before generating the next document.
$chainStore->saveHash($ownerUuid, $creditNoteHash);
Credit and debit notes are not edits to the original invoice. Never reuse the original UUID, never overwrite the original XML, and never pass the original invoice hash merely because the note references that invoice. Each note is the next immutable document in the chain.

Activity, PDF, and QR outputs

$activities = Zatca::compliance()->activities($requestId)->getData();
$pdfUrl = Zatca::invoice()->getPdfUrl($invoiceUuid);
$qrUrl = Zatca::invoice()->getQrCodeUrl($invoiceUuid);
Invoice processing is asynchronous on the ZatcaX server. Persist UUIDs and display the server states: pending, reported, or rejected.