AlFasil API
Send invoices and receipts, get structured JSON back. Available on every paid plan (Starter and up).
- Base URL:
https://alfasil.com - Format: JSON, UTF-8. Amounts are decimal strings (
"1680.5"), dates areYYYY-MM-DD, currencies are ISO 4217, countries ISO 3166-1 alpha-2. - Machine-readable spec:
/openapi.yaml
Authentication
Create a key in Settings → API keys (workspace owner). Send it as a bearer token:
Authorization: Bearer alfasil_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
The full key is shown once. AlFasil stores only a hash of it. Revoked keys stop working immediately. Keys stop working if the workspace drops to the Free plan. Keys created before the product was renamed start with qayd_live_ and keep working.
Quick start
1. Upload a document
Send the raw file as the request body with its content type. Supported: application/pdf, image/jpeg, image/png, image/webp, image/gif, and application/xml (or text/xml) for structured e-invoices.
curl https://alfasil.com/v1/documents \
-H "Authorization: Bearer $ALFASIL_KEY" \
-H "Content-Type: application/pdf" \
-H "X-File-Name: invoice-4471.pdf" \
-H "Idempotency-Key: erp-bill-4471" \
--data-binary @invoice-4471.pdf
Response 202 Accepted:
{
"document": {
"id": "doc_0mgf3k2q1zA8b2Kc9dX1",
"status": "queued",
"source": "api",
"file_name": "invoice-4471.pdf",
"data": null,
"warnings": []
}
}
Optional headers:
| Header | Meaning |
|---|---|
X-File-Name |
Original file name (URL-encode non-ASCII names) |
Idempotency-Key |
Retrying with the same key returns the same document instead of a duplicate (200) |
X-File-Sha256 |
Hex SHA-256 of the file; enables duplicate-file detection for large files |
X-Client-Id |
Firm plan: attach the document to a client workspace |
X-Page-Count |
Your page estimate, used for the quota check at upload. The real page count is read from the file when it is processed. |
X-Zatca-Qr |
The Base64 content of the invoice's ZATCA QR code, if you decoded it. The extracted data is checked against it (see QR_MISMATCH). Invalid values are ignored. |
Small files (≤ 4 MB) may also be sent as multipart/form-data with a file field (and optional client_id and zatca_qr fields).
E-invoices (XML)
UBL 2.1 e-invoices are read exactly, without AI: Saudi ZATCA Phase 2 (FATOORA) invoices, credit and debit notes; UAE PINT AE and other Peppol BIS / PINT Invoice and CreditNote documents. Upload the XML file itself, or a PDF/A-3 with the XML embedded — AlFasil then uses the XML instead of reading the pages. An e-invoice counts as one page, however many pages its PDF has.
curl https://alfasil.com/v1/documents \
-H "Authorization: Bearer $ALFASIL_KEY" \
-H "Content-Type: application/xml" \
--data-binary @INV-2026-000184.xml
Such documents have einvoice_format set (zatca, pint_ae or ubl) and model_used set to einvoice:<format>. XML that isn't a UBL invoice fails with not_an_einvoice; CII syntax (ZUGFeRD, Factur-X) isn't parsed yet.
2. Get the result
Either poll, or (better) register a webhook.
curl https://alfasil.com/v1/documents/doc_0mgf3k2q1zA8b2Kc9dX1 \
-H "Authorization: Bearer $ALFASIL_KEY"
{
"document": {
"id": "doc_0mgf3k2q1zA8b2Kc9dX1",
"status": "done",
"page_count": 1,
"needs_review": false,
"needs_review_fields": [],
"data": {
"document_type": "invoice",
"languages": ["en", "ar"],
"currency": "AED",
"supplier": { "name": "Al Noor Stationery Est.", "tax_id": "100234567800003", "tax_id_type": "TRN", "address": "Al Quoz 3, Dubai", "country": "AE" },
"buyer": { "name": "Demo Trading FZE", "tax_id": null, "tax_id_type": null, "address": null, "country": "AE" },
"invoice_number": "INV-4471",
"po_number": "PO-2026-118",
"issue_date": "2026-09-14",
"issue_date_original": null,
"due_date": "2026-10-14",
"due_date_original": null,
"period_start": null,
"period_end": null,
"stated_page_count": null,
"line_items": [
{ "description": "A4 copy paper", "quantity": "10", "unit_price": "95", "discount": null, "tax_rate": "5", "tax_amount": "47.5", "line_total": "950", "line_total_includes_tax": false },
{ "description": "Printer toner", "quantity": "2", "unit_price": "325", "discount": null, "tax_rate": "5", "tax_amount": "32.5", "line_total": "650", "line_total_includes_tax": false }
],
"subtotal": "1600",
"discount_total": null,
"shipping": null,
"tax_breakdown": [{ "rate": "5", "taxable_amount": "1600", "tax_amount": "80" }],
"tax_total": "80",
"total": "1680",
"amount_paid": null,
"balance_due": "1680",
"payment_terms": "Net 30",
"field_confidence": { "total": 0.99 },
"field_pages": { "total": 1 },
"notes": null
},
"warnings": []
}
}
Status values: queued → processing → done (all checks pass) or needs_review (see warnings and needs_review_fields), or failed (see error).
Hijri dates are converted to Gregorian; the printed original is kept in issue_date_original / due_date_original.
page_count is read from the file itself (PDF page tree), not estimated. zatca_qr holds the decoded ZATCA QR code (seller name, VAT number, timestamp, total, VAT total, phase) when the document has one; einvoice_format is set when the data came from an e-invoice XML.
When one file contains several invoices, the extra invoices become separate documents whose parent_id is the uploaded document.
Endpoints
| Method | Path | Description |
|---|---|---|
POST |
/v1/documents |
Upload a document |
GET |
/v1/documents |
List documents. Query: status (comma list), client_id, from, to (YYYY-MM-DD), exported (yes/no), limit (≤ 200), cursor |
GET |
/v1/documents/{id} |
One document |
PATCH |
/v1/documents/{id} |
Replace data (re-validated) and/or {"approve": true} |
DELETE |
/v1/documents/{id} |
Delete a document and its original |
POST |
/v1/documents/{id}/retry |
Re-process a failed document |
POST |
/v1/documents/bulk |
{"action": "approve" | "mark_exported" | "delete", "ids": [...]} |
GET |
/v1/usage |
Pages used this month, pack pages left (extra_pages) and history |
Webhooks are managed in Settings → Webhooks (or POST /v1/webhooks / DELETE /v1/webhooks/{id} from a signed-in session).
Webhooks
Events: document.processed, document.failed. AlFasil POSTs JSON to your HTTPS URL:
{
"id": "evt_0mgf3m1c4b9XyZ",
"type": "document.processed",
"created_at": "2026-10-09T08:55:12.000Z",
"data": { "document": { "id": "doc_…", "status": "done", "data": { "…": "…" }, "warnings": [] } }
}
Each request carries AlFasil-Signature: t=<unix seconds>,v1=<hex> (and the same value in Qayd-Signature, the header's name before the product was renamed) where v1 is HMAC-SHA256 of "<t>.<raw body>" with your webhook's signing secret. Reject requests older than 5 minutes. Respond with any 2xx within 10 seconds; otherwise AlFasil retries with exponential back-off (up to 8 attempts).
Node.js
import crypto from "node:crypto";
export function verifyAlFasil(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!parts.t || !parts.v1 || age > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
Python
import hashlib, hmac, time
def verify_alfasil(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
Warnings
Every warning has a stable code, the affected fields, and messages in English and Arabic:
| Code | Meaning |
|---|---|
LINES_SUBTOTAL_MISMATCH / LINES_TOTAL_MISMATCH |
Line items don't add up |
LINE_MATH_MISMATCH |
Quantity × unit price ≠ line total |
TOTAL_MISMATCH |
Subtotal − discount + shipping + tax ≠ total |
BALANCE_MISMATCH |
Total − paid ≠ balance due |
TAX_MATH_MISMATCH / LINE_TAX_MATH_MISMATCH |
Tax amount doesn't match the rate |
UNUSUAL_TAX_RATE / UNEXPECTED_TAX |
Rate unusual for the supplier's country |
TAX_ID_FORMAT |
TRN/VAT number format looks wrong |
DUE_BEFORE_ISSUE / ISSUE_IN_FUTURE / IMPLAUSIBLE_YEAR |
Date problems |
DUPLICATE_FILE / DUPLICATE_INVOICE |
Already uploaded |
MISSING_FIELD |
A required field wasn't found |
LOW_CONFIDENCE |
The AI wasn't sure; please double-check (severity info). Fields confirmed by the ZATCA QR code are left out. |
QR_MISMATCH |
The VAT number, date, total or VAT differs from the invoice's ZATCA QR code |
MISSING_PAGES |
The document prints a page count ("Page 1 of 2") higher than the pages in the file |
Errors
All errors share one shape:
{ "error": { "code": "quota_exceeded", "message_en": "You've used all pages in your plan this month.", "message_ar": "استهلكت كل صفحات باقتك لهذا الشهر." } }
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_request |
Malformed input |
| 401 | invalid_api_key |
Missing, wrong or revoked key |
| 402 | quota_exceeded |
Monthly pages (and pack pages) used up |
| 403 | plan_required |
Feature not in your plan |
| 404 | not_found |
Unknown id (or another workspace's) |
| 409 | idempotency_conflict |
Same Idempotency-Key, different file |
| 410 | gone |
Original already deleted |
| 411 | length_required |
Raw upload without Content-Length |
| 413 | file_too_large |
Over the plan's file size |
| 415 | unsupported_media_type / file_mismatch |
Not a supported type, or content doesn't match it |
| 429 | rate_limited |
Too many requests per minute |
Processing failures are reported on the document (status: "failed", error.code), not as HTTP errors: not_an_invoice, not_an_einvoice, too_many_pages (more pages than your plan allows per document), quota_exceeded, bad_file, invalid_output, timeout, upstream.
Limits
| Plan | Requests / minute | Max file | Pages / month |
|---|---|---|---|
| Starter | 60 | 20 MB | 500 |
| Pro | 120 | 25 MB | 1,500 |
| Firm | 240 | 25 MB | 5,000 |