> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sahlfinancial.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Read documents

> POST /v1/kyc/extract: 1 to 5 files in, fields, per-document checks and a case out.

`POST /v1/kyc/extract` reads the files of one upload step and returns the fields it found, the checks that belong to each document, and, with a `reference`, a case id and document ids. Scope: `kyc:extract`. It is the only call that uses the vision model, so it is the only one that counts against your monthly read allowance.

## Request

Multipart form (`multipart/form-data`). Only `files` is required.

| Field | Type | Default | Rule |
| - | - | - | - |
| `files` | file, repeated | none | 1 to 5 files. JPEG, PNG, WebP, TIFF or PDF. |
| `doc_type` | string | none | Hint to the reader about which document this is, for example `payslip` or `national_id`. Applies to every file in the call. |
| `step_key` | string | none | Stable name of your upload step, for example `photo_id`. Decides which document types the step accepts. See [Document types and fields](/guides/document-types#step-keys). |
| `reference` | string | none | Your id for the client, 1 to 64 characters of `A-Z a-z 0-9 _ . : -`. Files the result on a case. |
| `environment` | string | `sandbox` | `sandbox` or `production`. Anything else is a 422. |
| `subject` | string | none | Client name for the case, up to 255 characters. |
| `kind` | string | none | `individual`, `corporation`, `partnership`, `charitable_org`, `trust`, `estate`, or an alias. Picks the individual or entity thresholds for the document checks. Unknown values count as `individual`. |

Send one document type per call. `doc_type` and `step_key` apply to all the files in the call, and a front and a back of the same card is the normal case for two files.

## File rules

| Rule | Value | Where it comes from |
| - | - | - |
| Files per call | 1 to 5 | Fixed in the router. Otherwise 400 `Send between 1 and 5 files.` |
| Formats | `image/jpeg`, `image/png`, `image/webp`, `image/tiff`, `application/pdf` | Upload validator. `image/jpg` is accepted as JPEG. |
| Size per file | 30 MB by default | Setting `MAX_UPLOAD_MB`. Over it: 413 `File too large. Maximum allowed size is 30 MB.` |
| Image size | 50 megapixels (7000 x 7000) by default | Read from the image header, so a small file can be refused. 413. |
| Content check | The first bytes must match the declared type | Otherwise 400 `File content does not match declared MIME type.` A PDF may have up to 1 KB of preamble before `%PDF-`. |
| PDF pages | No page limit is applied on this endpoint | The page-count check is not called by `/extract`. |

The content type comes from your multipart part header, not from the file name. Send the right `Content-Type` for each file part. Most HTTP libraries and `curl -F` set it from the extension.

## What happens to a call

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant App as Your server
    participant API as Sahl Partner API
    participant Reader as Vision model
    App->>API: POST /v1/kyc/extract
    API->>API: validate reference, environment, subject, file count
    API->>API: validate each file (size, type, first bytes, pixels)
    API->>API: load the workspace policy
    API->>API: reserve N reads for the month (429 if over)
    API->>Reader: one read per file
    Reader-->>API: JSON of fields and a document type
    API->>API: normalise values, run document checks
    API-->>App: 200 fields, documents, checks
```

Order matters in two places. Validation errors (400, 413, 422) happen before the read is reserved, so they cost nothing. The reservation happens before the model runs, so a read that then fails still counts.

## Response

```json theme={null}
{
  "fields": {
    "first_name": "Test",
    "last_name": "Client",
    "document_holder_name": "Test Client",
    "employer_name": "Test Employer SARL",
    "occupation": "Analyst",
    "document_date": "2026-09-30"
  },
  "documents": [
    {
      "filename": "payslip-test.pdf",
      "doc_type": "payslip",
      "step_hint": "payslip",
      "step_key": null,
      "fields": {
        "first_name": "Test",
        "last_name": "Client",
        "document_holder_name": "Test Client",
        "employer_name": "Test Employer SARL",
        "occupation": "Analyst",
        "document_date": "2026-09-30"
      },
      "meta_created": "2026-10-01",
      "meta_provenance": { "producer": "Example Payroll 4.2", "revisions": 1 },
      "mapped": 6,
      "notes": [],
      "document_id": "22222222-2222-4222-8222-222222222222"
    }
  ],
  "field_count": 6,
  "checks": [],
  "reader_unavailable": false,
  "policy": { "id": null, "version": 0, "source": "legacy", "regime": "none", "regulator": null, "purpose": "onboarding", "overrides_refused": [] },
  "case_id": "00000000-0000-4000-8000-000000000001",
  "document_ids": ["22222222-2222-4222-8222-222222222222"]
}
```

The values above are fake. Which fields appear depends on the file you send.

### Top-level keys

| Key | Type | Meaning |
| - | - | - |
| `fields` | object of strings | All fields of the call, merged. For a key found in several files the first non-empty value wins, in the order of `files`. Send the strongest identity document first. |
| `documents` | array | One entry per file, in the order you sent them. |
| `field_count` | integer | Number of keys in `fields`. |
| `checks` | array | The per-document checks of every file, in one list. |
| `reader_unavailable` | boolean | True when at least one file was never read. |
| `policy` | object | Which workspace policy the call ran under. |
| `case_id` | uuid | Only with a `reference`. |
| `document_ids` | array of uuid | Only with a `reference`. Same order as `documents`. |

### Keys of each `documents[]` entry

| Key | Type | Meaning |
| - | - | - |
| `filename` | string | The name you sent. |
| `doc_type` | string or null | What the reader says the document is. See [the list](/guides/document-types#document-types-the-reader-returns). Null when it could not name it. |
| `step_hint` | string or null | The `doc_type` you sent. |
| `step_key` | string or null | The `step_key` you sent. |
| `fields` | object | The fields read from this file only. |
| `meta_created` | string or null | Creation date of the file, `YYYY-MM-DD`: the PDF `CreationDate`, or the EXIF date of an image. |
| `meta_provenance` | object | What the file says about how it was made. Empty when nothing is known. |
| `mapped` | integer | Number of fields read from this file. |
| `notes` | array of strings | Corrections the server made to the reader's answer, with the reason. |
| `document_id` | uuid | Only with a `reference`. |

`meta_provenance` keys: for a PDF, `producer` and `creator` (the software, up to 200 characters), `modified` (the `ModDate` as `YYYY-MM-DD`, when it differs from the creation date), `revisions` (how many times the file was saved incrementally). For an image, `creator` (the EXIF Software tag) and `camera` (the EXIF Make tag). A missing EXIF block is not reported, because WhatsApp and most browsers strip it.

### Field values

Every value in `fields` is a string. The reader is told to omit a field it cannot read, so a missing key means "not read", never "empty". The server then normalises:

| Kind | Rule |
| - | - |
| Dates (`date_of_birth`, `id_expiry`, `incorporation_date`, `document_date`) | `YYYY-MM-DD`. Day-first dates (`DD/MM/YYYY`) and Eastern Arabic digits are converted. A Hijri date (year 1343 to 1500, or marked `AH`) is converted to Gregorian. If it cannot be converted, the text is returned as printed. |
| `province`, `id_province` | Two-letter code, for example `QC`, `ON`, `NY`. |
| `country`, `citizenship` | Two-letter ISO code, for example `MA`, `CA`, `US`. (`id_country` is a two-letter code the reader is asked for; the server does not rewrite it.) |
| Numbers (`sin`, `ssn`, `bank_number`, `bank_transit`, `bank_routing`, `bank_account`, `business_number`, `ice`, `if_number`, `iban`) | Digits and letters only, spaces and dashes removed. |
| Money (`annual_income`, `net_liquid_assets`, `net_fixed_assets`, `total_net_worth`) | The reader is asked for digits only, no symbol, no separator; the server does not reformat them. Read only from a document that states the amount. An amount in MAD comes back as digits, without the currency. |
| `sex` | `M` or `F`. |
| Any value | Up to 500 characters. Eastern Arabic and Persian digits become 0 to 9. |

Corrections the server makes after the read, each listed in `notes`:

* Bank details are kept only when the document is a bank document (void cheque, bank statement, bank letter, RIB). On any other document they are removed, because a utility bill's account number or an invoice's IBAN is not the client's.
* `bank_number` and `bank_transit` are Canadian codes. They are dropped when the document is from another country, or when the length is wrong (3 digits and 5 digits). A 9-digit `bank_number` is moved to `bank_routing`, since 9 digits is a US routing number.
* A name that reads as a parent on a Moroccan card (`... ben ...`, `fils de`, `bent`) is dropped from `first_name`, `last_name` and `document_holder_name`.
* On an invoice, the supplier's legal name, address and registration numbers are not merged into `fields` unless the recipient is the same entity.
* `specimen_markings` stays on the document entry and is never merged into `fields`.

## Confidence

The API returns no per-field confidence and no per-document score. The reader gives values, not probabilities. Do not look for a confidence key.

You can still judge a read:

| Signal | Where | What to do |
| - | - | - |
| Key absent from `fields` | `documents[].fields` | The reader did not find it. Ask the person or re-scan. |
| `legible:` check, severity `warning` | `checks` | Some expected fields are missing. The detail lists them. |
| `legible:` check, severity `critical` | `checks` | None of the expected fields were read. Treat the file as unreadable. |
| `doctype:` check failed | `checks` | The reader says the document is another type than the step accepts. |
| `reader_unavailable: true` | top level | The file was not read at all. Retry later. |
| Format checks such as `format:cin:` or `mrz:` | `checks` | The number does not match its format. Usually a misread. |

The console shows a fixed value of 0.9 for every field the reader returns. It is a label for "read by the model, not yet reviewed", not a measurement, and field validation stays `pending` until a person reviews it.

## `reader_unavailable`

`reader_unavailable: true` means at least one file was never read: no credentials on the Sahl side, a quota or timeout, or an answer that could not be parsed. It does not mean the document was blank. A blank or cropped document returns `reader_unavailable: false` and few or no fields.

The call still returns 200. The read still counts. Retry the file later, and if it persists, quote the `X-Request-ID` header to Sahl.

With a `reference`, each file is filed on the case with a status that you see in the console under Documents:

| Status | When |
| - | - |
| `completed` | Fields were read and no check failed. |
| `completed_with_warnings` | Fields were read and at least one check failed (critical or warning). |
| `review_required` | No field was read and the reader was available: the page holds nothing it knows. |
| `failed` | No field was read and the reader was unavailable (error code `reader_unavailable`). |

## Document checks in the answer

Each file gets the checks that fit its type. They are the same checks `/verify` repeats on the entries you send back. The full list with meanings is in [Verify a profile](/guides/verification#checks).

In short: the document is the type the step expects (`doctype:`), its key fields were read (`legible:`), an ID is not expired (`expiry:`) or about to expire (`expiry_soon:`), the holder is 18 or older (`adult:`), a passport or ID card MRZ check digits are valid (`mrz:`), a proof of address is recent (`recency:`), the file was not re-saved from an editor (`provenance:`), and the document is not a specimen or sample (`authenticity:specimen:`).

A payslip gets no document check. Its entry carries fields only.

## Examples

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://app.sahlfinancial.com/api/v1/kyc/extract \
    -H "Authorization: Bearer $SAHL_API_KEY" \
    -F "files=@payslip-test.pdf;type=application/pdf" \
    -F "doc_type=payslip" \
    -F "reference=client-0001" \
    -F "environment=sandbox" \
    -F "subject=Test Client" \
    -F "kind=individual"
  ```

  ```javascript JavaScript theme={null}
  import { readFile } from "node:fs/promises";

  const form = new FormData();
  form.append("files", new Blob([await readFile("payslip-test.pdf")], { type: "application/pdf" }), "payslip-test.pdf");
  form.append("doc_type", "payslip");
  form.append("reference", "client-0001");
  form.append("environment", "sandbox");
  form.append("subject", "Test Client");
  form.append("kind", "individual");

  const res = await fetch("https://app.sahlfinancial.com/api/v1/kyc/extract", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.SAHL_API_KEY}` },
    body: form,
  });
  if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
  const extracted = await res.json();
  console.log(extracted.field_count, extracted.reader_unavailable);
  ```

  ```python Python theme={null}
  import os, requests

  with open("payslip-test.pdf", "rb") as f:
      res = requests.post(
          "https://app.sahlfinancial.com/api/v1/kyc/extract",
          headers={"Authorization": f"Bearer {os.environ['SAHL_API_KEY']}"},
          files=[("files", ("payslip-test.pdf", f, "application/pdf"))],
          data={
              "doc_type": "payslip",
              "reference": "client-0001",
              "environment": "sandbox",
              "subject": "Test Client",
              "kind": "individual",
          },
          timeout=120,
      )
  res.raise_for_status()
  extracted = res.json()
  print(extracted["field_count"], extracted["reader_unavailable"])
  ```
</CodeGroup>

Two files of one document (front and back of an ID card):

```bash theme={null}
curl -X POST https://app.sahlfinancial.com/api/v1/kyc/extract \
  -H "Authorization: Bearer $SAHL_API_KEY" \
  -F "files=@id-front-test.jpg" -F "files=@id-back-test.jpg" \
  -F "doc_type=national_id" -F "step_key=photo_id" \
  -F "reference=client-0001" -F "environment=sandbox"
```

## Limits and retention in the code

| Item | Value |
| - | - |
| Reads per month | 2,000 by default, per workspace, calendar month (UTC). Past it: 429 `kyc_extract_cap_reached` with `used` and `limit`. |
| Counted when | Before the model runs. A failed read still counts. A refused call (400, 413, 422) does not. |
| Concurrency | The counter is incremented in one statement, so concurrent calls cannot overshoot the limit. |
| Rate limit | 100 requests a minute per client IP on these routes. |
| Without `reference` | Nothing is filed on your workspace. The read fields are returned in the response only. |
| With `reference` | The file, the read fields and the checks are stored on your case, where your staff see them under Cases and Documents. |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.