POST /v1/scans

Recognize a document

Runs recognition synchronously on one image and returns the extracted data. The balance is charged only when the scan is billable: the document type was determined and at least one of an MRZ with valid checksums, five or more visual-zone fields, or a decoded barcode was read. Sandbox keys are never charged: sk_sandbox_public recognizes the image it is sent, free, until its allowance runs out, while a registered account's own sk_sandbox_... key is answered from a fixed synthetic specimen. The result is written down only when the retention window resolved for the request is above zero: options.retain_hours when the request names one, otherwise the account's own history-retention setting. Send retain_hours: 0 to have nothing stored at all, so there is nothing to read back later.

The call is synchronous: the recognition runs while the request is open and the extracted data comes back in the same response. There is no job id and no callback.

The image travels inside the JSON body as base64, which is about a third larger than the file on disk. The body ceiling is 36 MiB.

A failure to recognize is not an error. status carries no_document_found, unreadable, unsupported_document or rejected under a 200; the codes below are the cases where no result was produced at all.

Request

Method POST
Path /v1/scans
Authentication Authorization: Bearer <api key>

Headers

Header Type Required Description
idempotency-key string no Caller-chosen key that makes a retried request return the first result instead of running and charging again.

Body fields

Field Type Required Default Description
image string yes The document image, base64-encoded (JPEG or PNG).
options ScanOptions no {}
reference string | null no null Caller's own correlation string, echoed back in the response.

Example request body

{
  "image": "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==",
  "options": {
    "mode": "full",
    "expect_country": null,
    "date_format": "iso",
    "return_portrait": true,
    "retain_hours": 0
  },
  "reference": "order-1042"
}

Responses

200

The scan result. status says how far recognition got.

Body: Scan.

Field Type Description
meta ScanMeta
document ScanDocument | null
holder ScanHolder | null
fields ScanField[] Every field the engine extracted off the printed document, re-keyed to our vocabulary — the open set. Always present; empty when nothing was extracted. A field read in more than one language appears once per language, so name repeats and only id is unique.
mrz ScanMrz
images ScanImages Image crops, returned in the recognition response only. Each is scaled down by height, proportionally and never upwards, to at most 250 px for document_crop and 100 px for every other crop, then re-encoded with every metadata block dropped.
quality ScanQuality
authenticity ScanAuthenticity
{
  "meta": {
    "schema_version": "1.0",
    "id": "01a0af18-cd8d-7a61-9f2d-4c7b8e105da3",
    "status": "recognized",
    "billed": true,
    "confidence": "high",
    "timing": {
      "upload_ms": 198,
      "processing_ms": 812,
      "total_ms": 1024
    },
    "created_at": "2026-09-17T10:15:00Z",
    "reference": "order-1042"
  },
  "document": {
    "kind": "passport",
    "country": "GRC",
    "country_name": "Greece",
    "issuing_state": "GRC",
    "type_name": "Greece - Passport",
    "type_confidence": "high",
    "is_expired": false,
    "days_remaining": 2001
  },
  "holder": {
    "given_names": "ELENI SOFIA",
    "surname": "PARADEIGMA",
    "full_name": "PARADEIGMA ELENI SOFIA",
    "birth_date": "1994-03-08",
    "sex": "F",
    "nationality": "GRC"
  },
  "fields": [
    {
      "id": "surname@0",
      "name": "surname",
      "label": "Surname",
      "category": "identity",
      "value": "PARADEIGMA",
      "language": null,
      "confidence": "high"
    },
    {
      "id": "surname@1032",
      "name": "surname",
      "label": "Surname",
      "category": "identity",
      "value": "ΠΑΡΑΔΕΙΓΜΑ",
      "language": "Greek",
      "confidence": "high"
    },
    {
      "id": "document_number@0",
      "name": "document_number",
      "label": "Document number",
      "category": "document",
      "value": "AM7304518",
      "language": null,
      "confidence": "high"
    },
    {
      "id": "days_to_expire@0",
      "name": "days_to_expire",
      "label": "Days to expire",
      "category": "dates",
      "value": "2001",
      "language": null,
      "confidence": "high"
    },
    {
      "id": "mrz@0",
      "name": "mrz",
      "label": "MRZ",
      "category": "document",
      "value": "P<GRCPARADEIGMA<<ELENI<SOFIA<<<<<<<<<<<<<<<<AM73045184GRC9403084F3203101PN48291630<<<<72",
      "language": null,
      "confidence": "high"
    }
  ],
  "mrz": {
    "status": "passed",
    "reason": null,
    "lines": [
      "P<GRCPARADEIGMA<<ELENI<SOFIA<<<<<<<<<<<<<<<<",
      "AM73045184GRC9403084F3203101PN48291630<<<<72"
    ],
    "text": "P<GRCPARADEIGMA<<ELENI<SOFIA<<<<<<<<<<<<<<<<AM73045184GRC9403084F3203101PN48291630<<<<72"
  },
  "images": {
    "document_crop": "data:image/jpeg;base64,/9j/4AAQSkZJRg==",
    "rear": null,
    "main_photo": "data:image/jpeg;base64,/9j/4AAQSkZJRg==",
    "signature": "data:image/jpeg;base64,/9j/4AAQSkZJRg==",
    "watermark_face": "data:image/jpeg;base64,/9j/4AAQSkZJRg==",
    "barcode": null,
    "chip": null
  },
  "quality": {
    "overall": "pass"
  },
  "authenticity": {
    "overall": "not_checked",
    "checks": []
  }
}

