Skip to content

Report your first sandbox invoice

Pick Python, C#, or TypeScript and run one program that creates a simplified invoice for SAR 115, signs it, validates the signed XML, and reports it to ZATCA's Integration Sandbox. It saves the response and checks whether reporting was accepted. Each language follows the same steps and uses the same input file.

You need a sandbox certificate and its matching private key. Reporting also needs the sandbox token and secret issued for that device. Without credentials, you can run the local steps with the public test certificate below; that run sends nothing.

Where this fits in the ZATCA journey

An EGS is your electronic invoice generation system. Onboarding identifies each EGS unit and gives it credentials. Once onboarded, it can submit invoices and notes.

flowchart TD
    A[Select environment and EGS] --> B[Private key, CSR and OTP]
    B --> C[Compliance credentials]
    C --> D[Check invoice and note samples]
    D --> E{Checks pass?}
    E -->|No| F[Correct and restart onboarding]
    F --> B
    E -->|Yes| G[Production-type credentials]
    G --> H[Create invoice and record history]
    H --> I{Subtype}
    I -->|Simplified| J[Sign and validate]
    J --> K[Issue to customer and report]
    I -->|Standard| L[Prepare XML and validate]
    L --> M[Request clearance before issue]
    K --> N{API outcome}
    M --> N
    N -->|Accepted| O[Store XML and response]
    O --> P[Standard only: deliver cleared XML]
    N -->|Rejected| Q[Follow correction procedure]
    N -->|Unknown| R[Reconcile before retry]

Keep credentials under lifecycle management too: renew them before expiry and revoke them when the EGS unit is retired or its credentials must be withdrawn. A connection failure can leave the API outcome unknown.

The runnable examples cover the simplified branch: creation through reporting. In live operation, simplified documents are reported within 24 hours. Standard documents follow clearance before customer delivery; retain the returned cleared XML. These are separate submission paths. ZATCA describes both in sections 4.3.1 and 4.3.2 of its Detailed Technical Guidelines.

Local validation catches problems before an API call. ZATCA still makes the submission decision, and a sandbox result is only a test result.

1. Get the sandbox inputs

If you already have sandbox production-type credentials, continue to the files below. Otherwise, use the ZATCA Developer Portal to complete this sequence for your test EGS unit:

  1. Generate a private key and CSR using the non-production environment and your test seller identity. Keep the private key. The CSR guide has runnable commands; declare the document types your unit supports.
  2. Send the CSR with the sandbox's OTP through Compliance CSID (POST /compliance). Retain the returned compliance token, secret, certificate, and request ID.
  3. Sign and submit the required samples to Compliance Invoice (POST /compliance/invoices) using the compliance credentials. The checks cover invoice and credit/debit note types declared in the CSR. One successful invoice check does not complete all onboarding checks.
  4. Use the compliance credentials and returned request ID to request Production CSID (POST /production/csids). Save that response's token and secret, and the issued certificate corresponding to your private key.

The library exposes these operations as post_csr_for_ccsid, check_invoice_compliance, and post_ccsid_for_pcsid in Python, with corresponding methods in the other bindings. ZATCA documents the onboarding sequence in section 3.3 of the Detailed Technical Guidelines.

“Production CSID” is the credential's role. A production-type CSID issued by the Integration Sandbox belongs to the sandbox. It cannot authenticate against the live production environment. See ZATCA's Developer Portal Manual.

Prepare these inputs:

Input What to use
certificate.pem PEM certificate issued for the sandbox device.
private-key.pem The private key used to create that device's CSR.
ZATCA_TOKEN binarySecurityToken from the sandbox production-CSID response, unchanged.
ZATCA_SECRET secret from that same response.
input.json Seller details matching your test device, plus invoice identity and trusted predecessor context.

The token is an API credential; the signing examples expect a PEM certificate file. Export the issued certificate in PEM format. A newly generated key will not match a certificate issued for an earlier CSR.

2. Download one complete example

Download one project and extract it into an empty working folder. Each archive contains the complete source, project configuration, and input.json. Open a terminal in that folder.

