The Mutabiq API

Check ZATCA Phase 2 e-invoice XML from your own systems — an ERP hook, a queue worker, a CI job — with the same deterministic checks as the upload page, and read the findings as JSON in English, Arabic or both.

Pre-validation against published ZATCA requirements. A PASS is not ZATCA approval: Mutabiq never generates, submits or clears an invoice and never connects to the Fatoora platform.

If you already run the ZATCA SDK

Keep running it. Mutabiq's rule pack mirrors the rule files that come with the SDK, and every fixture behind it is a document the SDK judged first; the coverage page lists each SDK rule code Mutabiq checks and each it does not. What Mutabiq adds comes after the verdict: each finding explained in English and Arabic, with the field to check in the mapping, and a report a colleague or a client can read without the SDK installed.

Its place, on the upload page and through this API, is onboarding and support: a CI job on the export template, a hook on the copy that goes out before reporting, a support desk reading a rejected file. It is not the signing path: Mutabiq never signs, stamps, submits or clears, and a PASS from it is not a clearance.

Access and keys

The API is part of the Team and Partner plans. An owner of the organisation creates keys on the API keys page. A key is shown once, when it is created, and Mutabiq keeps only a SHA-256 hash of it; a revoked key is refused at once. Every check made with a key counts against the organisation's monthly allowance, exactly as an upload does.

A key also stops working on its own when the owner who created it leaves the organisation, is removed from it, deletes their account, or makes another member the owner. Another owner then creates a new key; there is no way to restore the old one.

Send the key in the Authorization header of every request:

Authorization: Bearer mtb_…

The endpoint

POST /api/v1/validate

The request body is the file itself, not a form, sent with one of these Content-Types: application/xml, text/xml, application/zip, application/pdf or application/octet-stream. What the file is, is decided by its first bytes and not by the Content-Type, and XML is read as UTF-8 and nothing else.

  • One XML invoice, up to 5 MB.
  • A PDF/A-3 with the invoice XML embedded in it, up to 10 MB: the XML is checked, the PDF is not.
  • A ZIP of up to 100 XML invoices, up to 20 MB, answered with one entry per invoice. Each invoice counts as one check.

