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

# Lire des documents

> POST /v1/kyc/extract : de 1 à 5 fichiers en entrée, champs, contrôles par document et dossier en sortie.

`POST /v1/kyc/extract` lit les fichiers d'une étape d'envoi et renvoie les champs trouvés, les contrôles propres à chaque document et, avec une `reference`, un identifiant de dossier et des identifiants de documents. Scope : `kyc:extract`. C'est le seul appel qui utilise le modèle de vision, donc le seul qui compte dans votre quota mensuel de lectures.

## Requête

Formulaire multipart (`multipart/form-data`). Seul `files` est obligatoire.

| Champ | Type | Par défaut | Règle |
| - | - | - | - |
| `files` | fichier, répété | aucun | De 1 à 5 fichiers. JPEG, PNG, WebP, TIFF ou PDF. |
| `doc_type` | string | aucun | Indication donnée au lecteur sur le document, par exemple `payslip` ou `national_id`. S'applique à chaque fichier de l'appel. |
| `step_key` | string | aucun | Nom stable de votre étape d'envoi, par exemple `photo_id`. Détermine les types de documents que l'étape accepte. Voir [Types de documents et champs](/fr/guides/document-types#clés-détape). |
| `reference` | string | aucun | Votre identifiant du client, de 1 à 64 caractères parmi `A-Z a-z 0-9 _ . : -`. Classe le résultat sur un dossier. |
| `environment` | string | `sandbox` | `sandbox` ou `production`. Toute autre valeur donne une 422. |
| `subject` | string | aucun | Nom du client pour le dossier, jusqu'à 255 caractères. |
| `kind` | string | aucun | `individual`, `corporation`, `partnership`, `charitable_org`, `trust`, `estate`, ou un alias. Choisit les seuils personne ou entité pour les contrôles de documents. Les valeurs inconnues comptent comme `individual`. |

Envoyez un seul type de document par appel. `doc_type` et `step_key` s'appliquent à tous les fichiers de l'appel, et le recto et le verso d'une même carte sont le cas normal pour deux fichiers.

## Règles sur les fichiers

| Règle | Valeur | Origine |
| - | - | - |
| Fichiers par appel | 1 à 5 | Fixé dans le routeur. Sinon 400 `Send between 1 and 5 files.` |
| Formats | `image/jpeg`, `image/png`, `image/webp`, `image/tiff`, `application/pdf` | Validateur d'envoi. `image/jpg` est accepté comme JPEG. |
| Taille par fichier | 30 Mo par défaut | Paramètre `MAX_UPLOAD_MB`. Au-delà : 413 `File too large. Maximum allowed size is 30 MB.` |
| Taille d'image | 50 mégapixels (7000 x 7000) par défaut | Lue dans l'en-tête de l'image, donc un petit fichier peut être refusé. 413. |
| Contrôle du contenu | Les premiers octets doivent correspondre au type déclaré | Sinon 400 `File content does not match declared MIME type.` Un PDF peut avoir jusqu'à 1 Ko de préambule avant `%PDF-`. |
| Pages d'un PDF | Aucune limite de pages n'est appliquée sur cet endpoint | Le contrôle du nombre de pages n'est pas appelé par `/extract`. |

Le type de contenu vient de l'en-tête de votre partie multipart, pas du nom du fichier. Envoyez le bon `Content-Type` pour chaque partie de fichier. La plupart des bibliothèques HTTP et `curl -F` le déduisent de l'extension.

## Ce qui se passe pendant un appel

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant App as Votre serveur
    participant API as API Partner Sahl
    participant Reader as Modèle de vision
    App->>API: POST /v1/kyc/extract
    API->>API: valider reference, environment, subject, nombre de fichiers
    API->>API: valider chaque fichier (taille, type, premiers octets, pixels)
    API->>API: charger la politique de l'espace de travail
    API->>API: réserver N lectures pour le mois (429 si dépassé)
    API->>Reader: une lecture par fichier
    Reader-->>API: JSON des champs et un type de document
    API->>API: normaliser les valeurs, exécuter les contrôles de documents
    API-->>App: 200 fields, documents, checks
```

L'ordre compte à deux endroits. Les erreurs de validation (400, 413, 422) surviennent avant la réservation de la lecture, donc elles ne coûtent rien. La réservation a lieu avant l'exécution du modèle, donc une lecture qui échoue ensuite compte quand même.

## Réponse

```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"]
}
```

Les valeurs ci-dessus sont fictives. Les champs présents dépendent du fichier envoyé.

### Clés de premier niveau

| Clé | Type | Signification |
| - | - | - |
| `fields` | objet de strings | Tous les champs de l'appel, fusionnés. Pour une clé trouvée dans plusieurs fichiers, la première valeur non vide l'emporte, dans l'ordre de `files`. Envoyez d'abord le document d'identité le plus fort. |
| `documents` | tableau | Une entrée par fichier, dans l'ordre d'envoi. |
| `field_count` | entier | Nombre de clés dans `fields`. |
| `checks` | tableau | Les contrôles par document de tous les fichiers, dans une seule liste. |
| `reader_unavailable` | booléen | Vrai quand au moins un fichier n'a jamais été lu. |
| `policy` | objet | La politique de l'espace de travail sous laquelle l'appel s'est exécuté. |
| `case_id` | uuid | Seulement avec une `reference`. |
| `document_ids` | tableau d'uuid | Seulement avec une `reference`. Même ordre que `documents`. |

### Clés de chaque entrée `documents[]`

| Clé | Type | Signification |
| - | - | - |
| `filename` | string | Le nom que vous avez envoyé. |
| `doc_type` | string ou null | Ce que le lecteur dit être le document. Voir [la liste](/fr/guides/document-types#types-de-documents-renvoyés-par-le-lecteur). Null quand il n'a pas pu le nommer. |
| `step_hint` | string ou null | Le `doc_type` que vous avez envoyé. |
| `step_key` | string ou null | Le `step_key` que vous avez envoyé. |
| `fields` | objet | Les champs lus dans ce fichier seulement. |
| `meta_created` | string ou null | Date de création du fichier, `YYYY-MM-DD` : le `CreationDate` du PDF, ou la date EXIF d'une image. |
| `meta_provenance` | objet | Ce que le fichier indique sur sa fabrication. Vide quand rien n'est connu. |
| `mapped` | entier | Nombre de champs lus dans ce fichier. |
| `notes` | tableau de strings | Corrections apportées par le serveur à la réponse du lecteur, avec la raison. |
| `document_id` | uuid | Seulement avec une `reference`. |

Clés de `meta_provenance` : pour un PDF, `producer` et `creator` (le logiciel, jusqu'à 200 caractères), `modified` (le `ModDate` au format `YYYY-MM-DD`, quand il diffère de la date de création), `revisions` (combien de fois le fichier a été enregistré de façon incrémentale). Pour une image, `creator` (la balise EXIF Software) et `camera` (la balise EXIF Make). L'absence de bloc EXIF n'est pas signalée, car WhatsApp et la plupart des navigateurs le suppriment.

### Valeurs des champs

Chaque valeur de `fields` est une string. Le lecteur a pour consigne d'omettre un champ qu'il ne peut pas lire, donc une clé absente signifie "non lu", jamais "vide". Le serveur normalise ensuite :

| Type | Règle |
| - | - |
| Dates (`date_of_birth`, `id_expiry`, `incorporation_date`, `document_date`) | `YYYY-MM-DD`. Les dates jour d'abord (`DD/MM/YYYY`) et les chiffres arabes orientaux sont convertis. Une date hégirienne garde la forme `YYYY-MM-DD AH` jusqu'à sa conversion en aval. |
| `province`, `id_province` | Code de deux lettres, par exemple `QC`, `ON`, `NY`. |
| `country`, `citizenship`, `id_country` | Code ISO de deux lettres, par exemple `MA`, `CA`, `US`. |
| Numéros (`sin`, `ssn`, `bank_number`, `bank_transit`, `bank_routing`, `bank_account`, `business_number`, `ice`, `if_number`, `iban`) | Chiffres et lettres uniquement, espaces et tirets retirés. |
| Montants (`annual_income`, `net_liquid_assets`, `net_fixed_assets`, `total_net_worth`) | Chiffres uniquement, sans symbole ni séparateur. Lus seulement dans un document qui indique le montant. Un montant en MAD revient en chiffres, sans la devise. |
| `sex` | `M` ou `F`. |
| Toute valeur | Jusqu'à 500 caractères. Les chiffres arabes orientaux et persans deviennent 0 à 9. |

Corrections que le serveur apporte après la lecture, chacune listée dans `notes` :

* Les coordonnées bancaires ne sont conservées que si le document est un document bancaire (chèque annulé, relevé bancaire, lettre de banque, RIB). Sur tout autre document, elles sont retirées, car le numéro de compte d'une facture de services ou l'IBAN d'une facture ne sont pas ceux du client.
* `bank_number` et `bank_transit` sont des codes canadiens. Ils sont écartés quand le document vient d'un autre pays, ou quand la longueur est fausse (3 chiffres et 5 chiffres). Un `bank_number` de 9 chiffres est déplacé vers `bank_routing`, puisque 9 chiffres correspondent à un numéro de routage américain.
* Un nom qui se lit comme celui d'un parent sur une carte marocaine (`... ben ...`, `fils de`, `bent`) est retiré de `first_name`, `last_name` et `document_holder_name`.
* Sur une facture, la dénomination légale, l'adresse et les numéros d'immatriculation du fournisseur ne sont pas fusionnés dans `fields`, sauf si le destinataire est la même entité.
* `specimen_markings` reste sur l'entrée du document et n'est jamais fusionné dans `fields`.

## Confiance

L'API ne renvoie ni confiance par champ ni score par document. Le lecteur donne des valeurs, pas des probabilités. Ne cherchez pas de clé de confiance.

Vous pouvez tout de même juger une lecture :

| Signal | Où | Que faire |
| - | - | - |
| Clé absente de `fields` | `documents[].fields` | Le lecteur ne l'a pas trouvée. Demandez à la personne ou rescannez. |
| Contrôle `legible:`, sévérité `warning` | `checks` | Certains champs attendus manquent. Le détail les liste. |
| Contrôle `legible:`, sévérité `critical` | `checks` | Aucun des champs attendus n'a été lu. Traitez le fichier comme illisible. |
| Contrôle `doctype:` échoué | `checks` | Le lecteur dit que le document est d'un autre type que celui accepté par l'étape. |
| `reader_unavailable: true` | premier niveau | Le fichier n'a pas été lu du tout. Réessayez plus tard. |
| Contrôles de format comme `format:cin:` ou `mrz:` | `checks` | Le numéro ne correspond pas à son format. Le plus souvent une erreur de lecture. |

La console affiche une valeur fixe de 0.9 pour chaque champ renvoyé par le lecteur. C'est une étiquette pour "lu par le modèle, pas encore relu", pas une mesure, et la validation du champ reste `pending` jusqu'à la relecture par une personne.

## `reader_unavailable`

`reader_unavailable: true` signifie qu'au moins un fichier n'a jamais été lu : pas d'identifiants côté Sahl, un quota ou un délai dépassé, ou une réponse impossible à analyser. Cela ne veut pas dire que le document était vierge. Un document vierge ou recadré renvoie `reader_unavailable: false` et peu de champs, voire aucun.

L'appel renvoie quand même 200. La lecture compte quand même. Réessayez le fichier plus tard, et si le problème persiste, communiquez à Sahl l'en-tête `X-Request-ID`.

Avec une `reference`, chaque fichier est classé sur le dossier avec un statut que vous voyez dans la console sous **Documents** :

| Statut | Quand |
| - | - |
| `completed` | Des champs ont été lus et aucun contrôle n'a échoué. |
| `completed_with_warnings` | Des champs ont été lus et au moins un contrôle a échoué (critical ou warning). |
| `review_required` | Aucun champ n'a été lu et le lecteur était disponible : la page ne contient rien qu'il connaisse. |
| `failed` | Aucun champ n'a été lu et le lecteur était indisponible (code d'erreur `reader_unavailable`). |

## Contrôles de documents dans la réponse

Chaque fichier reçoit les contrôles adaptés à son type. Ce sont les mêmes contrôles que `/verify` répète sur les entrées que vous renvoyez. La liste complète avec leur sens est dans [Vérifier un profil](/fr/guides/verification).

En bref : le document est du type attendu par l'étape (`doctype:`), ses champs clés ont été lus (`legible:`), une pièce d'identité n'est pas expirée (`expiry:`) ou sur le point de l'être (`expiry_soon:`), le titulaire a 18 ans ou plus (`adult:`), les chiffres de contrôle de la MRZ d'un passeport ou d'une carte d'identité sont valides (`mrz:`), un justificatif de domicile est récent (`recency:`), le fichier n'a pas été réenregistré depuis un éditeur (`provenance:`), et le document n'est pas un spécimen ou un échantillon (`authenticity:specimen:`).

Un bulletin de paie ne reçoit aucun contrôle de document. Son entrée ne porte que des champs.

## Exemples

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

Deux fichiers d'un même document (recto et verso d'une carte d'identité) :

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

## Limites et conservation dans le code

| Élément | Valeur |
| - | - |
| Lectures par mois | 2 000 par défaut, par espace de travail, mois civil (UTC). Au-delà : 429 `kyc_extract_cap_reached` avec `used` et `limit`. |
| Décompté quand | Avant l'exécution du modèle. Une lecture qui échoue compte quand même. Un appel refusé (400, 413, 422) ne compte pas. |
| Concurrence | Le compteur est incrémenté en une seule instruction, donc des appels concurrents ne peuvent pas dépasser la limite. |
| Limite de débit | 100 requêtes par minute et par IP cliente sur ces routes. |
| Sans `reference` | Rien n'est classé dans votre espace de travail. Les champs lus sont renvoyés dans la réponse uniquement. |
| Avec `reference` | Le fichier, les champs lus et les contrôles sont stockés sur votre dossier, où votre personnel les voit sous **Cases** et **Documents**. |


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