Edit the seller in input.json to match your sandbox onboarding identity. The sample contains demonstration data. invoice_counter is a decimal string to avoid JavaScript integer precision loss; use the next counter for your EGS unit. The predecessor hash must come from your trusted invoice history. The sample value is for the demonstration's initial invoice, not a value to reuse for every invoice. Each run generates a new UUID and the current UTC issue time.

Download the Python project

Use CPython 3.13 for this walkthrough. Version 0.5.0 publishes wheels for CPython 3.9–3.13; Python 3.14 needs a source build and is outside this setup. Individual files: main.py and requirements.txt.

python3.13 -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt

On Windows PowerShell, create the environment with py -3.13 -m venv .venv and activate it with .venv\Scripts\Activate.ps1.

Complete Python program
"""One simplified invoice: create, sign, validate, report, inspect the result."""
import argparse
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import sys
from uuid import uuid4

from fatoora import InvoiceBuilder, InvoiceSubType, InvoiceTypeKind, VatCategory
from fatoora.api import CsidProduction, ZatcaClient
from fatoora.config import Config, Environment
from fatoora.sign import Signer
from fatoora.errors import FfiError
from fatoora.validation import validate_zatca_invoice_from_str

ENVIRONMENT = Environment.NON_PRODUCTION  # Integration Sandbox only.


def required(name):
    value = os.environ.get(name, "").strip()
    if not value:
        raise ValueError(f"Set {name} first")
    return value


def save_json(path, value):
    path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")


def messages(items):
    result = []
    for item in items:
        with item:
            result.append({"code": item.code(), "message": item.message()})
    return result


def handle_response(response, output):
    with response.validation_results() as results:
        summary = {
            "http_status": response.http_status(),
            "outcome": response.outcome().name,
            "reporting_status": response.reporting_status(),
            "validation_status": results.status(),
            "info": messages(results.info_messages()),
            "warnings": messages(results.warning_messages()),
            "errors": messages(results.error_messages()),
        }
    save_json(output / "response.json", summary)
    print(json.dumps(summary, indent=2, ensure_ascii=False))
    response.ensure_accepted()  # A successful HTTP status alone is insufficient.
    print("Sandbox reporting accepted. Keep any warnings in response.json.")


