Quick start
cURL
curl https://misfora.com/v1/scan \ -H "Authorization: Bearer msf_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \ -F "file=@passport.jpg"
Response
{
"success": true,
"request_id": "3f9a2b7e-1c4d-4e8a-9b2f-6a7d0c5e8f1a",
"document": {
"type": "passport",
"issuing_country": "TUR",
"document_number": "U12345678",
"expiry_date": "2029-05-14",
"document_expired": false,
"first_name": "AHMET",
"last_name": "YILMAZ",
"birth_date": "1990-03-22",
"nationality": "TUR",
"gender": "M"
},
"validation": {
"first_name": true,
"last_name": true,
"document_number": true,
"birth_date": true,
"expiry_date": true,
"gender": true,
"nationality": true,
"issuing_country": true
},
"review_recommended": false,
"warnings": [],
"meta": { "processing_time_ms": 1022, "api_version": "1.0.0" }
}
That's it — no job creation, polling, or webhook is required. The response above is the complete result of the scan.
Python
import requests
response = requests.post(
"https://misfora.com/v1/scan",
headers={"Authorization": "Bearer msf_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"},
files={"file": open("passport.jpg", "rb")},
)
response.raise_for_status()
print(response.json())
Node.js
const fs = require("fs");
const form = new FormData();
form.append("file", fs.readFileSync("passport.jpg"), "passport.jpg");
const res = await fetch("https://misfora.com/v1/scan", {
method: "POST",
headers: { Authorization: "Bearer msf_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" },
body: form,
});
console.log(await res.json());
Authentication
Every request carries your API key as a bearer token:
Authorization: Bearer <your_api_key>
Keys are provisioned per account (see Getting an API key) and always start with msf_.
| Situation | Response |
|---|---|
| Header missing, malformed, or key not recognized | 401 INVALID_API_KEY |
| Key is valid but the account has been suspended | 403 ACCOUNT_SUSPENDED |
Both "no key" and "wrong key" return the same generic message — this is deliberate, so the response can't be used to probe for valid key prefixes.
Request IDs
Every response — success or error — includes a request ID, both as a response header and in the JSON body:
X-Request-Id: 3f9a2b7e-1c4d-4e8a-9b2f-6a7d0c5e8f1a
{ "request_id": "3f9a2b7e-1c4d-4e8a-9b2f-6a7d0c5e8f1a", "...": "..." }
The two values are always identical. Include this ID when contacting Misfora support about a specific request — it's the fastest way for us to find it in our logs.
Rate limits and quotas
Three independent limits apply to each API key. Default limits are configured per account and agreed with you when access is provisioned — contact us to discuss the right limits and volume for your use case.
| Limit | Scope | On exceeding |
|---|---|---|
| Requests / minute | Burst rate | 429 RATE_LIMIT_EXCEEDED |
| Requests / day | Daily volume | 429 RATE_LIMIT_EXCEEDED |
| Monthly quota | Billing period volume | 403 MONTHLY_QUOTA_EXCEEDED |
The minute and day limits use fixed UTC windows (not a sliding window): the minute counter resets at the start of each UTC minute, the day counter at the start of each UTC day. The monthly quota is a fixed ceiling on requests consumed since the start of your current billing period; when the period is renewed on our side, the usage counter resets to zero — the quota itself doesn't change automatically.
What counts against your monthly quota
A request is billable once it's accepted into the processing pipeline and assigned a definitive processing outcome — a successful scan, "no document detected," an OCR engine failure, or a processing timeout. Requests that never reach the pipeline at all (bad key, rate limit exceeded, monthly quota already exceeded, unsupported file type, file too large) never count. One exception to the pipeline rule: an unreadable file (INVALID_IMAGE) does reach the pipeline but isn't billable, since the file can't be decoded and no OCR ever runs on it. See the full breakdown below.
Current quota balance
GET /v1/usage (below) breaks down your historical request counts by outcome, including a quota_used figure per period — but it isn't a live "how much do I have left right now" balance. Contact us if you need programmatic quota-remaining monitoring; in the meantime, the 429/403 responses themselves are the authoritative signal that a limit has been reached.
POST /v1/scan
Uploads a photo or scan of a document and synchronously returns the extracted fields.
Request: multipart/form-data, a single field named file.
| Constraint | Value |
|---|---|
| Content types accepted | image/jpeg, image/png, image/heic |
| Max file size | 10 MB |
| Max resolution | 13 megapixels |
| Processing timeout | 20 seconds |
There's no document_type field in the request — the document type (passport / id_card) is determined entirely by the format of the MRZ found in the image.
Before OCR runs, Misfora automatically detects the document in the frame and corrects for rotation and perspective — including photos taken at an angle, sideways, or upside down. A well-lit, straight-on photo still recognizes more reliably, but this correction step is a safety net for real-world photos, not a strict requirement: the API attempts to read whatever quality of image it's given.
Response fields — document
All fields except type are optional: if one field couldn't be read (for example, expiry_date), the rest of the response is still assembled rather than failing the whole request.
| Field | Type | Description |
|---|---|---|
type | "passport" | "id_card" | Document type, inferred from the MRZ format. The only required field. |
issuing_country | ISO 3166-1 alpha-3 or null | Issuing country, from the MRZ. |
document_number | string or null | Document number. |
expiry_date | date (YYYY-MM-DD) or null | Expiry date as read from the MRZ. |
document_expired | bool or null | Whether the document is expired as of now (expiry_date < today). |
first_name | string or null | Given name. |
last_name | string or null | Surname. |
birth_date | date or null | Date of birth. |
nationality | ISO 3166-1 alpha-3 or null | Nationality, from the MRZ. |
gender | "M" | "F" | null | Gender, from the MRZ. |
If you're used to ICAO MRZ terminology: "passport" corresponds to the 2-line TD3 format, "id_card" to the 3-line TD1 format. The API always uses the plain passport / id_card values above — TD1/TD3 never appear in a response.
Raw MRZ lines are intentionally not included in the response — only parsed, validated fields.
Response fields — validation
Boolean flags for whether a specific check passed (ICAO MRZ checksums / lookup tables of legitimate values / a name-confidence threshold) — not a numeric confidence score.
| Field | What it checks |
|---|---|
first_name, last_name | Recognized and passed the name-confidence check. |
document_number | MRZ check digit for the document number is correct. |
birth_date | MRZ check digit for the date of birth is correct. |
expiry_date | MRZ check digit for the expiry date is correct. |
gender | Value is one of the allowed values (M/F). |
nationality, issuing_country | Country code is a legitimate MRZ code. |
validation.expiry_date tells you whether the MRZ check digit for the expiry date is correct — a data-quality signal about the OCR result. document.document_expired tells you whether the extracted expiry date has already passed — a real-world fact about the document. A date can be read perfectly (validation.expiry_date: true) and the document can still be expired (document.document_expired: true). Don't use one in place of the other.
A false value doesn't mean the underlying field is empty — document.first_name can be non-null while validation.first_name is false, if the confidence check simply didn't pass.
review_recommended and warnings
review_recommended: true is a recommendation, not a rejection — the API never calls a result "verified" or "invalid." It's set to true when the document is expired, OR the wrong document type is suspected, OR at least one validation.* check failed.
warnings code | Meaning |
|---|---|
DOCUMENT_EXPIRED | The document's validity period has passed. |
WRONG_DOCUMENT_TYPE_SUSPECTED | The recognized document doesn't look like the expected type (a heuristic from the recognition pipeline). |
meta
| Field | Description |
|---|---|
processing_time_ms | Total time spent handling the request, in milliseconds. For a rejected or failed request this includes validation/rejection time, not only OCR — e.g. a RATE_LIMIT_EXCEEDED response never reaches the OCR engine at all, but still reports the time spent up to that point. In our own testing, a successful scan typically falls between 600ms and 2500ms, with a median around 1100ms — heavily dependent on image size and quality. |
api_version | Response contract version — currently always "1.0.0". |
GET /v1/usage
Returns your own /v1/scan usage, aggregated by day or week. This is a history endpoint — see Current quota balance above for what it does not do. Your company is determined by the API key itself; there's no company_id parameter, so you can't request another account's data even in principle. Calls to this endpoint don't count against your minute/day rate limits and aren't billed.
Query parameters (all optional):
| Parameter | Default | Description |
|---|---|---|
group_by | day | day or week. Anything else → 400 INVALID_REQUEST. |
tz | UTC | Timezone used for day/week bucketing (e.g. Europe/Istanbul). Invalid value → 400 INVALID_REQUEST. |
date_from | — | Start of the range, YYYY-MM-DD, inclusive. Invalid value → 400 INVALID_REQUEST. |
date_to | — | End of the range, YYYY-MM-DD, inclusive. Invalid value → 400 INVALID_REQUEST. |
Request
curl "https://misfora.com/v1/usage?group_by=day&tz=Europe/Istanbul&date_from=2026-09-01&date_to=2026-09-09" \ -H "Authorization: Bearer msf_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
Response
{
"group_by": "day",
"period": { "from": "2026-09-01", "to": "2026-09-09", "timezone": "Europe/Istanbul" },
"total_requests": 12450,
"successful_requests": 12080,
"failed_requests": 320,
"rejected_requests": 50,
"quota_used": 12400,
"data": [
{
"date": "2026-09-08", "total_requests": 1540, "successful_requests": 1497,
"failed_requests": 38, "rejected_requests": 5, "quota_used": 1535
},
{
"date": "2026-09-09", "total_requests": 1290, "successful_requests": 1256,
"failed_requests": 30, "rejected_requests": 4, "quota_used": 1286
}
]
}
Four counts are broken out, and they measure different things — don't collapse them into each other:
| Field | What it counts |
|---|---|
total_requests | Every /v1/scan request in the bucket — successful, failed, or rejected. |
successful_requests | Requests that returned a successful scan. |
failed_requests | Requests that reached the OCR pipeline but did not return a successful scan (DOCUMENT_NOT_DETECTED, OCR_ENGINE_ERROR, PROCESSING_TIMEOUT, INVALID_IMAGE). |
rejected_requests | Requests that never reached the OCR pipeline at all — bad key, rate limit, monthly quota, unsupported file type, file too large. |
quota_used | Of the requests above, how many actually counted against your monthly quota — see below. |
It's a separate axis, not derivable from the other three by addition. The one asymmetry to know: INVALID_IMAGE counts toward failed_requests (the request did reach the pipeline, as an OCR-side outcome) but is not billable — the file couldn't even be decoded as an image, so no OCR ever ran and no value was delivered. Everything else that's billable is exactly the set in the error table below marked "Counts against quota: Yes", plus every successful scan.
Every bucket in data carries all five numbers, and the five top-level fields are simply the sum of that bucket-level field across every entry in data — date in each bucket is the bucket's start date (YYYY-MM-DD) in the requested timezone.
Errors
Every error uses the same JSON envelope as a success response, not a bare {"detail": "..."}:
{
"success": false,
"request_id": "3f9a2b7e-1c4d-4e8a-9b2f-6a7d0c5e8f1a",
"error": {
"code": "DOCUMENT_NOT_DETECTED",
"message": "No document detected in this photo"
},
"meta": { "processing_time_ms": 940, "api_version": "1.0.0" }
}
Match on error.code in your integration, not error.message — the message text may change; the code is stable.
| error.code | HTTP | When it happens | Counts against quota | Retry? |
|---|---|---|---|---|
INVALID_API_KEY | 401 | Key missing, malformed, or not recognized. | No | Don't retry — fix the key. |
ACCOUNT_SUSPENDED | 403 | Key is valid but the account is suspended. | No | Don't retry — contact us. |
RATE_LIMIT_EXCEEDED | 429 | Minute or day limit exceeded. | No | Retry after a short delay. |
MONTHLY_QUOTA_EXCEEDED | 403 | Monthly quota used up. | No | Don't retry until the quota renews — contact us. |
UNSUPPORTED_FILE_TYPE | 400 | File type isn't jpeg/png/heic. | No | Don't retry — fix the upload. |
IMAGE_TOO_LARGE | 413 | File over 10 MB or over 13 MP. | No | Don't retry — resize/compress and resend. |
INVALID_IMAGE | 422 | File couldn't be decoded as an image. | No | Don't retry — fix the file. |
DOCUMENT_NOT_DETECTED | 422 | No document found in the photo. | Yes | Don't blind-retry — ask the user for a clearer photo. |
OCR_ENGINE_ERROR | 502 | The OCR engine failed while processing. | Yes | Retry once or twice with backoff. |
PROCESSING_TIMEOUT | 503 | Processing didn't finish within 20 seconds. | Yes | Retry with exponential backoff — this error is billable, so avoid unlimited retries. |
PROCESSING_ERROR | 500 | Unexpected internal error. | No | Retry once; if it persists, contact us with the request_id. |
INVALID_REQUEST | 400 | GET /v1/usage only — invalid group_by/tz/date_from/date_to. | Not applicable (this endpoint isn't billed at all) | Don't retry — fix the parameter. |
DOCUMENT_NOT_DETECTED, OCR_ENGINE_ERROR, and PROCESSING_TIMEOUT are billable because the request reached the pipeline and OCR was actually attempted. INVALID_IMAGE also reaches the pipeline but isn't billable — see the quota callout above.
Security
- HTTPS is required for every request.
- The API key is sent via the
Authorizationheader, never as a query parameter or in the request body. - Your key is shown exactly once, at the moment it's generated — we don't store it in a recoverable form and can't display it again. If you lose it, you'll need a replacement (see below).
- Responses never include raw MRZ lines — only parsed, validated fields.
- Uploaded images for
/v1/scanexist only for the duration of the request and are discarded once processing finishes — including any temporary file the OCR engine uses internally while reading the image, which is deleted before the response is returned. Nothing is written to a database or cloud storage by the API — the same recognition pipeline used by our guest check-in product can optionally persist a document image, but/v1/scannever makes that call. - The extracted fields returned in the response are not retained by Misfora beyond the request itself. What we do keep, for billing and operational purposes, is lightweight usage metadata per request (document type, nationality, processing time, whether review was recommended) — not the name, document number, or date of birth.
- The OCR engine runs on fixed, pre-trained models; your images and extracted data are not used to train or fine-tune any model.
We don't yet have a formal, published data-retention/DPA-level policy document for this API — if that's a requirement for your evaluation (it often is for KYC/identity use cases), contact us and we'll work through it with you directly rather than have you rely on this page for it.
Getting an API key
Access is provisioned manually on our side — contact us to agree on limits and terms. Once provisioned, a one-time setup link is emailed to your contact address; opening it shows your key exactly once.
Need higher volume than the standard limits, or a response shape tailored to your own system? Mention it when you contact us — this is handled as part of onboarding, not something you configure yourself.
If you need a replacement key (lost the original, suspect it's compromised, or just want to rotate it), contact us for a new setup link. Generating a new key immediately and permanently invalidates the previous one — there's no overlap window once you complete that step. Until you do, your current key keeps working normally.
Versioning
The current contract version is 1.0.0 (see meta.api_version in every response). The /v1 path segment is the API version; backward-compatible additions within v1 (new optional fields, new warning codes) may ship without a version bump. Any breaking change ships as a new version path (/v2), never as a silent change to /v1.