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');
Renew the current production certificate with a new OTP from the Fatoora portal.
use ZatcaX\SDK\Laravel\Facades\Zatca;
$response = Zatca::compliance()->renewCertificate(
ownerUuid: $ownerUuid,
otp: $otp,
);
if ($response->isSuccessful()) {
$certificate = $response->getData();
}
Import credentials exported from an existing accounting or ZATCA integration system. The secret is optional when it is already encoded in the authorization token.
use ZatcaX\SDK\Laravel\Facades\Zatca;
$response = Zatca::compliance()->importCertificate([
'owner_uuid' => $ownerUuid,
'certificate' => $certificate,
'private_key' => $privateKey,
'authorizations' => $authorizationToken,
'secret' => $certificateSecret,
'environment' => 'production',
'production' => true,
]);
if ($response->isSuccessful()) {
$certificate = $response->getData();
}
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.statusiscompletedwhen all compliance invoices are reported; otherwise it isfailed.data.certificatecontains 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,
);
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_uuidwith 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);
This example adds SAR 75 to original invoice INV-1001. The note contains only the additional amount and a clear reason.
use ZatcaX\SDK\Builders\InvoiceBuilder;
use ZatcaX\SDK\Builders\ItemBuilder;
$debitNote = InvoiceBuilder::make()
->ownerUuid($ownerUuid)
->id(1003) // New, unique sequential document ID.
->standard()
->debitNote()
->billingReferenceId('INV-1001')
->previousHash($currentChainHash)
->supplier($supplier)
->customer($customer)
->addItem(fn (ItemBuilder $item) => $item
->name('Additional service charge')
->unitCode('PCE')
->quantity(1)
->unitPrice(75)
->discount(0))
->notes('Additional service charge omitted from invoice INV-1001.');
$response = Zatca::invoice()->submit($debitNote);
$debitNoteHash = $response->json('data.invoice_hash');
$chainStore->saveHash($ownerUuid, $debitNoteHash);
Activity, PDF, and QR outputs
$activities = Zatca::compliance()->activities($requestId)->getData();
$pdfUrl = Zatca::invoice()->getPdfUrl($invoiceUuid);
$qrUrl = Zatca::invoice()->getQrCodeUrl($invoiceUuid);
pending, reported, or rejected.