def run(input_path, submit):
    # Read credentials before creating an invoice; never write them into artifacts.
    certificate = Path(required("ZATCA_CERT_PATH")).read_text(encoding="utf-8")
    key = Path(required("ZATCA_KEY_PATH")).read_text(encoding="utf-8")
    token, secret = (required("ZATCA_TOKEN"), required("ZATCA_SECRET")) if submit else (None, None)
    data = json.loads(Path(input_path).read_text(encoding="utf-8"))
    seller = data["seller"]
    invoice_uuid = str(uuid4())
    output = Path("runs") / invoice_uuid
    output.mkdir(parents=True, exist_ok=False)
    print(f"Artifacts: {output}")
    state = {"uuid": invoice_uuid, "invoice_id": data["invoice_id"], "stage": "create", "submitted": False}
    save_json(output / "state.json", state)
    try:
        with InvoiceBuilder.new(InvoiceTypeKind.TAX, InvoiceSubType.SIMPLIFIED) as builder:
            builder.set_id(data["invoice_id"])
            builder.set_uuid(invoice_uuid)
            builder.set_issue_datetime(datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
            builder.set_currency("SAR")
            builder.set_invoice_counter(int(data["invoice_counter"]))
            builder.set_previous_invoice_hash(data["previous_invoice_hash"])
            builder.set_payment_means_code("10")
            builder.set_vat_category(VatCategory.STANDARD)
            builder.set_seller(
                name=seller["name"], country_code="SA", city=seller["city"],
                street=seller["street"], building_number=seller["building_number"],
                postal_code=seller["postal_code"], district=seller["district"],
                additional_number=seller["additional_number"], vat_id=seller["vat_id"],
                other_id=seller["crn"], other_id_scheme="CRN",
            )
            builder.add_line_item("دفتر", "2", "PCE", "50", "15", VatCategory.STANDARD)
            with builder.build() as invoice, Signer.from_pem(certificate, key) as signer:
                (output / "invoice.xml").write_text(invoice.to_xml(), encoding="utf-8")
                print("Created: SAR 100 net + SAR 15 VAT = SAR 115 payable")
                state["stage"] = "sign"
                with invoice.sign(signer) as signed, Config(ENVIRONMENT) as config:
                    signed_xml = signed.xml()
                    (output / "signed.xml").write_text(signed_xml, encoding="utf-8")
                    state["invoice_hash"] = signed.invoice_hash()
                    state["stage"] = "validate"
                    report = validate_zatca_invoice_from_str(
                        config, signed_xml, previous_invoice_hash=data["previous_invoice_hash"],
                    )
                    save_json(output / "validation.json", report)
                    if not report["is_valid"]:
                        raise ValueError("Local validation failed; inspect validation.json. Nothing was submitted.")
                    print("Local validation passed; inspect validation.json for warnings")
                    if not submit:
                        state["stage"] = "local_only"
                        print("Local run complete. No reporting request sent. Use --submit with sandbox credentials.")
                        return
                    state["stage"] = "report"
                    state["submitted"] = True  # Attempted; outcome may be unknown if the connection fails.
                    save_json(output / "state.json", state)
                    with CsidProduction.new(ENVIRONMENT, token, secret, None) as credentials:
                        with ZatcaClient(config) as client:
                            with client.report_simplified_invoice(signed, credentials, False, "en") as response:
                                handle_response(response, output)
                    state["stage"] = "accepted"
    except Exception as error:
        state["error_type"] = type(error).__name__
        if isinstance(error, FfiError):
            save_json(output / "error.json", {"code": error.code, "details": error.details})
        raise
    finally:
        save_json(output / "state.json", state)


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--input", default="input.json")
    parser.add_argument("--submit", action="store_true", help="Report to the Integration Sandbox")
    args = parser.parse_args()
    try:
        run(args.input, args.submit)
    except Exception as error:
        print(f"Stopped: {error}", file=sys.stderr)
        print("No automatic retry. Inspect saved artifacts before another run.", file=sys.stderr)
        sys.exit(1)

Download the C# project

Requires the .NET 8 SDK and a supported native platform. Individual files: Program.cs and Journey.csproj.

dotnet restore

The project enables nullable references and imports the Fatoora namespace.

Complete C# program
using System.Text.Json;
using System.Globalization;
using Fatoora;

public static class Program
{
    private const byte Sandbox = 0; // Integration Sandbox only.
    private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
    private static string Required(string name) =>
        !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(name))
            ? Environment.GetEnvironmentVariable(name)!.Trim()
            : throw new InvalidOperationException($"Set {name} first");
    private static void Save(string path, object value) =>
        File.WriteAllText(path, JsonSerializer.Serialize(value, JsonOptions) + "\n");
    private static string? ReadText(Text? value)
    {
        using (value) return value?.Value();
    }
    private static object[] Messages(nuint count, Func<nuint, ValidationMessage> get)
    {
        var result = new List<object>();
        for (nuint i = 0; i < count; i++)
        {
            using var message = get(i);
            result.Add(new { code = ReadText(message.Code()), message = ReadText(message.Message()) });
        }
        return result.ToArray();
    }
    public static void HandleResponse(ValidationResponse response, string output)
    {
        using var results = response.ValidationResults();
        var summary = new {
            http_status = response.HttpStatus(), outcome = response.Outcome().ToString(),
            reporting_status = ReadText(response.ReportingStatus()),
            validation_status = ReadText(results.Status()),
            info = Messages(results.InfoLen(), results.InfoMessage),
            warnings = Messages(results.WarningLen(), results.WarningMessage),
            errors = Messages(results.ErrorLen(), results.ErrorMessage),
        };
        Save(Path.Combine(output, "response.json"), summary);
        Console.WriteLine(JsonSerializer.Serialize(summary, JsonOptions));
        response.EnsureAccepted(); // A successful HTTP status alone is insufficient.
        Console.WriteLine("Sandbox reporting accepted. Keep any warnings in response.json.");
    }
    public static void Run(string inputPath, bool submit)
    {
        string certificate = File.ReadAllText(Required("ZATCA_CERT_PATH"));
        string key = File.ReadAllText(Required("ZATCA_KEY_PATH"));
        string token = submit ? Required("ZATCA_TOKEN") : "";
        string secret = submit ? Required("ZATCA_SECRET") : "";
        using var input = JsonDocument.Parse(File.ReadAllText(inputPath));
        var data = input.RootElement;
        var seller = data.GetProperty("seller");
        string Field(JsonElement parent, string name) => parent.GetProperty(name).GetString()
            ?? throw new InvalidOperationException($"Missing input field: {name}");
        string uuid = Guid.NewGuid().ToString();
        string output = Path.Combine("runs", uuid);
        if (Directory.Exists(output)) throw new IOException("Output directory already exists");
        Directory.CreateDirectory(output);
        Console.WriteLine($"Artifacts: {output}");
        var state = new Dictionary<string, object> {
            ["uuid"] = uuid, ["invoice_id"] = Field(data, "invoice_id"),
            ["stage"] = "create", ["submitted"] = false,
        };
        Save(Path.Combine(output, "state.json"), state);
        try
        {
            using var builder = InvoiceBuilder.New(0, 1, null, null, null, null);
            builder.SetId(Field(data, "invoice_id"));
            builder.SetUuid(uuid);
            builder.SetIssueDatetime(DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture));
            builder.SetCurrency("SAR");
            builder.SetInvoiceCounter(ulong.Parse(Field(data, "invoice_counter")));
            builder.SetPreviousInvoiceHash(Field(data, "previous_invoice_hash"));
            builder.SetPaymentMeansCode("10");
            builder.SetVatCategory(1);
            using var address = Address.New("SA", Field(seller, "city"), Field(seller, "street"),
                Field(seller, "building_number"), Field(seller, "postal_code"), null,
                Field(seller, "additional_number"), Field(seller, "district"));
            builder.SetSeller(Field(seller, "name"), address, Field(seller, "vat_id"), Field(seller, "crn"), "CRN");
            builder.AddLineItem("دفتر", "2", "PCE", "50", "15", 1);
            using var invoice = builder.Build();
            File.WriteAllText(Path.Combine(output, "invoice.xml"), invoice.Xml());
            Console.WriteLine("Created: SAR 100 net + SAR 15 VAT = SAR 115 payable");
            state["stage"] = "sign";
            using var signer = Signer.FromPem(certificate, key);
            using var signed = signer.Sign(invoice);
            string signedXml = signed.Xml();
            File.WriteAllText(Path.Combine(output, "signed.xml"), signedXml);
            state["invoice_hash"] = signed.InvoiceHash();
            state["stage"] = "validate";
            using var config = Config.New(Sandbox);
            string reportJson = Xml.ValidateZatca(config, signedXml, JsonSerializer.Serialize(new {
                previous_invoice_hash = Field(data, "previous_invoice_hash"),
            }));
            File.WriteAllText(Path.Combine(output, "validation.json"), reportJson);
            using var report = JsonDocument.Parse(reportJson);
            if (!report.RootElement.GetProperty("is_valid").GetBoolean())
                throw new InvalidOperationException("Local validation failed; inspect validation.json. Nothing was submitted.");
            Console.WriteLine("Local validation passed; inspect validation.json for warnings");
            if (!submit)
            {
                state["stage"] = "local_only";
                Console.WriteLine("Local run complete. No reporting request sent. Use --submit with sandbox credentials.");
                return;
            }
            state["stage"] = "report";
            state["submitted"] = true; // Attempted; a failed connection can leave the outcome unknown.
            Save(Path.Combine(output, "state.json"), state);
            using var credentials = CsidProduction.Create(Sandbox, null, token, secret);
            using var client = ZatcaClient.Create(config);
            using var response = client.ReportSimplifiedInvoice(signed, credentials, false, "en");
            HandleResponse(response, output);
            state["stage"] = "accepted";
        }
        catch (Exception error)
        {
            state["error_type"] = error.GetType().Name;
            if (error is BindingErrorException binding)
            {
                using var details = JsonDocument.Parse(binding.Inner.DetailsJson());
                Save(Path.Combine(output, "error.json"), new { code = binding.Inner.Code(), details = details.RootElement });
            }
            throw;
        }
        finally { Save(Path.Combine(output, "state.json"), state); }
    }
    public static int Main(string[] args)
    {
        try
        {
            string input = "input.json";
            bool submit = false;
            for (int i = 0; i < args.Length; i++)
            {
                if (args[i] == "--submit") submit = true;
                else if (args[i] == "--input" && i + 1 < args.Length) input = args[++i];
                else throw new ArgumentException("Usage: dotnet run -- [--input input.json] [--submit]");
            }
            Run(input, submit);
            return 0;
        }
        catch (Exception error)
        {
            Console.Error.WriteLine($"Stopped: {error.Message}");
            Console.Error.WriteLine("No automatic retry. Inspect saved artifacts before another run.");
            return 1;
        }
    }
}

