Qoyod
Pricing

Knowledge Base

PHP Integration Examples

This guide is aimed at developers and technical-integration teams connecting their PHP-written systems to the Fatoora platform as part of e-invoicing in Saudi Arabia. Its topic is single and specific: real, copy-ready PHP code examples that build the invoice-submission path from start to finish. From constructing the authentication header, to computing the hash value, to encoding the document, to sending the request and reading the response and handling errors.

We do not explain here the conceptual story of connecting to the Zakat, Tax and Customs Authority (ZATCA) as a business step; that has its own dedicated guides. We assume you are a PHP developer writing the code right now and want models you can run directly. For each technical concept we point to its reference guide: Authentication for the details of the Authorization header, andAPI endpoints for the catalog of addresses and headers, andInvoice submission for the full step-by-step sequence.

The examples here are written in two styles: native PHP functions relying on the built-in cURL extension, and a parallel version using the Guzzle library for those who prefer a more modern interface. Both perform the same job, so choose what fits your project’s architecture. Each code snippet is preceded by an explanation of what it does, and followed by a note on what you must watch out for.

Before you start, fix in your mind one principle that governs everything that follows: the billing interfaces on the Fatoora platform do not use OAuth. There is no endpoint to request a temporary access token. Authentication relies on the cryptographic stamp certificate (CSID) and its accompanying secret, sent with every request via a Basic header. This simplifies your PHP code, because you do not manage a token lifecycle nor deal with its expiry mid-submission.

Start today

Let Qoyod handle the technical integration with the Fatoora platform

Instead of building the signing, authentication and hashing layer yourself, Qoyod issues your invoices signed and compliant with the Authority’s requirements from day one, so you are free to develop your system rather than manage certificates.

Start your free trial and issue your compliant invoices

What you set up in your PHP environment before the first example

Before you write the first line, make sure your environment has four things. The first is an enabled cURL extension, available by default in most PHP installations. The second is the OpenSSL extension for computing the hash and handling the certificate. The third is a reliable library for building a UBL document, since crafting XML by hand with text strings is a common source of errors. The fourth is your device’s credentials: the certificate ID (CSID) and its accompanying secret.

Verify the availability of the two extensions with a single command line before you build anything on top of them:

<?php
// Verify the required extensions are available before starting
foreach (['curl', 'openssl', 'json'] as $ext) {
    if (!extension_loaded($ext)) {
        throw new RuntimeException("Required extension not enabled: {$ext}");
    }
}
echo "Environment ready\n";

Note that we check for the extensions explicitly and throw a clear exception when they are missing. This is better than an obscure error message that appears late on the first cURL function call. Make this check part of your system’s initialization, not a test you run once and forget.

Place the credentials in the system settings or environment variables, not inside the code. This separation makes it easy to switch between the test environment and the production environment, and prevents the secret from leaking into the code repository. We read them in the following examples from environment variables:

<?php
// Read the credentials from environment variables, not from the code
$config = [
    'base_url' => getenv('FATOORA_BASE_URL'),   // environment address (test or production)
    'csid'     => getenv('FATOORA_CSID'),        // certificate ID from the Authority
    'secret'   => getenv('FATOORA_SECRET'),      // the secret accompanying the certificate
    'version'  => 'V2',                          // interface version
];

foreach (['base_url', 'csid', 'secret'] as $key) {
    if (empty($config[$key])) {
        throw new RuntimeException("Missing configuration value: {$key}");
    }
}

The base address differs between the two environments. The test environment is for compliance and development, and the production environment is for real invoices. The relative path is the same in both; only the base address changes. Fix it in the configuration as you saw above, and do not embed it directly inside the code.

Building the UBL document before signing

Before any authentication or hashing, the invoice document itself must exist in an XML format compliant with UBL 2.1. Do not build this document with text strings you assemble by hand, as this is a common source of errors that appear late at rejection rather than at writing time. Use a reliable library to compose the XML, and populate it from your structured business data.

For illustration only, here is a simplified structure showing how you prepare the invoice data in PHP before passing it to the UBL-building library. The actual document contains more fields required by the specification, but the principle is to separate business data from XML crafting:

<?php
// Structured business data feeding the UBL-building library
$invoiceData = [
    'uuid'         => generateUuid(),          // a unique identifier for each invoice
    'invoiceNumber'=> 'INV-2026-000142',
    'issueDate'    => '2026-06-24',
    'invoiceType'  => 'standard',              // standard or simplified
    'seller' => [
        'name'   => 'Example Trading Establishment',
        'vatNo'  => '300000000000003',
    ],
    'buyer' => [
        'name'   => 'Example Customer',
        'vatNo'  => '310000000000003',
    ],
    'lines' => [
        ['desc' => 'Consulting service', 'qty' => 1, 'price' => 1000.00, 'vatRate' => 0.15],
    ],
    'previousHash' => $store->lastInvoiceHash(), // the previous invoice's hash to link the chain
];

// Pass this data to the UBL library to produce a compliant XML document
$ublXml = $ublBuilder->build($invoiceData);

Note the field previousHash. You read it from your storage layer, and it is the hash of the previous invoice that links the chain. The first invoice uses the initial value defined in the specification. Also remember that the UBL document requires mandatory Arabic content by the Authority’s requirements, with the option to add English alongside it, so make sure the seller, buyer and line-item names are in Arabic inside the XML.

The unique-identifier generation function is simple. It produces a random identifier per the UUID version 4 standard, generates it once per invoice, and stores it:

<?php
/**
 * Generates a unique identifier per the UUID v4 standard
 */
function generateUuid(): string
{
    $data = random_bytes(16);
    $data[6] = chr(ord($data[6]) & 0x0f | 0x40);  // version 4
    $data[8] = chr(ord($data[8]) & 0x3f | 0x80);  // the variant
    return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}

After building the document comes the signing step with the private key linked to the CSID certificate. Signing is a precise cryptographic operation that relies on a specialized library, and writing it from scratch is not advised. What matters to you in PHP code is that signing comes after the XML build is complete and before computing the hash, because any later modification to the document invalidates the signature and invalidates the hash computed over it.

Building the Authorization header with Base64

The first building block in any request is the authentication header. The format is simple: join the certificate ID and the secret with a colon between them, then encode the result with Base64, then place it after the word Basic. What is sent is the certificate ID (which carries the public key) with the secret, not the private key. The private key stays local for signing only.

The logical shape of the header before the code:

Authorization: Basic base64( CSID + ":" + secret )

And this is the PHP function that builds it. We use the built-in base64_encode , so there is no need for any external library for this step:

<?php
/**
 * Builds the Authorization header value per the Basic scheme
 * Inputs: the certificate ID and its accompanying secret
 * Output: the header value ready to send
 */
function buildAuthHeader(string $csid, string $secret): string
{
    $raw   = $csid . ':' . $secret;
    $token = base64_encode($raw);
    return 'Basic ' . $token;
}

// Usage example
$authHeader = buildAuthHeader($config['csid'], $config['secret']);
// The expected result looks like this:
// Basic VFVsSi4uLjphQmNEMTIzNA==

Note that base64_encode in PHP does not add any spaces or newlines, and this is exactly what we want. Some languages break the output into lines at a certain length, so the request is rejected. In PHP you do not face this problem, but if you move the data by hand make sure the string is a single continuous line with no spaces.

The header itself is uniform across the platform’s four interfaces (compliance, issuance, authentication and reporting). You build the function once, and reuse its output in every request. The only difference is that in the test environment you use the compliance ID, and in production you use the production ID. For deeper detail see the guide Authentication.

A fundamental security point: treat the header value as a complete secret, not as passing text. Do not print it in debug logs, do not pass it in a URL, and do not store it in a cache others can reach. Base64 encoding is not encryption, so whoever reads the value decodes it instantly and obtains the secret. Keep the credentials in memory only at the moment of building the request, and do not write them to disk except in a secured secrets vault.

How invoice data turns into a signed request in PHP

Before we dive into computing the hash and sending, a quick map helps you fix the order of the steps. Each step’s output is the next one’s input, and the order is not optional.

The PHP integration path
The steps that PHP code executes to submit a compliant invoice.
1

Build the UBL file and sign it

2

Compute the SHA-256 hash

3

Base64-encode and build the Basic header

4

POST to clearance or reporting

5

Read the response and save the result

PHP code wraps these steps in reusable functions.

Steps one through four prepare the document inside your server. Steps five and six build the request and send it. The seventh step reads the reply and stores it. We go through them in order in the following sections with PHP examples for each one.

Computing the invoiceHash value with the SHA-256 algorithm