400

The request is malformed: the body is not JSON or a required part is missing.

Body: ErrorResponse.

{
  "error": {
    "code": "invalid_request",
    "message": "Body is not valid JSON.",
    "docs_url": "https://doc.cheap/docs/errors/invalid_request",
    "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642",
    "event_id": null
  }
}

401

Missing or malformed Authorization: Bearer <api key> header, or an unknown key.

Body: ErrorResponse.

{
  "error": {
    "code": "unauthorized",
    "message": "Send your API key as `Authorization: Bearer <key>`.",
    "docs_url": "https://doc.cheap/docs/errors/unauthorized",
    "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642",
    "event_id": null
  }
}

402

The account has no credits left; the scan was not run.

Body: ErrorResponse.

{
  "error": {
    "code": "insufficient_credits",
    "message": "The balance is 0 credits; top up to continue.",
    "docs_url": "https://doc.cheap/docs/errors/insufficient_credits",
    "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642",
    "event_id": null
  }
}

403

The public sandbox key has used up its free recognitions; register for an API key to continue.

Body: ErrorResponse.

{
  "error": {
    "code": "registration_required",
    "message": "The free trial without an account is used up; register for your own API key to keep scanning.",
    "docs_url": "https://doc.cheap/docs/errors/registration_required",
    "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642",
    "event_id": null
  }
}

409

The Idempotency-Key cannot be honoured. idempotency_conflict: it was already used with a different request body. idempotency_in_progress: the first request under this key is still running, so retry in a moment to receive its result. idempotency_replay_unavailable: that request finished, but its result was not stored (the request asked for retain_hours: 0) or its retention window has passed, so it cannot be replayed — send a new key.

Body: ErrorResponse.

{
  "error": {
    "code": "idempotency_conflict",
    "message": "Idempotency-Key 8f3c2a1b-5d4e-4f60-9a7b-3c2d1e0f9a8b was already used with a different body.",
    "docs_url": "https://doc.cheap/docs/errors/idempotency_conflict",
    "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642",
    "event_id": null
  }
}

413

The request body is larger than the endpoint accepts. Send a smaller or more compressed image.

Body: ErrorResponse.

{
  "error": {
    "code": "payload_too_large",
    "message": "The request body is larger than this endpoint accepts.",
    "docs_url": "https://doc.cheap/docs/errors/payload_too_large",
    "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642",
    "event_id": null
  }
}

415

The request body must be sent as Content-Type: application/json.

Body: ErrorResponse.

{
  "error": {
    "code": "unsupported_media_type",
    "message": "Send the request body as `Content-Type: application/json`.",
    "docs_url": "https://doc.cheap/docs/errors/unsupported_media_type",
    "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642",
    "event_id": null
  }
}

422

The body is well-formed JSON but does not satisfy the request schema.

Body: ErrorResponse.

{
  "error": {
    "code": "validation_failed",
    "message": "/options/mode: Invalid option: expected one of \"full\"",
    "docs_url": "https://doc.cheap/docs/errors/validation_failed",
    "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642",
    "event_id": null
  }
}

429

Rate limit exceeded for this key or IP.

Body: ErrorResponse.

{
  "error": {
    "code": "rate_limited",
    "message": "Sandbox limit of 10 requests per hour per IP reached.",
    "docs_url": "https://doc.cheap/docs/errors/rate_limited",
    "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642",
    "event_id": null
  }
}

500

Unexpected failure on the server; the scan was not charged.

Body: ErrorResponse.

{
  "error": {
    "code": "internal_error",
    "message": "Unexpected error.",
    "docs_url": "https://doc.cheap/docs/errors/internal_error",
    "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642",
    "event_id": null
  }
}

503

Temporarily unable to serve the request; nothing was charged and it may be retried, after the Retry-After seconds where that header is present. engine_unavailable: the recognition engine did not answer within its timeout. service_unavailable: a store this service depends on is unreachable, so the request cannot be served now.

Body: ErrorResponse.

{
  "error": {
    "code": "engine_unavailable",
    "message": "The recognition engine did not answer in time; nothing was charged. Retry shortly.",
    "docs_url": "https://doc.cheap/docs/errors/engine_unavailable",
    "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642",
    "event_id": null
  }
}

Error codes reachable here

Code When Page
engine_unavailable The recognition engine did not answer within its timeout. Nothing was charged. engine_unavailable
idempotency_conflict The Idempotency-Key was used before with a different request body. idempotency_conflict
idempotency_in_progress The first request under this Idempotency-Key is still running. idempotency_in_progress
idempotency_replay_unavailable The key's first result was not retained, so there is nothing left to replay. idempotency_replay_unavailable
insufficient_credits The balance cannot cover one recognition. Checked before the engine. insufficient_credits
internal_error The service failed in a way it does not model. Nothing was charged. internal_error
invalid_request The body is not readable as JSON, or the request was refused before it. invalid_request
payload_too_large The body is over 36 MiB. Refused before it is read into memory. payload_too_large
rate_limited The caller is over the rate limit for its key kind. rate_limited
registration_required The public sandbox key's lifetime free allowance is used up. registration_required
service_unavailable A dependency the service needs is unreachable. Nothing was charged. service_unavailable
unauthorized The Authorization header is missing, malformed or names an unknown key. unauthorized
unsupported_media_type The body was sent as something other than application/json. unsupported_media_type
validation_failed A field or option failed the schema — a wrong type, an out-of-range value, an unknown option key. validation_failed

Every error body carries the same shape; the full list is on Errors.