Download the TypeScript project

Requires Node.js 22+ and npm. This is a server-side Node program. Individual files: main.ts, package.json, and tsconfig.json.

npm install
npm run build

Keep the installed package's WASM file with its generated modules. Node loads it through the package; no browser asset setup is needed for this program.

Complete TypeScript program
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
import {
  Address, BindingError, Config, CsidProduction, InvoiceBuilder, Signer, Xml, ZatcaClient,
  type ValidationResponse, type ValidationMessage,
} from 'fatoority';

const ENVIRONMENT = 0; // Integration Sandbox only.
interface Input {
  invoice_id: string;
  invoice_counter: string;
  previous_invoice_hash: string;
  seller: { name: string; vat_id: string; crn: string; city: string; street: string;
    building_number: string; postal_code: string; additional_number: string; district: string };
}
function required(name: string): string {
  const value = process.env[name]?.trim();
  if (!value) throw new Error(`Set ${name} first`);
  return value;
}
function saveJson(path: string, value: unknown): void {
  writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
}
function messages(count: number, get: (index: number) => ValidationMessage) {
  return Array.from({ length: count }, (_, index) => {
    const item = get(index);
    return { code: item.code()?.value() ?? null, message: item.message()?.value() ?? null };
  });
}
export function handleResponse(response: ValidationResponse, output: string): void {
  const results = response.validationResults();
  const summary = {
    http_status: response.httpStatus(), outcome: response.outcome().value,
    reporting_status: response.reportingStatus()?.value() ?? null,
    validation_status: results.status()?.value() ?? null,
    info: messages(results.infoLen(), i => results.infoMessage(i)),
    warnings: messages(results.warningLen(), i => results.warningMessage(i)),
    errors: messages(results.errorLen(), i => results.errorMessage(i)),
  };
  saveJson(`${output}/response.json`, summary);
  console.log(JSON.stringify(summary, null, 2));
  response.ensureAccepted(); // A successful HTTP status alone is insufficient.
  console.log('Sandbox reporting accepted. Keep any warnings in response.json.');
}