Each invoice carries a hash value (invoiceHash) computed from the content of the signed document. This value links the invoice to its predecessor, forming a chain that cannot be tampered with. The hash is computed with the SHA-256 algorithm, then its binary result is encoded with Base64.

What matters here is a technical fine point that is often dropped: do not hash the readable text, but the raw binary representation of the hash. In PHP we ask hash to return the raw bytes via the third parameter, then we encode them:

<?php
/**
 * Computes the invoiceHash value from the signed document content
 * SHA-256 hash over the raw bytes then Base64 encoding
 */
function computeInvoiceHash(string $signedXml): string
{
    // The third parameter true returns the hash as raw bytes, not as a hex string
    $rawHash = hash('sha256', $signedXml, true);
    return base64_encode($rawHash);
}

// Example: the content here is the UBL document after signing it
$invoiceHash = computeInvoiceHash($signedXml);

The common mistake is that the developer passes false or drops the third parameter, so they hash the hex string instead of the raw bytes, and the value differs from what the Authority expects and the request is rejected. Always remember the third parameter true.

This hash value is one of the inputs to the next invoice, so store it as soon as each invoice is issued. Whoever defers building the storage layer discovers later that they broke the hash chain because they did not save the previous invoice’s hash. The hash chain is detailed in the guide Invoice submission.

The hash chain is what makes your invoice ledger tamper-proof. Each invoice’s hash is computed over content that includes its predecessor’s hash, so if someone tried to modify an old invoice, the entire chain after it would break. The first invoice in the system uses an initial hash value defined in the specification, then each invoice after it builds on the one before. This imposes a strict order: do not issue invoice number two before you have stored invoice number one’s hash. In concurrent systems that issue invoices from multiple threads, ensure issuance is serialized per device so that threads do not race on the same counter and corrupt the chain.

Base64-encoding the UBL document and building the request body

The invoice itself is an XML document compliant with UBL 2.1, but it is not sent as raw XML. It is Base64-encoded and placed inside a JSON field named invoice. Any request that does not adhere to this envelope is rejected at the gateway directly.

This function takes the signed document, the hash value and the unique identifier, and builds the full request body in JSON format:

<?php
/**
 * Builds the request body in the JSON format required by the platform
 * The document is Base64-encoded inside the invoice field
 */
function buildRequestBody(string $signedXml, string $invoiceHash, string $uuid): string
{
    $payload = [
        'invoiceHash' => $invoiceHash,
        'uuid'        => $uuid,
        'invoice'     => base64_encode($signedXml),
    ];

    return json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}

We use the two flags JSON_UNESCAPED_SLASHES andJSON_UNESCAPED_UNICODE because the default behavior in PHP escapes forward slashes and converts Arabic characters into lengthy unicode encoding. This escaping does not technically break the JSON, but keeping the output clean makes it easier to debug when comparing what you sent with what the platform expects.

The unique identifier (UUID) is a value unique to each invoice. You generate it once when creating the invoice and store it. Do not regenerate it on resubmission, otherwise the platform treats it as a new invoice.

The structure of the JSON request body the platform receives

Visualizing the shape of the final request helps you connect the previous functions to one another. The three mandatory fields and what each carries:

Request body fields
The three fields in the clearance or reporting request body.
JSON request body

invoiceHash: the invoice’s SHA-256 hash (Base64)

uuid: the invoice’s unique identifier

invoice: the invoice in Base64 encoding

The three fields are derived from the invoice file itself.

Note the separation between two layers: the headers carry the request’s identity and settings, and the JSON body carries the invoice and its metadata. The header Content-Type: application/json is mandatory because the body is JSON even though the invoice inside it is encoded XML.

Sending the request to the clearance or reporting path via cURL

Now we combine everything above into a single POST request. The path differs by invoice type: the standard tax invoice (B2B) is sent to the clearance path (Clearance) and is cleared before it reaches the buyer. The simplified tax invoice (B2C) is sent to the reporting path (Reporting) within twenty-four hours of its issuance.

This is a native PHP function using the built-in cURL. It builds the request, sets the headers, sends it, and returns the status code and the body together:

<?php
/**
 * Sends the invoice to the platform via cURL
 * Returns an array containing the status code and the response body
 */
