Standalone PHP
Use the same resources and builders in a framework-free PHP application.
Create a client
require __DIR__.'/vendor/autoload.php';
use ZatcaX\SDK\Config;
use ZatcaX\SDK\ZatcaClient;
$config = new Config(
baseUrl: 'https://zatcax.example.com',
clientSecret: getenv('ZATCAX_CLIENT_SECRET'),
clientId: getenv('ZATCAX_CLIENT_ID'),
domain: 'erp.example.com',
);
$zatca = new ZatcaClient($config);
Certificate lifecycle
The standalone client exposes the same create, renew, and import operations without Laravel's service container or Facade.
Generate a fresh six-digit OTP in the ZATCA Fatoora portal. CSR text fields must contain English characters. The invoice type is 1000 for standard, 0100 for simplified, or 1100 for both.
use ZatcaX\SDK\Builders\CredenceBuilder;
use ZatcaX\SDK\Enums\Environment;
$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 an existing production certificate using the owner UUID and a new Fatoora OTP.
$response = $zatca->compliance()->renewCertificate(
ownerUuid: $ownerUuid,
otp: $otp,
);
if ($response->isSuccessful()) {
$certificate = $response->getData();
}
Import an existing certificate, private key, and authorization token from another compliant integration.
$response = $zatca->compliance()->importCertificate([
'owner_uuid' => $ownerUuid,
'certificate' => $certificate,
'private_key' => $privateKey,
'authorizations' => $authorizationToken,
'secret' => $certificateSecret, // Optional when encoded in authorizations.
'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.
Standalone PHP webhook receiver
Expose this script at the URL passed to webhookUrl(). Persist or queue the payload before returning HTTP 200.
$payload = json_decode(
file_get_contents('php://input'),
true,
512,
JSON_THROW_ON_ERROR,
);
if (($payload['data']['target'] ?? null) !== 'credence') {
http_response_code(422);
exit;
}
$requestId = $payload['data']['request_id'];
$status = $payload['data']['status'];
$certificate = $payload['data']['certificate'];
// Persist once using $requestId as an idempotency key.
saveCertificateWebhook($requestId, $status, $certificate, $payload);
header('Content-Type: application/json');
http_response_code(200);
echo json_encode(['received' => true]);
Submit an invoice
use ZatcaX\SDK\Builders\InvoiceBuilder;
use ZatcaX\SDK\Builders\ItemBuilder;
$invoice = InvoiceBuilder::make()
->ownerUuid($ownerUuid)
->id(5001)
->simplified()
->supplier($supplier)
->customer($customer)
->addItem(fn (ItemBuilder $item) => $item
->name('Consulting service')
->unitCode('HUR')
->quantity(2)
->unitPrice(250)
->discount(0))
->addItem(fn (ItemBuilder $item) => $item
->name('Software license')
->unitCode('PCE')
->quantity(3)
->unitPrice(120)
->discount(20));
$response = $zatca->invoice()->submit($invoice);
if ($response->isSuccessful()) {
print_r($response->getData());
}
Repeat addItem() for every line. The callback form assigns sequential item IDs automatically and the builder calculates the taxable total across all items. For a dynamic array, loop over it and call addItem() during each iteration.
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);
Static entry point
Zatca keeps a singleton client for the current PHP process.
use ZatcaX\SDK\Zatca;
Zatca::configure(
baseUrl: 'https://zatcax.example.com',
clientSecret: getenv('ZATCAX_CLIENT_SECRET'),
clientId: getenv('ZATCAX_CLIENT_ID'),
domain: 'erp.example.com',
);
$response = Zatca::invoice()->submit($invoice);
Array payloads
Resource methods accept builders or arrays. Arrays must match the same structure returned by toArray().
$payload = $invoice->toArray();
$response = $zatca->invoice()->submit($payload);
// Low-level access is also available:
$response = $zatca->post('api/zatca/compliance/invoice/submit', $payload);
$response = $zatca->get('api/zatca/compliance/activities/'.$requestId);
Long-running workers
Create one client per process and reuse it. Set a suitable timeout for queue workers, retry transient transport failures in your job system, and never retry validation or authorization failures without correcting the request.