export async function run(inputPath: string, submit: boolean, transport?: typeof fetch): Promise<void> {
  const certificate = readFileSync(required('ZATCA_CERT_PATH'), 'utf8');
  const key = readFileSync(required('ZATCA_KEY_PATH'), 'utf8');
  const token = submit ? required('ZATCA_TOKEN') : '';
  const secret = submit ? required('ZATCA_SECRET') : '';
  const data: Input = JSON.parse(readFileSync(inputPath, 'utf8'));
  const seller = data.seller;
  const uuid = randomUUID();
  const output = `runs/${uuid}`;
  mkdirSync('runs', { recursive: true });
  mkdirSync(output); // A new directory preserves earlier attempts.
  console.log(`Artifacts: ${output}`);
  const state: Record<string, unknown> = {
    uuid, invoice_id: data.invoice_id, stage: 'create', submitted: false,
  };
  saveJson(`${output}/state.json`, state);
  try {
    const builder = InvoiceBuilder.new_(0, 1, null, null, null, null); // Tax, simplified.
    builder.setId(data.invoice_id);
    builder.setUuid(uuid);
    builder.setIssueDatetime(new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'));
    builder.setCurrency('SAR');
    builder.setInvoiceCounter(BigInt(data.invoice_counter));
    builder.setPreviousInvoiceHash(data.previous_invoice_hash);
    builder.setPaymentMeansCode('10');
    builder.setVatCategory(1); // Standard VAT category.
    const address = Address.new_('SA', seller.city, seller.street, seller.building_number,
      seller.postal_code, null, seller.additional_number, seller.district);
    builder.setSeller(seller.name, address, seller.vat_id, seller.crn, 'CRN');
    builder.addLineItem('دفتر', '2', 'PCE', '50', '15', 1);
    const invoice = builder.build();
    writeFileSync(`${output}/invoice.xml`, invoice.xml());
    console.log('Created: SAR 100 net + SAR 15 VAT = SAR 115 payable');
    state.stage = 'sign';
    const signed = Signer.fromPem(certificate, key).sign(invoice);
    const signedXml = signed.xml();
    writeFileSync(`${output}/signed.xml`, signedXml);
    state.invoice_hash = signed.invoiceHash();
    state.stage = 'validate';
    const config = Config.new_(ENVIRONMENT);
    const report = JSON.parse(Xml.validateZatca(config, signedXml, JSON.stringify({
      previous_invoice_hash: data.previous_invoice_hash,
    })));
    saveJson(`${output}/validation.json`, report);
    if (!report.is_valid) throw new Error('Local validation failed; inspect validation.json. Nothing was submitted.');
    console.log('Local validation passed; inspect validation.json for warnings');
    if (!submit) {
      state.stage = 'local_only';
      console.log('Local run complete. No reporting request sent. Use --submit with sandbox credentials.');
      return;
    }
    state.stage = 'report';
    state.submitted = true; // Attempted; a failed connection can leave the outcome unknown.
    saveJson(`${output}/state.json`, state);
    const client = new ZatcaClient(config, transport ? { fetch: transport } : {});
    const credentials = CsidProduction.create(ENVIRONMENT, null, token, secret);
    const response = await client.reportSimplifiedInvoice(signed, credentials, false, 'en');
    handleResponse(response, output);
    state.stage = 'accepted';
  } catch (error) {
    state.error_type = error instanceof Error ? error.name : 'UnknownError';
    if (error instanceof Error && error.cause instanceof BindingError) {
      saveJson(`${output}/error.json`, { code: error.cause.code(), details: JSON.parse(error.cause.detailsJson()) });
    }
    throw error;
  } finally {
    saveJson(`${output}/state.json`, state);
  }
}

if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
  const args = process.argv.slice(2);
  let input = 'input.json';
  let submit = false;
  let valid = true;
  for (let i = 0; i < args.length; i++) {
    if (args[i] === '--submit') submit = true;
    else if (args[i] === '--input' && args[i + 1]) input = args[++i];
    else valid = false;
  }
  if (!valid) {
    console.error('Usage: npm start -- [--input input.json] [--submit]');
    process.exitCode = 1;
  } else {
    run(input, submit).catch(error => {
      console.error(`Stopped: ${error instanceof Error ? error.message : String(error)}`);
      console.error('No automatic retry. Inspect saved artifacts before another run.');
      process.exitCode = 1;
    });
  }
}

