Error format
Errors use one envelope, whichever layer refused the call. Branch on error.code, never on the message: messages are written for people and may change.
{
"ok": false,
"error": {
"code": "insufficient_scope",
"message": "This API key has not been granted the permission this request needs.",
"details": { "required_ability": "invoices:issue" }
}
}
Language of messages
Messages are in Arabic unless the request's Accept-Language header prefers English (Accept-Language: en, en-US and so on); Arabic is used for anything else, or when the header is absent. Every answer names its language in the Content-Language header. Codes, field names and data never change with the language. The MCP server follows the opposite default for AI agents: English unless the client asks for Arabic.
Error codes
| HTTP | Code | Cause and fix |
|---|---|---|
| 400 | bad_request |
The request could not be read, for example a JSON body that does not parse. details says what was wrong. |
| 400 | unsupported_api_version |
The path names an API version other than v1 (or a malformed one). Use /api/v1/…. |
| 401 | unauthorized |
The key is missing, unknown or revoked, or its creator no longer belongs to the company. Check the header and the key in Settings → API keys. |
| 401 | invalid_token |
The OAuth access token is invalid, expired or revoked, or the company disconnected the app. Refresh it, or send the person through the consent screen again. |
| 401 | token_expired |
The key expired on the date in the message. details.renew_url is the page where an admin creates a new key or rotates this one (same scopes); details.expired_at is the exact time. |
| 403 | insufficient_scope |
The credential lacks a scope; details.required_scope names it (details.required_ability carries its original name for older clients). Use a credential that has it. |
| 403 | forbidden |
The person the credential acts for is not allowed to do this, for example to issue invoices. Change the user's role, or use a credential of someone who may. |
| 403 | plan_upgrade_required |
The company's plan does not include API access (Business or Enterprise). |
| 403 | mode_mismatch |
A sandbox credential met a live company, or the reverse. Credentials are always made for their own kind of company, so this does not happen in normal use; such a credential is refused as 401 unauthorized. |
| 404 | not_found |
No such endpoint, or no such record (a webhook endpoint or delivery) in the credential's company. Check the path and the id. |
| 404 | invoice_not_found |
There is no invoice with that id in the credential's company. |
| 404 | customer_not_found |
There is no customer with that id in the credential's company. On POST /invoices, a customer.id that is not yours answers it with 422. |
| 404 | note_not_found |
There is no credit or debit note with that id in the credential's company. |
| 404 | xml_not_applicable |
The company is not linked to ZATCA, so its documents have no legal XML. Download the PDF instead. |
| 405 | method_not_allowed |
The endpoint exists but not with this HTTP method; the Allow header lists the methods it takes. |
| 409 | invoice_not_draft |
The action needs a draft (validating or issuing it), and the document is not one, for example a draft that was cancelled. Read its status first. |
| 409 | invoice_immutable |
An issued document cannot be changed. Correct it with a credit or debit note. |
| 409 | document_not_available |
The PDF or XML is not ready yet, usually because ZATCA has not processed the document. Try again shortly. |
| 409 | idempotency_in_progress |
A request with the same Idempotency-Key is still running. Wait Retry-After seconds and send it again unchanged. |
| 413 | payload_too_large |
The request body is too large. |
| 415 | unsupported_media_type |
The body is not application/json. Send Content-Type: application/json. |
| 422 | validation_failed |
The body is invalid; details maps each field to its messages. |
| 422 | invalid_reference |
A line's tax_rate_id or product_id is not one of yours; details.field names it. |
| 422 | invoice_requirements_missing |
Your company settings lack something every invoice needs; details.missing lists it. Complete it in the dashboard. |
| 422 | invoice_not_issuable |
The invoice cannot be issued as it stands; the message says why. Nothing was created. |
| 422 | customer_vat_invalid |
The buyer's Saudi VAT number is malformed. |
| 422 | credit_exceeds_invoice |
The credit note is larger than what is left on the invoice after earlier credit notes. |
| 422 | note_reason_required |
A credit or debit note needs a reason. |
| 409 | invoice_not_payable |
A payment is recorded on an issued invoice only, never on a draft, cancelled or refunded one; details.status says where it stands. |
| 422 | payment_exceeds_balance |
The payment is more than the invoice still owes; details.balance and details.currency say how much. Overpayment can only be recorded by a person in WonderFatoora. On POST /invoices nothing was created. |
| 422 | plan_limit_reached |
The company has used up an allowance of its plan; details.resource and details.limit name it. Upgrade the plan or wait for the next period. |
| 422 | idempotency_key_reused |
This Idempotency-Key was already used with a different request. Use a new key for a new request. |
| 429 | rate_limited |
Too many requests. Wait Retry-After seconds. See Rate limits. |
| 500 | server_error |
Something failed on our side and was reported to us automatically; details.request_id identifies the call. Retry with the same external_reference or Idempotency-Key. |
| 503 | service_unavailable |
This endpoint is switched off for now. Try again later. |
Validation errors
HTTP/1.1 422 Unprocessable Content
{
"ok": false,
"error": {
"code": "validation_failed",
"message": "The submitted data is invalid.",
"details": {
"issue_date": ["The issue date field is required."],
"items.0.unit_price": ["The items.0.unit_price field is required."]
}
}
}
Troubleshooting
I get an HTML page instead of JSON
Send Accept: application/json on every request. Without it, a mistyped URL can answer with the website's HTML error page instead of a JSON error.
401 although the key looks right
- The header must be
Authorization: Bearer <key>(one space, no quotes) orX-Api-Key: <key>. - Keys in the URL are refused.
- The key may have been rotated: the old key stops working the moment it is rotated. An expired key answers
token_expiredinstead. - The user who created the key may have left the company. Create a new key.
403 forbidden when issuing
The key needs invoices:issue, and its creator needs the permission to issue invoices. A user with a view-only or limited role cannot issue through a key either.
Issuing fails with invoice_not_issuable
Read error.message; it names the problem. The most common causes: the company VAT number or address is incomplete; a business buyer of a standard invoice has no complete national address; a zero-rated or exempt line has no vat_exemption_code. Create a draft ("issue": false) to keep the data while you fix it.
The invoice is issued but deliverable is false
A standard invoice waits for ZATCA clearance. Read it again after a short wait. If its status becomes rejected, errors holds ZATCA's reasons.
A retry created a second invoice
Send external_reference. Without it, every call is a new invoice.
Getting help
Write to us with the time of the call, the HTTP status and error.code. Never send the API key itself.
Related
Ready to build?
API access is part of the Business and Enterprise plans. Create a key in Settings → API keys and make your first call in minutes.