function submitInvoice(array $config, string $path, string $body, int $clearanceStatus = 1): array
{
    $url = rtrim($config['base_url'], '/') . $path;

    $headers = [
        'Authorization: ' . buildAuthHeader($config['csid'], $config['secret']),
        'Content-Type: application/json',
        'Accept: application/json',
        'Accept-Version: ' . $config['version'],
        'Accept-Language: ar',
        'Clearance-Status: ' . $clearanceStatus,
    ];

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $body,
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 60,
        CURLOPT_CONNECTTIMEOUT => 15,
    ]);

    $responseBody = curl_exec($ch);
    $httpCode     = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $curlError    = curl_error($ch);
    curl_close($ch);

    if ($responseBody === false) {
        throw new RuntimeException("Failed to connect to the platform: {$curlError}");
    }

    return [
        'status' => $httpCode,
        'body'   => json_decode($responseBody, true),
    ];
}

We set two timeouts: a connection timeout (15 seconds) and a full-request timeout (60 seconds). This protects your system from hanging if the gateway is slow. In high-frequency points of sale you might lower the request timeout and rely on retry, as we will see shortly.

The path is passed as an argument, so you call the same function for both types. For the standard invoice:

<?php
// Standard tax invoice (B2B) to the clearance path
$result = submitInvoice(
    $config,
    '/invoices/clearance/single',
    $body,
    1   // Clearance-Status: 1 requests actual clearance
);

// Simplified tax invoice (B2C) to the reporting path
$result = submitInvoice(
    $config,
    '/invoices/reporting/single',
    $body
);

The full catalog of paths and their headers is in the guide API endpoints. Here we suffice with the two most-used paths in daily operation.

Try Qoyod free for 14 days. No credit card.

The parallel version with the Guzzle library

If your project relies on Composer and uses Guzzle, the code is shorter and clearer. You install the library with a single command:

composer require guzzlehttp/guzzle

Then you build the client and send the request. Guzzle handles encoding the body and setting the headers with a cleaner interface than raw cURL:

<?php
require 'vendor/autoload.php';

use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;

/**
 * Sends the invoice via Guzzle
 * Returns an array containing the status code and the response body
 */
function submitInvoiceGuzzle(array $config, string $path, array $payload, int $clearanceStatus = 1): array
{
    $client = new Client([
        'base_uri' => rtrim($config['base_url'], '/') . '/',
        'timeout'  => 60,
    ]);

    try {
        $response = $client->post(ltrim($path, '/'), [
            'headers' => [
                'Authorization'   => buildAuthHeader($config['csid'], $config['secret']),
                'Accept'          => 'application/json',
                'Accept-Version'  => $config['version'],
                'Accept-Language' => 'ar',
                'Clearance-Status'=> (string) $clearanceStatus,
            ],
            'json' => $payload,   // Guzzle sets Content-Type and encodes automatically
        ]);

        return [
            'status' => $response->getStatusCode(),
            'body'   => json_decode((string) $response->getBody(), true),
        ];
    } catch (RequestException $e) {
        // The response exists even on an error code such as 400 or 401
        $resp = $e->getResponse();
        return [
            'status' => $resp ? $resp->getStatusCode() : 0,
            'body'   => $resp ? json_decode((string) $resp->getBody(), true) : ['message' => $e->getMessage()],
        ];
    }
}

The fundamental difference is in error handling. Guzzle throws an exception on status codes 400 and above by default. So we catch RequestException and extract the response from it, because the error body itself contains the validation messages we need. Had we not caught the exception, the Authority’s detailed error message would have been lost.

With Guzzle we pass the payload as an array via the key json, so the library handles encoding and setting Content-Type. This eliminates the need for the manual body-building function, but make sure the value of the field invoice stays Base64-encoded before passing it.

Reading the JSON response and distinguishing success from rejection

After sending, a JSON response comes back. On successful clearance it comes in this form:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "clearanceStatus": "CLEARED",
  "clearedInvoice": "<Base64-signed-XML>",
  "validationResults": {
    "status": "PASS",
    "warningMessages": [],
    "errorMessages": []
  }
}

The field clearanceStatus carries the clearance outcome, andclearedInvoice carries the cleared copy that you store and hand to the buyer. In reporting, the corresponding field is reportingStatus. This function reads the response and turns it into a clear result for your system:

<?php
/**
 * Parses the platform's response and returns a simplified status for your system
 */