3. Run creation, signing and validation locally

Set the certificate and private-key paths. Use absolute paths if you keep them outside the working folder.

export ZATCA_CERT_PATH="$PWD/certificate.pem"
export ZATCA_KEY_PATH="$PWD/private-key.pem"
$env:ZATCA_CERT_PATH = Join-Path $PWD "certificate.pem"
$env:ZATCA_KEY_PATH = Join-Path $PWD "private-key.pem"

For a run without sandbox credentials, download the public test certificate and matching test key, keeping those filenames. Use the unmodified demonstration input.json with them. These files are for local testing; replace them with your issued sandbox pair before reporting.

python main.py
dotnet run
npm start

Expected output, with a different UUID on each run:

Artifacts: runs/<uuid>
Created: SAR 100 net + SAR 15 VAT = SAR 115 payable
Local validation passed; inspect validation.json for warnings
Local run complete. No reporting request sent. Use --submit with sandbox credentials.

Open runs/<uuid>/signed.xml and validation.json. The validation report checks the exact signed XML, including the applicable signature and QR checks, using the predecessor supplied independently in input.json. If it fails, the program exits with a nonzero status before constructing a reporting request.

4. Report with your sandbox credentials

Use your issued sandbox certificate/key pair and matching seller data. Load ZATCA_TOKEN and ZATCA_SECRET into the process environment through your shell or secret manager. They must come from the production-type sandbox response, not the compliance-CSID response. Do not put credentials in input.json.

