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 fieldRequiredMeaning
fileyesPNG, 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_typenoA type hint such as passport, id_card, qid, visa, invoice, receipt, cv. Leave it out and the type is detected.
outputsnoComma-separated extra outputs: pdf, docx, xlsx (json is always there). An unknown value is refused with 422 unknown_output.
improvenotrue to improve a CV’s wording after reading (never its facts).
consentnoA 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

KeyMeaning
statusThe document’s status: processing, verified, review, failed (also flagged, rejected after a person’s action).
verifiedtrue only when the document status is verified.
decisionverified · review · rescan · failed · processing — the machine decision under your organisation’s verification policy. rescan means the photo must be retaken.
document_typeWhat the readers saw: passport, qid, visa, cv, invoice …
valuesThe fields as flat key → value. A disputed value is never here.
field_statusPer field: read · unreadable · not_present · conflict.
field_evidencePer 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_fieldsFields the readers disagreed on — ask a person.
review_reason, review_reasonsWhy 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).
fieldsPer 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_countryWhere read.
outputsLinks 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_improvedCVs: the full structure; improved wording when requested.
timings_ms, engines, readers, cost_usdProcessing 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:

CodeMeaning
QUALITY_GATE_GLARE / _BLUR / _CUTOFF / _LOW_RESOLUTION / _DARK / _UNREADABLERetake the photo (rescan_required).
DOCUMENT_UNREADABLEThe file could not be opened (failed).
PROCESSING_PROVIDER_FAILUREThe reading service was unavailable after every fallback (failed).
MISSING_PAGEPages were not processed (for example a PDF beyond 5 pages).
UNSUPPORTED_LANGUAGE_SCRIPTThe script is not available on the active reading route.
FIELD_CONFLICT / FIELD_UNCONFIRMEDReaders disagree / a key field rests on one reading.
CRITICAL_FIELD_INVALIDA key field failed a consistency check.
REQUIRED_FIELD_MISSINGA required field is empty.
DATE_NEEDS_REVIEWA date is impossible or not Gregorian.
DOCUMENT_EXPIREDThe document has expired.
ARITHMETIC_MISMATCHInvoice amounts do not add up.
DUPLICATE_SUSPECTEDLooks like an invoice already recorded by your organisation.
TAMPER_SUSPECTEDSigns of image editing were found (document status flagged).
NO_FIELDS_EXTRACTED / LOW_CONFIDENCE / DOCUMENT_TYPE_UNKNOWNNothing 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:

OutputEndpoint
xlsxGET /v1/documents/{id}/outputs/xlsx — fields and line items (409 not_finished while reading).
CV docx / pdfGET /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 layoutGET /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).

EventWhen
job.finishedEvery job the machine finished (sent with the specific event).
job.completedVerified.
job.action_required + review.requiredA person must look (both are sent).
input.rescan_requiredA photo must be retaken.
job.failedReading failed.
job.reviewedA 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." }
  }
}
HTTPcodeWhen
401unauthorizedMissing or invalid API key or token.
401tenant_suspended · account_inactive · session_revokedThe organisation or account cannot be used.
403account_verification_requiredVerify email and phone first (missing, verify_url tell you what).
403account_suspended · subscription_expired · forbidden · insufficient_scopeNot allowed for this account or key.
404not_foundUnknown or deleted id.
409not_finished · not_a_read_cv · document_deleted · no_source_file · job_completed · idempotency_key_reusedThe item is not in a state that allows this.
413file_too_large · too_many_filesOver 10 MB, or over 20 files in a batch.
422unsupported_file · unknown_output · validation_error · cv_check_failed · unsafe_webhook_url · invalid_eventsThe input cannot be used.
429rate_limit · rate_limited · abuse_block · no_quota · quota_exceededToo many requests, or the monthly quota is used up.
500internal_errorUnexpected — retry; contact support with the request id.
503service_unavailableTry again in a few minutes.

Rate limits and quotas

LimitValue
Requests600 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.
HeadersX-RateLimit-Limit and X-RateLimit-Remaining on every response.
Repeated auth failuresMore than 20 responses of 401/403 from one IP within 5 minutes → 429 abuse_block for a while.
Free accounts30 uploads per hour (429 rate_limited).
Monthly quotaDocuments per month by plan (Sandbox: 50). Over it: 429 quota_exceeded.
Files10 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.xlsx

Python

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

EndpointPurpose
POST /v1/capture/checkIs this camera frame proven? MRZ check digits or a QR code; local, no AI, nothing stored; rate limited.
POST /v1/documents/{id}/reevaluateRead 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}/improveImprove a CV’s wording; facts are never changed.
GET /v1/documents/{id}/versionsVersions 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.

Related