The whole archive is checked in one request, and that request may run for up to 120 seconds (the route's maxDuration) before the platform ends it; the entry and size caps above are what keep a batch well inside that.

Measured on a development machine, not the production runtime: a ZIP of 100 synthetic invoices, a mix of passing and failing, took about 8 seconds through the validator, the median of three runs. The production runtime is slower and adds a cold start, so read it as an order of magnitude, not a promise.

Add a lang parameter to the query string, with the value en, ar or both, to choose the language of each finding's explanation. The default is en.

To file a check under a client, a branch or an engagement, send an X-Mutabiq-Label header: up to 80 characters, percent-encoded UTF-8 as encodeURIComponent writes it (a plain ASCII label with no % in it goes as it is). It is stored with the run, shown in the check history and returned as label. Put a reference of your own in it, never a value from the invoice; it is never logged.

An example

Checking one of the example invoices this site publishes, whose seller VAT registration number is one digit short: bad-04-vat-number-14-digits.xml

curl -X POST "https://mutabiq.hayatiq.ai/api/v1/validate?lang=en" \
  -H "Authorization: Bearer $MUTABIQ_API_KEY" \
  -H "Content-Type: application/xml" \
  --data-binary @bad-04-vat-number-14-digits.xml

The response the API returned for that file:

{
  "source": "xml",
  "verdict": "FAIL",
  "rule_pack_version": "2.2.0",
  "reference_date": "2026-09-27",
  "zatca_sdk": "3.4.8",
  "evaluated_at": "2026-09-27T09:14:05.212Z",
  "build_id": "4757443f97d2b322bb98307284bfa750d0e67767",
  "request_id": "7c0e4b1a-5d2f-4e8b-9a61-3f2d8c9b0e47",
  "run_id": "3b9d2f64-8a1e-4c57-b0d3-6e2f91a4c8d5",
  "label": null,
  "coverage": {
    "checked_codes": 96,
    "total_codes": 242,
    "zatca_sdk": "3.4.8"
  },
  "document_identity": {
    "invoice_number": "MTB-G01",
    "issue_date": "2026-09-01",
    "invoice_type_code": "388",
    "invoice_type_name": "0100000",
    "seller_vat_number": "30000000000003",
    "icv": "1"
  },
  "qr_consistent": null,
  "findings": [
    {
      "rule_id": "MTB-BR-KSA-40",
      "zatca_codes": [
        "BR-KSA-40"
      ],
      "severity": "ERROR",
      "xpath": "//*[local-name()='AccountingSupplierParty']//*[local-name()='PartyTaxScheme']/*[local-name()='CompanyID']",
      "observed_value_class": "not_15_digits",
      "explanation_key": "br_ksa_40.not_15_digits",
      "explanation": {
        "en": {
          "title": "Seller VAT registration number is not 15 digits",
          "what_is_wrong": "The seller VAT registration number (BT-31), in cbc:CompanyID under the seller's VAT tax scheme, is not exactly 15 digits. ZATCA rejects it under BR-KSA-40, which requires 15 digits and nothing else - too few, too many, or any character that is not a digit all fail the same test.",
          "what_to_check_in_your_erp": "Send the 15 digits alone: no spaces, no dashes, no VAT or SA prefix, and no group separators. The common causes are a number stored with formatting for display and exported as stored, a value padded or truncated to a fixed column width, and the commercial registration number being mapped into this field by mistake - it is a different number and usually 10 digits. Correct it in the company record or the mapping, not in the exported file."
        }
      }
    }
  ]
}

The response

A successful check answers 200 with JSON. Its fields:

source
What was checked: xml, pdf_a3 (the XML embedded in a PDF), or zip.
verdict
PASS or FAIL. FAIL means at least one finding is an ERROR; a WARNING or an INFO never fails a document.
rule_pack_version
The version of the rules that judged the document.
reference_date
The date the date rule judged the issue date against — see Determinism below.
zatca_sdk
The ZATCA SDK release whose published rules rule_pack_version mirrors.
evaluated_at
When the validator ran, ISO 8601 in UTC.
build_id
The deployment's build (its git commit); "unknown" when the host does not state one. It is given to authenticated API callers only, for support; the public /api/health and /status name no commit.
request_id
This request's id, as the X-Request-Id header.
run_id
The stored run's id, as the report page and the history show it; null when nothing was stored (a ZIP whose every member is a PDF refused before an invoice was found).
label
The run label sent as the X-Mutabiq-Label header, as it was stored with the run; null when none was sent or nothing was stored.
coverage
How many of the rule codes in ZATCA's SDK rule files the rule pack checks: checked_codes of total_codes, and the zatca_sdk release counted. The same numbers as /rules/coverage, which lists every unchecked code with the reason it is unchecked. null when rule_pack_version is not the pack this build was counted for.
document_identity
Which document was checked, read from it for this response only and never stored or logged: invoice_number, issue_date, invoice_type_code, invoice_type_name, seller_vat_number and icv, each as the document writes it, or null. For a ZIP, each entry carries its own.
qr_consistent
Whether the QR code agrees with the invoice it is on: true when a QR payload was decoded and tags 1 to 5 match the XML, false when the payload could not be decoded or a tag is absent or disagrees (MTB-QR-00 to MTB-QR-05), null when there was no QR to compare. A signal beside the verdict, not part of it: those findings are warnings, so a result can be PASS with qr_consistent false. On a single result and on each batch entry.
findings
One object per finding, in a fixed order.
rule_id
Mutabiq's rule id, which has a page of its own under /rules.
zatca_codes
The ZATCA rule code or codes the rule mirrors. Empty for a check of Mutabiq's own, such as the QR code and the archive checks.
severity
ERROR, WARNING or INFO.
xpath
Where in the document the finding is.
observed_value_class
What kind of value was found — empty, not_15_digits and the like — never the value itself.
explanation_key
The key of the explanation text, stable across languages.
explanation
The explanation, keyed by language: a title, what is wrong, and what to check in your ERP. An Arabic entry also says whether its review is pending or approved. null where no text is written yet.
summary
For a ZIP: how many entries the archive held, how many invoices passed and failed, and how many entries were skipped because they were neither an XML invoice nor a PDF.
entries
For a ZIP: one object per invoice, with its position in the archive, its file name as the archive gives it, its verdict and its findings.

Errors

A refusal is JSON with a stable error code, plus a reason where a PDF or an archive was refused. Nothing is recorded or counted for a refusal.

errorStatusMeaning
missing_api_key401The request has no Authorization header carrying a key after the word Bearer.
invalid_api_key401The key is not one Mutabiq issued, or it was mistyped.
api_key_revoked401An owner of the organisation revoked the key.
monthly_limit_reached402The organisation's checks for this month are used up.
api_not_in_plan403The organisation's plan does not include the API.
zip_not_in_plan403The organisation's plan does not include ZIP batches.
invalid_lang400lang is not en, ar or both.
invalid_label400The X-Mutabiq-Label header is not a label Mutabiq stores: longer than 80 characters, not percent-encoded UTF-8, or holding a control, zero-width or bidi character.
empty_body400The request has no body.
not_utf8400The XML is not valid UTF-8.
encoding_not_utf8400The XML declares an encoding other than UTF-8, or starts with a byte-order mark for one.
nul_byte400The XML contains a NUL character.
pdf_rejected400The PDF could not be read, or does not hold exactly one embedded XML invoice. reason says which.
batch_rejected400The archive could not be read, or holds no XML invoice. reason says which.
body_too_large413The request is larger than the largest file the API accepts.
file_too_large413The file is over the size limit for its type.
unsupported_media_type415The Content-Type is missing or is not one of the accepted types.
rate_limited429This key has made too many requests in the current minute.
engine_failed500The check could not run on our side. Try again.
storage_failed500The result could not be recorded on our side. Try again.
auth_unavailable500The key could not be checked on our side. Try again.

Rate limits

Each key may make 60 requests a minute. Every response to a request with a valid key carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, the last in Unix seconds; a 429 also carries Retry-After, in seconds. The limit is counted per key, not per address, so systems behind one shared address do not use each other's allowance. The monthly allowance is separate: once it is spent the answer is 402 until the month turns in Saudi time or the plan changes.

Every successful response also carries X-Usage-Limit, X-Usage-Used and X-Usage-Reset. X-Usage-Limit is the number of validations your plan allows in a month, X-Usage-Used is how many have been counted this month, including this request, and X-Usage-Reset is when the count starts again: midnight Saudi time on the first of next month, in ISO 8601. If the count cannot be read, all three are left out.

The edge Firewall also limits /api/v1/ to 120 requests per 60 seconds per IP address, and answers those with a bare 429: no JSON body, no Retry-After and none of the headers above. Treat any 429 as a signal to back off.

Versioning

v1 is the shape of the response. It changes only by adding fields; removing or renaming one would be a v2. The rules are versioned separately, as rule_pack_version: its minor version moves whenever a rule is added, removed or changes severity, and its patch version when a rule's check changes without changing what it reports. Every version is on the changelog.

API changes

Each version of the response shape, the day it shipped and the fields it added. Nothing has been removed.

1.4.0
label on every result: the run label sent as the optional X-Mutabiq-Label header; the invalid_label error.
1.3.0
qr_consistent on every single result and every batch entry: whether the QR code agrees with the invoice, beside the verdict.
1.2.0
coverage on every result: how many of the rule codes in ZATCA's SDK rule files the rule pack checks.
1.1.0
zatca_sdk, evaluated_at, build_id, request_id and run_id on every result; document_identity; the X-Request-Id and X-Usage-* headers.
1.0.0
The first keyed API.

Determinism

The verdict comes from deterministic code only; no language model decides it. The same file, under the same rule_pack_version and the same reference_date, gets the same result. reference_date is the date in Saudi Arabia (Asia/Riyadh) when the request is checked, and it is what the one date-dependent rule, MTB-BR-KSA-04, judges the issue date against.

The same rule pack checks the same codes: rule pack 2.2.0 checks 96 of the 242 rule codes in ZATCA's SDK 3.4.8 rule files, and every response states that fraction as coverage. What is not checked, and why.

What is stored, and what never is

The file is checked in memory and discarded when the response is sent. It is never written to disk, to a database or to a log, and neither is the name of a ZIP entry. For each check Mutabiq stores what an upload leaves: the verdict, the number of files, the invoice type code, the run label if you sent one, and each finding's rule id, severity, XPath, value class and explanation key, with the entry's position for a ZIP. Logs carry rule ids, counts and timings only. A key is stored only as a SHA-256 hash. The privacy policy has the rest.

OpenAPI

The OpenAPI 3.1 description of this API, for generating a client, is at /api/v1/openapi.json.