Developers
Mahad OCR API reference
The Mahad OCR API reads a document you upload and returns its fields as JSON, each marked with the evidence behind it. You POST a file to /v1/documents/upload with your API key in the X-MahadDoc-API-Key header, receive a document id at once (202), and read the finished result from GET /v1/documents/{id} — or receive a signed webhook when it is done.
Base URL https://api.mahadocr.com/v1. Interactive documentation generated from the running API: https://api.mahadocr.com/docs.
Authentication
Create a key in the dashboard (app.mahadocr.com → API keys) after verifying your email address and phone number. Keys start with mk_live_, are shown once and stored only as a SHA-256 hash. Send the key in the X-MahadDoc-API-Key header on every request. Dashboard sessions use Authorization: Bearer <token> instead.
Keep keys on your server. Never put a key in browser JavaScript, a mobile app or a public repository; disable a key at once if it leaks.
curl https://api.mahadocr.com/v1/documents -H "X-MahadDoc-API-Key: mk_live_YOUR_KEY"Upload a document
POST /v1/documents/upload — multipart/form-data. Returns 202 Accepted with the document; reading happens in the background. Uploading the exact same file again is recognised (cached: true).
| Form field | Required | Meaning |
|---|---|---|
| file | yes | PNG, JPG, PDF, Word (.docx/.doc); up to 10 MB. The real type is read from the file’s bytes — a renamed file is refused (422 unsupported_file). A PDF is read up to its first 5 pages. |
| doc_type | no | A type hint such as passport, id_card, qid, visa, invoice, receipt, cv. Leave it out and the type is detected. |
| outputs | no | Comma-separated extra outputs: pdf, docx, xlsx (json is always there). An unknown value is refused with 422 unknown_output. |
| improve | no | true to improve a CV’s wording after reading (never its facts). |
| consent | no | A note recorded with the consent record for this upload. |
curl -X POST https://api.mahadocr.com/v1/documents/upload \
-H "X-MahadDoc-API-Key: mk_live_YOUR_KEY" \
-F "[email protected]" -F "doc_type=passport"
HTTP/1.1 202 Accepted
{
"id": "DOC_3f9a1c7e2b40", "name": "passport.jpg", "type": "Passport",
"status": "processing", "cached": false, "uploaded_at": "2026-09-30T08:15:02",
"outcome": { "status": "pending", "code": null, "message": "Processing.",
"next_action": { "type": "WAIT", "message": "Check back shortly." }, "context": {} }
}Batch
POST /v1/documents/batch — field files repeated, up to 20 files (more → 413 too_many_files). Your plan quota is checked for the whole batch first. Each item is either a document or { "filename", "error" } (unsupported_file, file_too_large); the response is { "items": [...], "queued": n }.
Get the result
GET /v1/documents/{id} returns the document with a result object. Poll every few seconds until result.status is no longer processing.
Top-level keys: id, tenant_id, name, type, status, cached, uploaded_at, outcome, doc_type, fields, raw_json, engine, result, plus two legacy numeric keys (confidence, tamper_score) kept for older integrations. Build on result; raw_json is the engine’s internal record and may change.
List documents: GET /v1/documents?status=&type=&q=&page=1&page_size=25 (page_size at most 100) → { items, page, page_size, total }.
The result object
| Key | Meaning |
|---|---|
| status | The document’s status: processing, verified, review, failed (also flagged, rejected after a person’s action). |
| verified | true only when the document status is verified. |
| decision | verified · review · rescan · failed · processing — the machine decision under your organisation’s verification policy. rescan means the photo must be retaken. |
| document_type | What the readers saw: passport, qid, visa, cv, invoice … |
| values | The fields as flat key → value. A disputed value is never here. |
| field_status | Per field: read · unreadable · not_present · conflict. |
| field_evidence | Per field: deterministic (MRZ check digit) · corroborated (two readers) · grounded / text_layer (in the file’s own text) · arithmetic (totals add up) · model (one reader). |
| disputed_fields | Fields the readers disagreed on — ask a person. |
| review_reason, review_reasons | Why a person must look (one line, and the full list: expired first, then ⚠ fields, then failed checks with a reason). |
| next_action | { type, message } — what to do next. |
| field_review | { fields: [{ key, label, value, state, reason, evidence }], summary: { ok, check, missing } } — state ok (✓), check (⚠ one reader or disputed), missing (⚠ nothing published). |
| fields | Per field: { value, status: passed | review | missing, evidence: [mrz_checksum | reader_agree | text_layer | arithmetic | judge | operator | single_reader], reason, region } — region is { page, box: [x0, y0, x1, y1] } (0–1 of the page) only where the value was located on the page, else null. |
| verification | { statuses, level, evidence[], failed_rules, policy } — statuses: readable, classified, data_correct, authenticity_supported, verified. evidence[] items: { level L1–L4, rule, result passed | failed, fields, detail }. These are evidence flags, not a finding that a document is real. |
| issuing_country | Where read. |
| outputs | Links to available outputs (json always; cv docx/pdf for CVs; xlsx when requested). A requested format with no generator is listed in unavailable. |
| cv, cv_improved | CVs: the full structure; improved wording when requested. |
| timings_ms, engines, readers, cost_usd | Processing metadata. |
Statuses and outcome codes
The document’s outcome is { status, code, message, next_action, context }. status is verified, action_required (→ document status review), rescan_required (→ review, decision rescan) or failed. Codes:
| Code | Meaning |
|---|---|
| QUALITY_GATE_GLARE / _BLUR / _CUTOFF / _LOW_RESOLUTION / _DARK / _UNREADABLE | Retake the photo (rescan_required). |
| DOCUMENT_UNREADABLE | The file could not be opened (failed). |
| PROCESSING_PROVIDER_FAILURE | The reading service was unavailable after every fallback (failed). |
| MISSING_PAGE | Pages were not processed (for example a PDF beyond 5 pages). |
| UNSUPPORTED_LANGUAGE_SCRIPT | The script is not available on the active reading route. |
| FIELD_CONFLICT / FIELD_UNCONFIRMED | Readers disagree / a key field rests on one reading. |
| CRITICAL_FIELD_INVALID | A key field failed a consistency check. |
| REQUIRED_FIELD_MISSING | A required field is empty. |
| DATE_NEEDS_REVIEW | A date is impossible or not Gregorian. |
| DOCUMENT_EXPIRED | The document has expired. |
| ARITHMETIC_MISMATCH | Invoice amounts do not add up. |
| DUPLICATE_SUSPECTED | Looks like an invoice already recorded by your organisation. |
| TAMPER_SUSPECTED | Signs of image editing were found (document status flagged). |
| NO_FIELDS_EXTRACTED / LOW_CONFIDENCE / DOCUMENT_TYPE_UNKNOWN | Nothing usable, weak readings, or unknown type — a person must look. |
A person resolves a document with POST /v1/documents/{id}/approve (with corrected fields) or /reject; /rescan reads the stored original again without charging again.
Outputs
JSON is always returned. Ask for more with outputs=xlsx (and pdf, docx) at upload. Available links appear in result.outputs:
| Output | Endpoint |
|---|---|
| xlsx | GET /v1/documents/{id}/outputs/xlsx — fields and line items (409 not_finished while reading). |
| CV docx / pdf | GET /v1/documents/{id}/cv.docx | /cv.pdf — a professional CV from the extracted facts; refused with 422 cv_check_failed if a fact was lost or added. |
| CV layout | GET /v1/documents/{id}/cv — layout, questions for the candidate, and the fact check. |
pdf and docx exist for CVs today; for other document types they are listed under outputs.unavailable.
Webhooks
Signed webhook endpoints (v2 jobs)
Create an endpoint with POST /v2/webhooks { "url": "https://…", "events": [...] }. The response includes the endpoint’s own signing secret (whsec_…) — shown once. Events fire for documents uploaded to a job (POST /v2/jobs, then POST /v2/jobs/{id}/inputs).
| Event | When |
|---|---|
| job.finished | Every job the machine finished (sent with the specific event). |
| job.completed | Verified. |
| job.action_required + review.required | A person must look (both are sent). |
| input.rescan_required | A photo must be retaken. |
| job.failed | Reading failed. |
| job.reviewed | A person decided. |
POST https://your-server.example/hooks/mahad
Content-Type: application/json
X-Mahad-Event: job.finished
X-Mahad-Delivery: <delivery id>
X-Mahad-Signature: t=1790000000,v1=5f0c…
{"id":"evt_9c1e…","event":"job.finished","data":{"job_id":"…","entity_id":"…",
"decision_id":"…","status":"verified","code":null,"state_version":1},"created":"2026-09-30T08:15:09+00:00"}Reply with any 2xx. Failed deliveries are retried after 30 s, 2 min, 10 min, 30 min and 2 h (6 attempts), then marked dead. See every attempt with GET /v2/webhooks/{id}/deliveries. Destinations on internal networks are refused, and redirects are not followed.
Verify the signature — Python
import hashlib, hmac, time
def verify_mahad_webhook(secret: str, signature_header: str, raw_body: bytes,
tolerance_s: int = 300) -> bool:
"""X-Mahad-Signature: t=<unix>,v1=<hex HMAC-SHA256 of '<t>.<raw body>'>"""
parts = dict(p.split("=", 1) for p in signature_header.split(","))
t, received = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > tolerance_s:
return False # too old: possible replay
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
# Flask example — use the RAW body, not re-serialised JSON
# ok = verify_mahad_webhook(WHSEC, request.headers["X-Mahad-Signature"], request.get_data())Verify the signature — Node
const crypto = require('crypto');
// X-Mahad-Signature: t=<unix>,v1=<hex HMAC-SHA256 of '<t>.<raw body>'>
function verifyMahadWebhook(secret, signatureHeader, rawBody, toleranceS = 300) {
const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceS) return false; // possible replay
const expected = crypto.createHmac('sha256', secret)
.update(`${parts.t}.`).update(rawBody).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(parts.v1 || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: app.post('/hooks/mahad', express.raw({ type: 'application/json' }), (req, res) => {
// if (!verifyMahadWebhook(process.env.MAHAD_WHSEC, req.get('X-Mahad-Signature'), req.body))
// return res.sendStatus(400);
// const event = JSON.parse(req.body); res.sendStatus(200);
// });Dashboard webhook (v1 uploads)
A webhook URL set in the dashboard receives document.processed when a document uploaded with /v1/documents/upload finishes: { "event": "document.processed", "data": { "document_id", "status", "doc_type", … } } with headers X-MahadDoc-Event and X-MahadDoc-Signature: sha256=<hex>. It also carries X-Mahad-Signature: t=<unix>,v1=<hex>, signed exactly like the v2 webhooks above with your account's own secret — get it with GET /v2/webhooks/v1-secret and verify with the same code. The older X-MahadDoc-Signature header is still sent unchanged but is made with a service key you do not hold, so verify X-Mahad-Signature instead.
Errors
Every error has one shape, and every response carries an X-Request-ID header — quote it to support.
{
"error": {
"code": "file_too_large",
"message": "File exceeds the 10 MB limit",
"request_id": "8e1f…",
"next_action": { "type": "SMALLER_FILE", "message": "Send a smaller file." }
}
}| HTTP | code | When |
|---|---|---|
| 401 | unauthorized | Missing or invalid API key or token. |
| 401 | tenant_suspended · account_inactive · session_revoked | The organisation or account cannot be used. |
| 403 | account_verification_required | Verify email and phone first (missing, verify_url tell you what). |
| 403 | account_suspended · subscription_expired · forbidden · insufficient_scope | Not allowed for this account or key. |
| 404 | not_found | Unknown or deleted id. |
| 409 | not_finished · not_a_read_cv · document_deleted · no_source_file · job_completed · idempotency_key_reused | The item is not in a state that allows this. |
| 413 | file_too_large · too_many_files | Over 10 MB, or over 20 files in a batch. |
| 422 | unsupported_file · unknown_output · validation_error · cv_check_failed · unsafe_webhook_url · invalid_events | The input cannot be used. |
| 429 | rate_limit · rate_limited · abuse_block · no_quota · quota_exceeded | Too many requests, or the monthly quota is used up. |
| 500 | internal_error | Unexpected — retry; contact support with the request id. |
| 503 | service_unavailable | Try again in a few minutes. |
Rate limits and quotas
| Limit | Value |
|---|---|
| Requests | 600 per minute per IP address and per API key by default (a plan can set a different limit per key). Over it: 429 rate_limit with Retry-After: 60. |
| Headers | X-RateLimit-Limit and X-RateLimit-Remaining on every response. |
| Repeated auth failures | More than 20 responses of 401/403 from one IP within 5 minutes → 429 abuse_block for a while. |
| Free accounts | 30 uploads per hour (429 rate_limited). |
| Monthly quota | Documents per month by plan (Sandbox: 50). Over it: 429 quota_exceeded. |
| Files | 10 MB each; 20 per batch; PDF first 5 pages; list page_size ≤ 100. |
Examples: curl, Python, Node
curl
curl -X POST https://api.mahadocr.com/v1/documents/upload -H "X-MahadDoc-API-Key: mk_live_YOUR_KEY" -F "[email protected]" -F "outputs=xlsx"
curl https://api.mahadocr.com/v1/documents/DOC_3f9a1c7e2b40 -H "X-MahadDoc-API-Key: mk_live_YOUR_KEY"
curl https://api.mahadocr.com/v1/documents/DOC_3f9a1c7e2b40/outputs/xlsx -H "X-MahadDoc-API-Key: mk_live_YOUR_KEY" -o invoice.xlsxPython
import time, requests
API = "https://api.mahadocr.com/v1"
HEADERS = {"X-MahadDoc-API-Key": "mk_live_YOUR_KEY"} # read from your server's secret store
with open("passport.jpg", "rb") as f:
r = requests.post(f"{API}/documents/upload", headers=HEADERS,
files={"file": f}, data={"doc_type": "passport"})
r.raise_for_status() # 202
doc_id = r.json()["id"]
while True:
doc = requests.get(f"{API}/documents/{doc_id}", headers=HEADERS).json()
if doc["result"]["status"] != "processing":
break
time.sleep(2)
result = doc["result"]
print(result["decision"], result["values"])
for row in result["field_review"]["fields"]:
if row["state"] != "ok":
print("check:", row["label"], row["reason"])Node
// Node 18+ (global fetch, FormData, Blob)
const fs = require('fs');
const API = 'https://api.mahadocr.com/v1';
const HEADERS = { 'X-MahadDoc-API-Key': process.env.MAHAD_API_KEY };
async function read(path) {
const form = new FormData();
form.append('file', new Blob([fs.readFileSync(path)]), 'passport.jpg');
const up = await fetch(`${API}/documents/upload`, { method: 'POST', headers: HEADERS, body: form });
if (up.status !== 202) throw new Error((await up.json()).error.code);
const { id } = await up.json();
for (;;) {
const doc = await (await fetch(`${API}/documents/${id}`, { headers: HEADERS })).json();
if (doc.result.status !== 'processing') return doc.result;
await new Promise((r) => setTimeout(r, 2000));
}
}
read('passport.jpg').then((r) => console.log(r.decision, r.values));Other endpoints
| Endpoint | Purpose |
|---|---|
| POST /v1/capture/check | Is this camera frame proven? MRZ check digits or a QR code; local, no AI, nothing stored; rate limited. |
| POST /v1/documents/{id}/reevaluate | Read the stored original again with today’s readers; the new reading is kept beside the old one (409 when the original was not kept). |
| POST /v1/documents/{id}/improve | Improve a CV’s wording; facts are never changed. |
| GET /v1/documents/{id}/versions | Versions of the stored file. |
| DELETE /v1/documents/{id} | Erase the document: file, versions, fields and results. A minimal deletion record stays. |
| POST /v2/jobs, POST /v2/jobs/{id}/inputs, GET /v2/jobs/{id} | Jobs group several files about one person or case; supports Idempotency-Key. |
Frequently asked questions
What is the base URL?
https://api.mahadocr.com/v1 for documents. Jobs and webhook endpoints are under https://api.mahadocr.com/v2.
How do I send my API key?
Send your API key in the X-MahadDoc-API-Key header. Keys start with mk_live_, are shown once when created and are stored only as a hash. Call the API from your server, never from a browser or mobile app.
Is processing synchronous?
No. Upload returns 202 with a document id; poll GET /v1/documents/{id} until result.status is not processing, or use a webhook.
What files are accepted?
PNG, JPG, PDF and Word (.docx, .doc), up to 10 MB each. The real type is checked from the file’s bytes. A PDF is read up to its first 5 pages.
What are the rate limits?
By default 600 requests per minute per IP address and per API key (a plan can set a different limit per key). Free accounts can upload 30 documents per hour. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining.
Is there interactive documentation?
Yes, generated from the running API at https://api.mahadocr.com/docs.
How do I verify a webhook signature?
Webhook endpoints created with POST /v2/webhooks get their own secret. Each call carries X-Mahad-Signature: t=<unix time>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">. Recompute it with your secret and compare in constant time.