# 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

```json
{
  "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 |  |

```json
{
  "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`.

```json
{
  "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`.

```json
{
  "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`.

```json
{
  "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`.

```json
{
  "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`.

```json
{
  "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`.

```json
{
  "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`.

```json
{
  "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`.

```json
{
  "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`.

```json
{
  "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`.

```json
{
  "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`.

```json
{
  "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](/errors/engine_unavailable) |
| `idempotency_conflict` | The `Idempotency-Key` was used before with a different request body. | [idempotency_conflict](/errors/idempotency_conflict) |
| `idempotency_in_progress` | The first request under this `Idempotency-Key` is still running. | [idempotency_in_progress](/errors/idempotency_in_progress) |
| `idempotency_replay_unavailable` | The key's first result was not retained, so there is nothing left to replay. | [idempotency_replay_unavailable](/errors/idempotency_replay_unavailable) |
| `insufficient_credits` | The balance cannot cover one recognition. Checked before the engine. | [insufficient_credits](/errors/insufficient_credits) |
| `internal_error` | The service failed in a way it does not model. Nothing was charged. | [internal_error](/errors/internal_error) |
| `invalid_request` | The body is not readable as JSON, or the request was refused before it. | [invalid_request](/errors/invalid_request) |
| `payload_too_large` | The body is over 36 MiB. Refused before it is read into memory. | [payload_too_large](/errors/payload_too_large) |
| `rate_limited` | The caller is over the rate limit for its key kind. | [rate_limited](/errors/rate_limited) |
| `registration_required` | The public sandbox key's lifetime free allowance is used up. | [registration_required](/errors/registration_required) |
| `service_unavailable` | A dependency the service needs is unreachable. Nothing was charged. | [service_unavailable](/errors/service_unavailable) |
| `unauthorized` | The `Authorization` header is missing, malformed or names an unknown key. | [unauthorized](/errors/unauthorized) |
| `unsupported_media_type` | The body was sent as something other than `application/json`. | [unsupported_media_type](/errors/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](/errors/validation_failed) |

Every error body carries the same shape; the full list is on [Errors](/reference/errors).