function parseResponse(array $result): array
{
    $status = $result['status'];
    $body   = $result['body'] ?? [];

    // Successful clearance or reporting
    if ($status === 200) {
        $outcome = $body['clearanceStatus']
                ?? $body['reportingStatus']
                ?? 'UNKNOWN';

        return [
            'ok'            => in_array($outcome, ['CLEARED', 'REPORTED'], true),
            'outcome'       => $outcome,
            'clearedInvoice'=> $body['clearedInvoice'] ?? null,
            'warnings'      => $body['validationResults']['warningMessages'] ?? [],
        ];
    }

    // Rejection or error: extract the validation messages
    return [
        'ok'       => false,
        'outcome'  => 'REJECTED',
        'httpCode' => $status,
        'errors'   => $body['validationResults']['errorMessages']
                   ?? [['message' => $body['message'] ?? 'Unknown error']],
    ];
}

Pay attention to the warnings (warningMessages) even on success. The invoice may be cleared with warnings that do not prevent acceptance but point to an issue worth addressing before it turns into an error in a later update to the specification. Log the warnings and do not ignore them.

Error handling and retry

Not all errors are equal. Some are permanent and no retry fixes them, and some are temporary and deserve a second attempt. Distinguishing between them determines your system’s behavior.

Code 401 means authentication failed. Do not retry, as the problem is in the credentials, not the network. The shape of its response:

HTTP/1.1 401 Unauthorized
Content-Type: application/json

{
  "message": "Authentication failed",
  "code": "invalid-credentials"
}

Check on 401 that the certificate ID and the secret are correct, and that you are in the environment matching them (the compliance ID for the test environment, and the production ID for the production environment). Mixing the two environments is a common cause of this code. Authentication errors are detailed in the guide Authentication.

As for code 400, it means the invoice was rejected due to an error in its content, signature or hash. Do not retry with the same document; instead read errorMessages to know the rejected field. Codes 500, 502 and 503 and a connection timeout mean a temporary problem on the gateway that deserves a retry. This function applies retry with escalating delay for temporary errors only:

<?php
/**
 * Resends the request only on temporary errors
 * with an escalating delay between attempts
 */
function submitWithRetry(array $config, string $path, string $body, int $maxAttempts = 3): array
{
    $transient = [500, 502, 503, 504];

    for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
        try {
            $result = submitInvoice($config, $path, $body);
        } catch (RuntimeException $e) {
            // Connection failure: treat it as a temporary error
            $result = ['status' => 0, 'body' => null];
        }

        // Success or permanent error: return immediately with no further attempt
        if (!in_array($result['status'], $transient, true) && $result['status'] !== 0) {
            return $result;
        }

        // Temporary error: wait then retry, except on the last attempt
        if ($attempt < $maxAttempts) {
            sleep(2 ** $attempt);   // escalating delay: 2 then 4 then 8 seconds
        }
    }

    return $result;
}

The golden rule: retry on temporary errors only (network and gateway), and never retry on 400 and 401. Resending an invoice rejected for a content error repeats the rejection and burdens the gateway to no avail. The escalating delay (two seconds then four then eight) gives the gateway time to recover and avoids flooding it with successive attempts.

Make sure the submission operation is safe to re-execute (idempotent) at your system level. Store each invoice’s state before sending and update it after the reply, so you do not send the same invoice twice if power is cut mid-operation. The fixed unique identifier helps the platform recognize a resubmission.

The response-handling decision tree in PHP code

After each submission your system faces a decision: do I consider the invoice successful, or retry, or stop and report a permanent error? The following diagram summarizes the path by status code, which is exactly what the previous functions translate into code.

Response-handling decision tree
How the code behaves according to the status code.
Status code The action
2xx Save the approved result
400/401 Permanent error: fix, do not retry
5xx / timeout Retry with exponential backoff
Request errors are not retried, and server errors are retried with backoff.

The green path is success. The red path is a permanent error that repetition does not fix. The orange path is a temporary error that deserves a measured retry. Translating this diagram into code is exactly what the two functions parseResponse andsubmitWithRetry above did.

Preparing the code for production: logging and monitoring

The previous examples build the functional path. Before running it on real invoices, add a logging layer that gives you a trail to return to on any problem. For each invoice, log its unique identifier, its counter, its hash value, the response status code and its time. Do not log the credentials nor the private key nor the full encoded body in logs that many people can reach.

