ZatcaX
Home Documentation Standalone PHP

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');

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.

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,
);
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.

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.