API Reference

Misfora OCR API

Extract passport and ID card data from a single image — MRZ-based extraction with field-level validation and a signal for when a human should review the result. Synchronous JSON API, no SDK required.

Base URL: https://misfora.com/v1 Interactive reference (Swagger UI) ↗ ← Back to API overview

What this API does

Not a pricing page

This document is the technical reference for integrators who already have (or are evaluating) API access. For pricing and enterprise agreements, contact us directly — this page intentionally doesn't cover commercial terms. See the API overview for access options.

On this page
  1. Quick start
  2. Authentication
  3. Request IDs
  4. Rate limits and quotas
  5. POST /v1/scan
  6. GET /v1/usage
  7. Errors
  8. Security
  9. Getting an API key
  10. Versioning

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_.

SituationResponse
Header missing, malformed, or key not recognized401 INVALID_API_KEY
Key is valid but the account has been suspended403 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.

LimitScopeOn exceeding
Requests / minuteBurst rate429 RATE_LIMIT_EXCEEDED
Requests / dayDaily volume429 RATE_LIMIT_EXCEEDED
Monthly quotaBilling period volume403 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.

ConstraintValue
Content types acceptedimage/jpeg, image/png, image/heic
Max file size10 MB
Max resolution13 megapixels
Processing timeout20 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.

Photos don't need to be perfectly framed

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.

FieldTypeDescription
type"passport" | "id_card"Document type, inferred from the MRZ format. The only required field.
issuing_countryISO 3166-1 alpha-3 or nullIssuing country, from the MRZ.
document_numberstring or nullDocument number.
expiry_datedate (YYYY-MM-DD) or nullExpiry date as read from the MRZ.
document_expiredbool or nullWhether the document is expired as of now (expiry_date < today).
first_namestring or nullGiven name.
last_namestring or nullSurname.
birth_datedate or nullDate of birth.
nationalityISO 3166-1 alpha-3 or nullNationality, from the MRZ.
gender"M" | "F" | nullGender, from the MRZ.
ICAO terminology

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.

FieldWhat it checks
first_name, last_nameRecognized and passed the name-confidence check.
document_numberMRZ check digit for the document number is correct.
birth_dateMRZ check digit for the date of birth is correct.
expiry_dateMRZ check digit for the expiry date is correct.
genderValue is one of the allowed values (M/F).
nationality, issuing_countryCountry code is a legitimate MRZ code.
validation.expiry_date ≠ document.document_expired

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 codeMeaning
DOCUMENT_EXPIREDThe document's validity period has passed.
WRONG_DOCUMENT_TYPE_SUSPECTEDThe recognized document doesn't look like the expected type (a heuristic from the recognition pipeline).

meta

FieldDescription
processing_time_msTotal 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_versionResponse 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):

ParameterDefaultDescription
group_bydayday or week. Anything else → 400 INVALID_REQUEST.
tzUTCTimezone used for day/week bucketing (e.g. Europe/Istanbul). Invalid value → 400 INVALID_REQUEST.
date_fromStart of the range, YYYY-MM-DD, inclusive. Invalid value → 400 INVALID_REQUEST.
date_toEnd 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:

FieldWhat it counts
total_requestsEvery /v1/scan request in the bucket — successful, failed, or rejected.
successful_requestsRequests that returned a successful scan.
failed_requestsRequests that reached the OCR pipeline but did not return a successful scan (DOCUMENT_NOT_DETECTED, OCR_ENGINE_ERROR, PROCESSING_TIMEOUT, INVALID_IMAGE).
rejected_requestsRequests that never reached the OCR pipeline at all — bad key, rate limit, monthly quota, unsupported file type, file too large.
quota_usedOf the requests above, how many actually counted against your monthly quota — see below.
quota_used is not the same as failed_requests + rejected_requests

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 datadate 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.codeHTTPWhen it happensCounts against quotaRetry?
INVALID_API_KEY401Key missing, malformed, or not recognized.NoDon't retry — fix the key.
ACCOUNT_SUSPENDED403Key is valid but the account is suspended.NoDon't retry — contact us.
RATE_LIMIT_EXCEEDED429Minute or day limit exceeded.NoRetry after a short delay.
MONTHLY_QUOTA_EXCEEDED403Monthly quota used up.NoDon't retry until the quota renews — contact us.
UNSUPPORTED_FILE_TYPE400File type isn't jpeg/png/heic.NoDon't retry — fix the upload.
IMAGE_TOO_LARGE413File over 10 MB or over 13 MP.NoDon't retry — resize/compress and resend.
INVALID_IMAGE422File couldn't be decoded as an image.NoDon't retry — fix the file.
DOCUMENT_NOT_DETECTED422No document found in the photo.YesDon't blind-retry — ask the user for a clearer photo.
OCR_ENGINE_ERROR502The OCR engine failed while processing.YesRetry once or twice with backoff.
PROCESSING_TIMEOUT503Processing didn't finish within 20 seconds.YesRetry with exponential backoff — this error is billable, so avoid unlimited retries.
PROCESSING_ERROR500Unexpected internal error.NoRetry once; if it persists, contact us with the request_id.
INVALID_REQUEST400GET /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 Authorization header, 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/scan exist 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/scan never 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.