<?php
/**
 * Logs each submission's result safely without sensitive data
 */
function logSubmission(array $invoiceData, array $parsed): void
{
    $entry = [
        'timestamp' => date('c'),
        'uuid'      => $invoiceData['uuid'],
        'invoiceNo' => $invoiceData['invoiceNumber'],
        'outcome'   => $parsed['outcome'],
        'httpCode'  => $parsed['httpCode'] ?? 200,
        // We do not log CSID nor secret nor the encoded document
    ];
    error_log(json_encode($entry, JSON_UNESCAPED_UNICODE));
}

Monitor the rejection rate as a health indicator. A sudden rise in code 400 usually means your UBL-building library has drifted from the specification after an update, or that a new field has become mandatory. A rise in code 401 means a problem with the certificate or its approaching expiry. Organized logging turns these indicators from a surprise into an early warning.

Finally, separate the test-environment settings from production completely in your system. Do not make switching between them depend on a single flag that is easy to forget; instead make each environment carry its full set of variables. Sending a test invoice to production, or the reverse, is a costly error that is hard to undo after clearance.

How Qoyod helps you with the technical integration with the Fatoora platform

Everything above shows the size of the technical work behind a single compliant invoice: building UBL, signing, hashing, encoding, sending, error handling, and managing certificates for two environments. Building this layer and maintaining it with every update to the specification is an ongoing burden on your team.

Qoyod handles this entire layer. It issues your invoices signed and compliant with phase two of e-invoicing, manages the cryptographic stamp certificate (CSID) automatically, preserves the hash chain, and handles instant clearance for standard invoices and reporting within twenty-four hours for simplified invoices. You focus on your system, and Qoyod takes care of compliance.

Qoyod does not connect your establishment to the Authority on your behalf in the first registration step, as registering the certificate ID with the Authority is your responsibility, but it guides you through it. And it does not file the VAT return for you; rather it generates the return data for you to file yourself via the Authority’s portal. What Qoyod does is remove from your shoulders all the technical complexity you read about in this guide.

Start today

Let Qoyod handle the technical integration with the Fatoora platform

Instead of building the signing, authentication and hashing layer yourself, Qoyod issues your invoices signed and compliant with the Authority’s requirements from day one, so you are free to develop your system rather than manage certificates.

Start your free trial and issue your compliant invoices

Frequently asked questions about PHP integration examples

Do I need an external library to build the authentication header in PHP?

No. The built-in PHP function base64_encode is enough to build the Authorization header. It joins the certificate ID and the secret with a colon between them, encodes the result, and adds the word Basic before it. There is no need for any Composer package for this specific step.

What is the difference between using built-in cURL and the Guzzle library?

Both send the same request and reach the same result. Built-in cURL needs no installation and gives you fine control over every option. Guzzle is clearer and shorter and handles encoding and setting the headers, but it requires Composer and needs catching the exception to read the error body. Choose what fits your project’s architecture.

Why is my invoice rejected even though the hash value looks correct?

The most common reason is passing the third parameter in the function hash incorrectly. It must be true to return the raw bytes before Base64 encoding. If you hash the hex string instead of the raw bytes, the value differs from what the Authority expects and the request is rejected.

When do I use the clearance path and when the reporting path?

The standard tax invoice (B2B) is sent to the clearance path (Clearance) and is cleared before delivery to the buyer. The simplified tax invoice (B2C) is sent to the reporting path (Reporting) within twenty-four hours of its issuance. The invoice type determines the path.

How do I handle status code 401 in PHP code?

Code 401 means authentication failed, and retry does not fix it. Verify that the certificate ID and the secret are correct and that you are using the credentials of the matching environment: the compliance ID for the test environment and the production ID for the production environment. Mixing them is a common cause of this code.

Do I retry submission on every error?

No. Retry only on temporary errors such as 500, 502 and 503 and a connection timeout, with an escalating delay between attempts. Do not retry on 400 (content rejection) or 401 (authentication failure), as these are permanent errors that repeated attempts do not solve.

Guides

Continue your learning journey

Explore the rest of Qoyod’s guides, or start applying what you’ve learned.

Live webinars hosted by the Qoyod team to help you use the software easily and answer your questions.

Discover Qoyod’s latest updates, ongoing improvements, and new features in one place.

Our team is ready to help you and provide instant support for any issue you face, around the clock.