export ZATCA_TOKEN='<binarySecurityToken from sandbox production CSID>'
export ZATCA_SECRET='<secret from that same response>'
$env:ZATCA_TOKEN = '<binarySecurityToken from sandbox production CSID>'
$env:ZATCA_SECRET = '<secret from that same response>'

Run one command for the complete workflow:

python main.py --submit
dotnet run -- --submit
npm start -- --submit

This creates a new invoice and sends one reporting request. It does not submit the previous local run's artifact. Before repeated submissions, allocate a new invoice ID and counter and supply the correct predecessor in input.json. The examples do not maintain an invoice ledger or automatically advance those fields.

5. Read the response and keep the artifacts

The program saves response.json before calling the library's acceptance check. It prints gateway messages alongside the HTTP status and reporting outcome. For an accepted response, the last line is:

Sandbox reporting accepted. Keep any warnings in response.json.
Result What the example does What to do next
Accepted Exits successfully and sets state.json stage to accepted. Store the invoice and response together.
Accepted with warnings Succeeds and retains the warnings. Review the warning codes; acceptance does not erase them.
Rejected or unknown outcome in a decoded response Saves response.json, then exits nonzero. Read the findings and resolve the outcome before creating another invoice.
HTTP error, such as 400 or 401 Exits nonzero; binding diagnostics go to error.json. Inspect status/body details; correct the reported issue or credentials.
Connection failure or unreadable response Exits nonzero; state.json records the attempted stage. Treat acceptance as unresolved. Reconcile the attempt before retrying.
Local validation failure Saves validation.json and sends nothing. Fix the input indicated by the local findings.

HTTP 200 alone is insufficient. Each example calls ensure_accepted or EnsureAccepted/ensureAccepted, which checks the operation's reporting status and validation results. The API decoder raises an exception for non-2xx responses; such errors may have error.json instead of response.json.

Every run writes to a new runs/<uuid>/ directory:

  • invoice.xml: unsigned XML created from the input.
  • signed.xml: exact signed XML used for validation and reporting.
  • validation.json: local rule findings and stage coverage.
  • response.json: decoded reporting result, when one was returned.
  • error.json: structured binding diagnostics, when available.
  • state.json: invoice identity/hash and the last workflow stage.

In state.json, submitted: true means a reporting call was attempted. It does not mean accepted. Preserve these files after a failure. The program has no retry loop, and running it again creates a different invoice. Protect invoice artifacts and error bodies as business data; the examples do not write credentials into them.

What changes for production

Area Sandbox walkthrough Production integration
Environment NonProduction / 0, the developer-portal endpoint. Complete the required testing and onboarding for the live environment; configure Production / 2, the core endpoint. Simulation / 1 is a separate test environment.
Credentials Test device's certificate/key pair and API token/secret. Credentials issued for the live EGS unit. Changing an environment number does not convert credentials.
Invoice data Fixed two-item SAR 115 sale and editable demonstration identity. Real seller/customer data, items, tax treatment, and applicable invoice subtype.
History Manually supplied ID, counter, and previous hash. Durable per-unit sequencing and invoice history, safe under concurrent creation. History follows generated invoices; do not advance it only when reporting succeeds.
Submission One blocking/awaited sandbox call. Durable submission records and response reconciliation. A retry policy must distinguish rejection from an unknown outcome and preserve invoice identity for retries.
Operations Local files and process environment. Managed key storage, credential renewal/revocation, access controls, backups, and monitoring of pending submissions and warnings.

Keep environment selection consistent in the client, credentials, and CSR creation. The example constants deliberately select the sandbox. Before adapting them for production, implement the history and submission behavior above and use the current ZATCA developer materials for your integration requirements.

For standard invoices, supply the required buyer data and use the clearance operation. The library accepts a SignedInvoice for that call; ZATCA clearance produces the document to deliver. Inspect clearance_status. On successful clearance, save cleared_invoice_xml() / ClearedInvoiceXml() / clearedInvoiceXml() and use the returned document for customer delivery. The API reference explains those return types and the nullable cleared-document field.