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

# Recettes

> Cinq cas d'usage réels avec du code fonctionnel : dossier de prêt, ouverture de compte, vérification de revenu, eID, et un champ non lu.

Chaque recette est un script complet en JavaScript et en Python. Elles utilisent des clients fictifs et la sandbox. Exécutez-les telles quelles après avoir placé vos propres fichiers à côté. Les recettes JavaScript partagent un petit utilitaire, présenté en premier.

Toutes les recettes ont été exécutées contre un serveur local de substitution qui renvoie des réponses `/extract` préparées et exécute le code de vérification et de scoring de Sahl sur les corps envoyés (la recette eID interroge un substitut qui répond `pending`, puis `complete`). Elles n'ont pas été exécutées contre l'API réelle.

## Utilitaire pour les recettes JavaScript

Enregistrez-le sous `common.mjs`.

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

export const BASE = "https://app.sahlfinancial.com/api";
const KEY = process.env.SAHL_API_KEY;

export async function sahl(path, init = {}) {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: { Authorization: `Bearer ${KEY}`, ...init.headers },
  });
  if (!res.ok) {
    const error = new Error(`${init.method ?? "GET"} ${path} -> ${res.status}`);
    error.status = res.status;
    error.body = await res.text();
    error.requestId = res.headers.get("x-request-id");
    throw error;
  }
  return res;
}

export async function extract({ file, mime, docType, stepKey, reference }) {
  const form = new FormData();
  form.append("files", new Blob([await readFile(file)], { type: mime }), file);
  form.append("doc_type", docType);
  if (stepKey) form.append("step_key", stepKey);
  form.append("reference", reference);
  form.append("environment", "sandbox");
  form.append("kind", "individual");
  return (await sahl("/v1/kyc/extract", { method: "POST", body: form })).json();
}

export async function postJson(path, body) {
  return (await sahl(path, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  })).json();
}
```

L'utilitaire lève une erreur qui porte `status`, `body` et `requestId` (l'en-tête `X-Request-ID`). Communiquez l'identifiant de requête lorsque vous écrivez à Sahl.

## 1. Dossier de prêt

Un demandeur envoie une pièce d'identité, un justificatif de domicile et une fiche de paie. Vous voulez un verdict pour le dossier et une suite à donner : continuer, revue humaine ou refus.

| Étape | Appel | Remarques |
| - | - | - |
| Lire la pièce d'identité | `/extract`, `doc_type=national_id`, `step_key=photo_id` | L'identité d'abord : la première valeur non vide l'emporte lors de la fusion des champs. |
| Lire le justificatif de domicile | `/extract`, `doc_type=utility_bill`, `step_key=proof_of_address` | L'ancienneté est critique ici : 90 jours par défaut. |
| Lire la fiche de paie | `/extract`, `doc_type=payslip` | Champs uniquement. |
| Verdict | `/verify` avec toutes les entrées `documents` inchangées | |

<CodeGroup>
  ```javascript JavaScript theme={null}
  // Recette 1. Dossier de prêt : pièce d'identité, justificatif de domicile, fiche de paie, puis un verdict.
  import { extract, postJson } from "./common.mjs";

  const reference = "loan-0001";
  const files = [
    { file: "cin-test.jpg", mime: "image/jpeg", docType: "national_id", stepKey: "photo_id" },
    { file: "bill-test.pdf", mime: "application/pdf", docType: "utility_bill", stepKey: "proof_of_address" },
    { file: "payslip-test.pdf", mime: "application/pdf", docType: "payslip" },
  ];

  const reads = [];
  for (const f of files) reads.push(await extract({ ...f, reference })); // identité d'abord

  const unread = reads.filter((r) => r.reader_unavailable);
  if (unread.length) throw new Error("A file was not read. Retry later; no verdict was asked for.");

  const fields = Object.assign({}, ...reads.map((r) => r.fields).reverse()); // le premier fichier l'emporte
  const verdict = await postJson("/v1/kyc/verify", {
    reference,
    environment: "sandbox",
    subject: `${fields.first_name} ${fields.last_name}`,
    kind: "individual",
    values: { ...fields, country: "MA" },
    documents: reads.flatMap((r) => r.documents), // inchangées
  });

  let decision;
  if (!verdict.passed) decision = { route: "refuse_or_fix", reasons: verdict.critical_failures.map((c) => `${c.id}: ${c.detail}`) };
  else if (verdict.flags.length) decision = { route: "human_review", reasons: verdict.flags.map((c) => c.id) };
  else decision = { route: "auto_continue", reasons: [] };

  console.log(decision, verdict.case_id);
  ```

  ```python Python theme={null}
  # Recette 1. Dossier de prêt : pièce d'identité, justificatif de domicile, fiche de paie, puis un verdict.
  import os
  import requests

  BASE = "https://app.sahlfinancial.com/api"
  HEADERS = {"Authorization": f"Bearer {os.environ['SAHL_API_KEY']}"}
  REFERENCE = "loan-0001"

  FILES = [  # identité d'abord
      ("cin-test.jpg", "image/jpeg", "national_id", "photo_id"),
      ("bill-test.pdf", "application/pdf", "utility_bill", "proof_of_address"),
      ("payslip-test.pdf", "application/pdf", "payslip", None),
  ]


  def extract(path, mime, doc_type, step_key):
      data = {"doc_type": doc_type, "reference": REFERENCE, "environment": "sandbox", "kind": "individual"}
      if step_key:
          data["step_key"] = step_key
      with open(path, "rb") as f:
          res = requests.post(f"{BASE}/v1/kyc/extract", headers=HEADERS,
                              files=[("files", (path, f, mime))], data=data, timeout=120)
      res.raise_for_status()
      return res.json()


  reads = [extract(*f) for f in FILES]
  if any(r["reader_unavailable"] for r in reads):
      raise SystemExit("A file was not read. Retry later; no verdict was asked for.")

  fields = {}
  for r in reads:  # le premier fichier l'emporte, comme dans la fusion de l'API
      for k, v in r["fields"].items():
          fields.setdefault(k, v)

  res = requests.post(
      f"{BASE}/v1/kyc/verify", headers=HEADERS, timeout=60,
      json={
          "reference": REFERENCE,
          "environment": "sandbox",
          "subject": f"{fields['first_name']} {fields['last_name']}",
          "kind": "individual",
          "values": {**fields, "country": "MA"},
          "documents": [d for r in reads for d in r["documents"]],  # inchangées
      },
  )
  res.raise_for_status()
  verdict = res.json()

  if not verdict["passed"]:
      decision = ("refuse_or_fix", [f"{c['id']}: {c['detail']}" for c in verdict["critical_failures"]])
  elif verdict["flags"]:
      decision = ("human_review", [c["id"] for c in verdict["flags"]])
  else:
      decision = ("auto_continue", [])
  print(decision, verdict["case_id"])
  ```
</CodeGroup>

Résultat pour le faux fichier (ni rue, ni téléphone, ni email dans `values` : la complétude est donc inférieure à 100 %) :

```text theme={null}
{ route: 'human_review', reasons: [ 'completeness' ] } 00000000-0000-4000-8000-000000000001
```

Comment elle décide : un échec critique (`passed: false`) signifie refuser ou corriger. Des drapeaux sans échec critique signifient qu'une personne révise. Rien du tout signifie continuer. Ces suites sont votre politique, pas celle de Sahl.

## 2. Dossier d'ouverture de compte

Le client remplit votre formulaire de demande et envoie une pièce d'identité avec photo. Vous vérifiez l'ensemble du dossier, intégrez votre propre contrôle de doublon et conservez le `case_id`.

<CodeGroup>
  ```javascript JavaScript theme={null}
  // Recette 2. KYC pour une ouverture de compte : formulaire de demande + pièce d'identité + votre propre contrôle de doublon.
  import { extract, postJson } from "./common.mjs";

  const reference = "acct-0001";
  const form = {                       // ce que le client a saisi dans votre formulaire
    first_name: "Test", last_name: "Client", date_of_birth: "1988-04-12", citizenship: "MA",
    street1: "10 Rue Exemple", city: "Casablanca", province: "Casablanca-Settat", postal_code: "20000", country: "MA",
    phone: "+212600000000", email: "test.client@example.com",
    occupation: "Analyst", employer_name: "Test Employer SARL", annual_income: "84000",
    net_liquid_assets: "20000", total_net_worth: "60000", source_of_funds: "Employment income",
    objective: "Balanced", horizon: "5-10 years", investment_knowledge: "Good", investment_experience: "< 5 years",
    account_type: "Individual", third_party: "no", pep: "no",
  };

  const id = await extract({ file: "cin-test.jpg", mime: "image/jpeg", docType: "national_id", stepKey: "photo_id", reference });
  if (id.reader_unavailable) throw new Error("The ID was not read. Retry later.");

  // La pièce d'identité lue fait foi pour les champs d'identité.
  const values = { ...form, ...pick(id.fields, ["first_name", "last_name", "date_of_birth", "id_type", "id_number", "id_expiry"]) };

  const duplicate = await isDuplicateInMyDatabase(values); // votre propre recherche
  const verdict = await postJson("/v1/kyc/verify", {
    reference, environment: "sandbox", subject: `${values.first_name} ${values.last_name}`, kind: "individual",
    values,
    documents: id.documents,
    extra_checks: [{
      id: "internal:duplicate_client", label: "No duplicate client in our database", severity: "critical",
      passed: !duplicate, detail: duplicate ? "matches an existing client" : "",
    }],
  });

  console.log("passed:", verdict.passed, "| completeness:", verdict.completeness.percent + "%");
  console.log("critical:", verdict.critical_failures.map((c) => c.id));
  console.log("flags:", verdict.flags.map((c) => c.id));
  console.log("refused switches:", verdict.policy.overrides_refused);
  console.log("store case_id:", verdict.case_id);

  function pick(obj, keys) { return Object.fromEntries(keys.filter((k) => obj[k]).map((k) => [k, obj[k]])); }
  async function isDuplicateInMyDatabase() { return false; }
  ```

  ```python Python theme={null}
  # Recette 2. KYC pour une ouverture de compte : formulaire de demande + pièce d'identité + votre propre contrôle de doublon.
  import os
  import requests

  BASE = "https://app.sahlfinancial.com/api"
  HEADERS = {"Authorization": f"Bearer {os.environ['SAHL_API_KEY']}"}
  REFERENCE = "acct-0001"

  form = {  # ce que le client a saisi dans votre formulaire
      "first_name": "Test", "last_name": "Client", "date_of_birth": "1988-04-12", "citizenship": "MA",
      "street1": "10 Rue Exemple", "city": "Casablanca", "province": "Casablanca-Settat", "postal_code": "20000", "country": "MA",
      "phone": "+212600000000", "email": "test.client@example.com",
      "occupation": "Analyst", "employer_name": "Test Employer SARL", "annual_income": "84000",
      "net_liquid_assets": "20000", "total_net_worth": "60000", "source_of_funds": "Employment income",
      "objective": "Balanced", "horizon": "5-10 years", "investment_knowledge": "Good", "investment_experience": "< 5 years",
      "account_type": "Individual", "third_party": "no", "pep": "no",
  }


  def is_duplicate_in_my_database(values):  # votre propre recherche
      return False


  with open("cin-test.jpg", "rb") as f:
      res = requests.post(
          f"{BASE}/v1/kyc/extract", headers=HEADERS, timeout=120,
          files=[("files", ("cin-test.jpg", f, "image/jpeg"))],
          data={"doc_type": "national_id", "step_key": "photo_id", "reference": REFERENCE,
                "environment": "sandbox", "kind": "individual"},
      )
  res.raise_for_status()
  id_read = res.json()
  if id_read["reader_unavailable"]:
      raise SystemExit("The ID was not read. Retry later.")

  # La pièce d'identité lue fait foi pour les champs d'identité.
  identity_keys = ["first_name", "last_name", "date_of_birth", "id_type", "id_number", "id_expiry"]
  values = {**form, **{k: id_read["fields"][k] for k in identity_keys if id_read["fields"].get(k)}}

  duplicate = is_duplicate_in_my_database(values)
  res = requests.post(
      f"{BASE}/v1/kyc/verify", headers=HEADERS, timeout=60,
      json={
          "reference": REFERENCE, "environment": "sandbox",
          "subject": f"{values['first_name']} {values['last_name']}", "kind": "individual",
          "values": values,
          "documents": id_read["documents"],
          "extra_checks": [{
              "id": "internal:duplicate_client", "label": "No duplicate client in our database",
              "severity": "critical", "passed": not duplicate,
              "detail": "matches an existing client" if duplicate else "",
          }],
      },
  )
  res.raise_for_status()
  verdict = res.json()

  print("passed:", verdict["passed"], "| completeness:", f"{verdict['completeness']['percent']}%")
  print("critical:", [c["id"] for c in verdict["critical_failures"]])
  print("flags:", [c["id"] for c in verdict["flags"]])
  print("refused switches:", verdict["policy"]["overrides_refused"])
  print("store case_id:", verdict["case_id"])
  ```
</CodeGroup>

Résultat pour les données fictives, qui renseignent chaque donnée requise sauf `sin/ssn`. La liste des données requises du moteur est nord-américaine et un client marocain n'a pas de NAS : `sin/ssn` reste dans `missing` et la complétude est de 96 %. C'est au-dessus du seuil d'alerte de 80 %, donc rien n'est signalé :

```text theme={null}
passed: true | completeness: 96%
critical: []
flags: []
refused switches: []
store case_id: 00000000-0000-4000-8000-000000000001
```

Points à reprendre :

* La recette remplace les valeurs d'identité du formulaire par celles lues sur la pièce d'identité. Si vous préférez détecter une faute de frappe dans le formulaire, laissez les noms du formulaire dans `values` et laissez `consistency:profile_name_id` les comparer avec la pièce d'identité (critique lorsqu'ils diffèrent).
* Une entrée `extra_checks` en échec avec la gravité `critical` met `passed` à faux : votre propre règle peut donc bloquer le dossier.
* `policy.overrides_refused` est vide sauf si vous avez envoyé un interrupteur que la politique de votre espace de travail verrouille.
* Pour une entité, utilisez `kind: "corporation"` (ou un autre type d'entité), envoyez `legal_name`, `business_number`, `director_names` et `beneficial_owners`, et lisez l'acte constitutif avec `step_key=articles_of_incorporation`. Voir les [données requises](/fr/guides/verification#values).

## 3. Vérification du revenu à partir d'une fiche de paie

Le client déclare un revenu et envoie une fiche de paie. L'API lit la fiche de paie. Elle ne fixe aucune règle de revenu et ne renvoie un chiffre que si la fiche de paie en imprime un. Les règles ici sont donc les vôtres : titulaire, employeur, date, et revenu s'il est indiqué.

<CodeGroup>
  ```javascript JavaScript theme={null}
  // Recette 3. Cette fiche de paie confirme-t-elle le revenu déclaré par le client ?
  // L'API lit la fiche de paie. Les règles ci-dessous sont les vôtres : l'API ne fixe aucune règle de revenu.
  import { extract, postJson } from "./common.mjs";

  const reference = "inc-0001";
  const applicant = { first_name: "Test", last_name: "Client", employer_name: "Test Employer SARL", annual_income: 84000 };

  const read = await extract({ file: "payslip-test.pdf", mime: "application/pdf", docType: "payslip", reference });
  if (read.reader_unavailable) throw new Error("The payslip was not read. Retry later.");
  const doc = read.documents[0];
  const f = doc.fields;

  const key = (s) => String(s ?? "").normalize("NFKD").replace(/[^a-z ]/gi, "").toLowerCase().split(/\s+/).filter(Boolean).sort().join(" ");
  const findings = [];

  if (key(f.document_holder_name || `${f.first_name} ${f.last_name}`) !== key(`${applicant.first_name} ${applicant.last_name}`)) {
    findings.push("holder_does_not_match_applicant");
  }
  if (key(f.employer_name) !== key(applicant.employer_name)) findings.push("employer_does_not_match");

  // L'API ne contrôle pas l'ancienneté des fiches de paie (seulement celle des justificatifs de domicile). Votre règle : 90 jours.
  const ageDays = f.document_date ? (Date.now() - Date.parse(f.document_date)) / 86_400_000 : Infinity;
  if (!(ageDays <= 90)) findings.push(f.document_date ? "payslip_older_than_90_days" : "payslip_date_not_read");

  // Revenu : seulement si la fiche de paie l'indique. Sinon l'API n'a rien à comparer.
  if (f.annual_income) {
    const stated = Number(f.annual_income);
    if (Math.abs(stated - applicant.annual_income) / applicant.annual_income > 0.1) findings.push("income_differs_by_more_than_10_percent");
  } else {
    findings.push("income_not_stated_on_payslip");
  }

  // Signaux au niveau du fichier déjà calculés par l'API.
  for (const c of read.checks) if (!c.passed && c.severity !== "info") findings.push(c.id);

  // Demander la bande de capacité que donne le revenu déclaré, sans aucun contrôle de document.
  const { assessment } = await postJson("/v1/kyc/assess", {
    reference, environment: "sandbox", kind: "individual", require_documents: false,
    values: { first_name: applicant.first_name, last_name: applicant.last_name, annual_income: String(applicant.annual_income) },
    documents: [],
  });

  console.log({ findings, capacity: assessment.capacity });
  ```

  ```python Python theme={null}
  # Recette 3. Cette fiche de paie confirme-t-elle le revenu déclaré par le client ?
  # L'API lit la fiche de paie. Les règles ci-dessous sont les vôtres : l'API ne fixe aucune règle de revenu.
  import os
  import re
  import unicodedata
  from datetime import date

  import requests

  BASE = "https://app.sahlfinancial.com/api"
  HEADERS = {"Authorization": f"Bearer {os.environ['SAHL_API_KEY']}"}
  REFERENCE = "inc-0001"
  applicant = {"first_name": "Test", "last_name": "Client", "employer_name": "Test Employer SARL", "annual_income": 84000}

  with open("payslip-test.pdf", "rb") as fh:
      res = requests.post(
          f"{BASE}/v1/kyc/extract", headers=HEADERS, timeout=120,
          files=[("files", ("payslip-test.pdf", fh, "application/pdf"))],
          data={"doc_type": "payslip", "reference": REFERENCE, "environment": "sandbox", "kind": "individual"},
      )
  res.raise_for_status()
  read = res.json()
  if read["reader_unavailable"]:
      raise SystemExit("The payslip was not read. Retry later.")
  f = read["documents"][0]["fields"]


  def key(s):
      s = unicodedata.normalize("NFKD", str(s or ""))
      return " ".join(sorted(re.sub(r"[^a-z ]", "", s.lower()).split()))


  findings = []
  if key(f.get("document_holder_name") or f"{f.get('first_name')} {f.get('last_name')}") != key(
      f"{applicant['first_name']} {applicant['last_name']}"
  ):
      findings.append("holder_does_not_match_applicant")
  if key(f.get("employer_name")) != key(applicant["employer_name"]):
      findings.append("employer_does_not_match")

  # L'API ne contrôle pas l'ancienneté des fiches de paie (seulement celle des justificatifs de domicile). Votre règle : 90 jours.
  if f.get("document_date"):
      if (date.today() - date.fromisoformat(f["document_date"])).days > 90:
          findings.append("payslip_older_than_90_days")
  else:
      findings.append("payslip_date_not_read")

  # Revenu : seulement si la fiche de paie l'indique. Sinon l'API n'a rien à comparer.
  if f.get("annual_income"):
      stated = float(f["annual_income"])
      if abs(stated - applicant["annual_income"]) / applicant["annual_income"] > 0.1:
          findings.append("income_differs_by_more_than_10_percent")
  else:
      findings.append("income_not_stated_on_payslip")

  # Signaux au niveau du fichier déjà calculés par l'API.
  findings += [c["id"] for c in read["checks"] if not c["passed"] and c["severity"] != "info"]

  res = requests.post(
      f"{BASE}/v1/kyc/assess", headers=HEADERS, timeout=60,
      json={
          "reference": REFERENCE, "environment": "sandbox", "kind": "individual", "require_documents": False,
          "values": {"first_name": applicant["first_name"], "last_name": applicant["last_name"],
                     "annual_income": str(applicant["annual_income"])},
          "documents": [],
      },
  )
  res.raise_for_status()
  print({"findings": findings, "capacity": res.json()["assessment"]["capacity"]})
  ```
</CodeGroup>

Résultat pour la fausse fiche de paie, qui n'imprime pas de chiffre annuel :

```text theme={null}
{ findings: [ 'income_not_stated_on_payslip' ], capacity: { score: 20, band: 'Low', missing: [ 'net_liquid_assets', 'total_net_worth' ] } }
```

Sur quoi repose cette recette :

* `annual_income` ne revient que si la fiche de paie l'indique. On demande au lecteur de ne jamais estimer.
* L'API contrôle l'ancienneté des justificatifs de domicile, pas des fiches de paie. Les 90 jours ici sont votre règle.
* `capacity` n'utilise que le revenu déclaré. Quand `net_liquid_assets` et `total_net_worth` manquent, le score est tiré vers le bas ; envoyez-les si vous les avez. Voir [Capacité](/fr/guides/risk-assessment#capacité).

## 4. Contrôle eID

Un client canadien ouvre un compte à distance. L'eID ne couvre que les clients canadiens dans cette version : cette recette est la seule qui garde le pays `CA`. Vous lancez le contrôle, attendez le client, conservez le rapport PDF, puis vérifiez sous la même référence.

<Warning>
  Cela envoie un vrai email via le fournisseur eID, en sandbox aussi. Remplacez l'adresse par une adresse que vous contrôlez. Votre espace de travail a besoin de son propre compte chez un fournisseur eID.
</Warning>

<CodeGroup>
  ```javascript JavaScript theme={null}
  // Recette 4. Contrôle eID : lancer, interroger jusqu'à ce que le client termine, conserver le PDF, puis vérifier.
  import { writeFile } from "node:fs/promises";
  import { sahl, postJson } from "./common.mjs";

  const reference = "eid-0001";
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

  // 1. Lancer. Le client reçoit un email maintenant, même en sandbox. Utilisez une adresse que vous contrôlez.
  const started = await postJson("/v1/kyc/eid", {
    reference, first_name: "Test", last_name: "Client", email: "you@your-domain.example",
    country: "CA", language: "en", documents: 1, environment: "sandbox",
  });
  console.log("eID request", started.key, "started for", started.reference);

  // 2. Interroger : toutes les 30 s pendant 10 minutes, puis toutes les 5 minutes, jusqu'à 24 heures.
  const deadline = Date.now() + 24 * 3600_000;
  let result;
  for (let n = 0; Date.now() < deadline; n++) {
    result = await (await sahl(`/v1/kyc/eid/${started.key}?environment=sandbox`)).json();
    if (result.complete) break;
    await sleep(n < 20 ? 30_000 : 300_000);
  }
  if (!result?.complete) throw new Error("Not completed in 24 hours. Start a new request if needed.");

  console.log("passed:", result.passed);
  for (const c of result.checks.filter((c) => !c.passed)) console.log(" failed:", c.id, c.detail);

  // 3. Conserver le rapport dans un délai d'environ sept jours : le fournisseur supprime ensuite les données personnelles.
  const pdf = Buffer.from(await (await sahl(`/v1/kyc/eid/${started.key}/report`)).arrayBuffer());
  await writeFile(`eid-${started.key}.pdf`, pdf);

  // 4. Vérifier avec la MÊME référence et le MÊME environnement, afin qu'une exigence eID de la politique puisse être satisfaite.
  const verdict = await postJson("/v1/kyc/verify", {
    reference, environment: "sandbox", kind: "individual", require_documents: false,
    values: { first_name: "Test", last_name: "Client", country: "CA" }, documents: [],
  });
  console.log("verify passed:", verdict.passed, verdict.critical_failures.map((c) => c.id));
  ```

  ```python Python theme={null}
  # Recette 4. Contrôle eID : lancer, interroger jusqu'à ce que le client termine, conserver le PDF, puis vérifier.
  import os
  import time

  import requests

  BASE = "https://app.sahlfinancial.com/api"
  HEADERS = {"Authorization": f"Bearer {os.environ['SAHL_API_KEY']}"}
  REFERENCE = "eid-0001"

  # 1. Lancer. Le client reçoit un email maintenant, même en sandbox. Utilisez une adresse que vous contrôlez.
  res = requests.post(
      f"{BASE}/v1/kyc/eid", headers=HEADERS, timeout=60,
      json={"reference": REFERENCE, "first_name": "Test", "last_name": "Client",
            "email": "you@your-domain.example", "country": "CA", "language": "en",
            "documents": 1, "environment": "sandbox"},
  )
  res.raise_for_status()
  key = res.json()["key"]
  print("eID request", key, "started for", REFERENCE)

  # 2. Interroger : toutes les 30 s pendant 10 minutes, puis toutes les 5 minutes, jusqu'à 24 heures.
  deadline = time.time() + 24 * 3600
  result, n = None, 0
  while time.time() < deadline:
      r = requests.get(f"{BASE}/v1/kyc/eid/{key}", params={"environment": "sandbox"}, headers=HEADERS, timeout=60)
      r.raise_for_status()
      result = r.json()
      if result["complete"]:
          break
      time.sleep(30 if n < 20 else 300)
      n += 1
  if not result or not result["complete"]:
      raise SystemExit("Not completed in 24 hours. Start a new request if needed.")

  print("passed:", result["passed"])
  for c in result["checks"]:
      if not c["passed"]:
          print(" failed:", c["id"], c["detail"])

  # 3. Conserver le rapport dans un délai d'environ sept jours : le fournisseur supprime ensuite les données personnelles.
  pdf = requests.get(f"{BASE}/v1/kyc/eid/{key}/report", headers=HEADERS, timeout=60)
  pdf.raise_for_status()
  with open(f"eid-{key}.pdf", "wb") as fh:
      fh.write(pdf.content)

  # 4. Vérifier avec la MÊME référence et le MÊME environnement, afin qu'une exigence eID de la politique puisse être satisfaite.
  res = requests.post(
      f"{BASE}/v1/kyc/verify", headers=HEADERS, timeout=60,
      json={"reference": REFERENCE, "environment": "sandbox", "kind": "individual", "require_documents": False,
            "values": {"first_name": "Test", "last_name": "Client", "country": "CA"}, "documents": []},
  )
  res.raise_for_status()
  verdict = res.json()
  print("verify passed:", verdict["passed"], [c["id"] for c in verdict["critical_failures"]])
  ```
</CodeGroup>

Résultat, avec un client qui termine après la troisième interrogation :

```text theme={null}
eID request 123456 started for eid-0001
passed: true
verify passed: true []
```

Conservez le PDF : le fournisseur supprime les données personnelles après environ sept jours. Voir [Contrôle eID](/fr/guides/eid).

## 5. Gérer un champ que le lecteur n'a pas lu

L'API ne renvoie aucune confiance par champ. Un champ est dans `fields` ou il n'y est pas, et les contrôles signalent quand les champs clés d'un document manquent. Cette recette transforme ces signaux en ce qu'il faut demander au client, ne réessaie que si le fichier n'a jamais été lu, et laisse ce que le client a saisi primer sur ce qui a été lu.

<CodeGroup>
  ```javascript JavaScript theme={null}
  // Recette 5. Un champ n'a pas été lu. L'API n'a pas de confiance par champ : regardez donc ce qui est absent.
  import { extract, postJson } from "./common.mjs";

  // Ce dont les contrôles ont besoin par type de document (voir « Types de documents et champs »).
  const EXPECTED = {
    passport: ["first_name", "last_name", "date_of_birth", "id_number"],
    national_id: ["first_name", "last_name", "date_of_birth", "id_number"],
    drivers_license: ["first_name", "last_name", "id_number"],
    utility_bill: ["street1", "city", "postal_code", "document_holder_name"],
    bank_statement: ["bank_name", "document_holder_name"],
  };
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

  // Réessayer seulement si le fichier n'a jamais été lu (reader_unavailable). Une lecture vide n'est pas réessayée : envoyez un meilleur fichier.
  async function readWithRetry(args, tries = 3) {
    for (let i = 0; i < tries; i++) {
      const read = await extract(args);
      if (!read.reader_unavailable) return read;
      await sleep(2000 * 2 ** i); // 2 s, 4 s, 8 s
    }
    return null;
  }

  function problems(read) {
    const doc = read.documents[0];
    const out = [];
    for (const k of EXPECTED[doc.doc_type] ?? []) if (!doc.fields[k]) out.push({ field: k, ask: "type_it_or_rescan" });
    for (const c of read.checks) {
      if (c.passed || c.severity === "info") continue;
      if (c.id.startsWith("doctype:")) out.push({ check: c.id, ask: "upload_the_right_document", detail: c.detail });
      else if (c.id.startsWith("legible:")) out.push({ check: c.id, ask: "better_scan", detail: c.detail });
      else if (c.id.startsWith("expiry:")) out.push({ check: c.id, ask: "valid_document", detail: c.detail });
      else out.push({ check: c.id, ask: "review", detail: c.detail });
    }
    return out;
  }

  const reference = "fix-0001";
  const read = await readWithRetry({ file: "cin-test.jpg", mime: "image/jpeg", docType: "national_id", stepKey: "photo_id", reference });
  if (!read) {
    console.log("Reader not available after 3 tries. Queue the file and tell the client it is being processed.");
  } else {
    const todo = problems(read);
    console.log(todo.length ? { needs_attention: todo } : "all expected fields read");
    // Ce que la personne saisit prime sur ce qui a été lu. Envoyez-le dans `values` ; les entrées repartent inchangées.
    const typed = { id_number: "BK123456" }; // depuis votre formulaire, seulement pour les champs de `todo`
    const verdict = await postJson("/v1/kyc/verify", {
      reference, environment: "sandbox", kind: "individual",
      values: { ...read.fields, ...typed }, documents: read.documents,
    });
    console.log("passed:", verdict.passed);
  }
  ```

  ```python Python theme={null}
  # Recette 5. Un champ n'a pas été lu. L'API n'a pas de confiance par champ : regardez donc ce qui est absent.
  import os
  import time

  import requests

  BASE = "https://app.sahlfinancial.com/api"
  HEADERS = {"Authorization": f"Bearer {os.environ['SAHL_API_KEY']}"}

  # Ce dont les contrôles ont besoin par type de document (voir « Types de documents et champs »).
  EXPECTED = {
      "passport": ["first_name", "last_name", "date_of_birth", "id_number"],
      "national_id": ["first_name", "last_name", "date_of_birth", "id_number"],
      "drivers_license": ["first_name", "last_name", "id_number"],
      "utility_bill": ["street1", "city", "postal_code", "document_holder_name"],
      "bank_statement": ["bank_name", "document_holder_name"],
  }


  def read_with_retry(path, mime, doc_type, step_key, reference, tries=3):
      """Réessayer seulement si le fichier n'a jamais été lu (reader_unavailable). Une lecture vide n'est pas réessayée."""
      for i in range(tries):
          with open(path, "rb") as f:
              res = requests.post(
                  f"{BASE}/v1/kyc/extract", headers=HEADERS, timeout=120,
                  files=[("files", (path, f, mime))],
                  data={"doc_type": doc_type, "step_key": step_key, "reference": reference,
                        "environment": "sandbox", "kind": "individual"},
              )
          res.raise_for_status()
          read = res.json()
          if not read["reader_unavailable"]:
              return read
          time.sleep(2 * 2 ** i)  # 2 s, 4 s, 8 s
      return None


  def problems(read):
      doc = read["documents"][0]
      out = [{"field": k, "ask": "type_it_or_rescan"} for k in EXPECTED.get(doc["doc_type"], []) if not doc["fields"].get(k)]
      for c in read["checks"]:
          if c["passed"] or c["severity"] == "info":
              continue
          if c["id"].startswith("doctype:"):
              ask = "upload_the_right_document"
          elif c["id"].startswith("legible:"):
              ask = "better_scan"
          elif c["id"].startswith("expiry:"):
              ask = "valid_document"
          else:
              ask = "review"
          out.append({"check": c["id"], "ask": ask, "detail": c["detail"]})
      return out


  REFERENCE = "fix-0001"
  read = read_with_retry("cin-test.jpg", "image/jpeg", "national_id", "photo_id", REFERENCE)
  if read is None:
      print("Reader not available after 3 tries. Queue the file and tell the client it is being processed.")
  else:
      todo = problems(read)
      print({"needs_attention": todo} if todo else "all expected fields read")
      typed = {"id_number": "BK123456"}  # depuis votre formulaire, seulement pour les champs de `todo`
      res = requests.post(
          f"{BASE}/v1/kyc/verify", headers=HEADERS, timeout=60,
          json={"reference": REFERENCE, "environment": "sandbox", "kind": "individual",
                "values": {**read["fields"], **typed}, "documents": read["documents"]},
      )
      res.raise_for_status()
      print("passed:", res.json()["passed"])
  ```
</CodeGroup>

Quand une CIN revient sans `date_of_birth` ni `id_number`, le résultat est :

```text theme={null}
{ needs_attention: [
  { field: 'date_of_birth', ask: 'type_it_or_rescan' },
  { field: 'id_number', ask: 'type_it_or_rescan' },
  { check: 'legible:Government photo ID', ask: 'better_scan', detail: 'could not read: date_of_birth, id_number' }
] }
```

Règles à retenir :

| Situation | À faire |
| - | - |
| `reader_unavailable: true` | Réessayez avec une attente. La lecture a été décomptée. Si cela persiste, mettez le fichier en file d'attente et communiquez `X-Request-ID` à Sahl. |
| Clé absente, `reader_unavailable: false` | Le lecteur a vu le fichier et n'a rien trouvé. Réessayer le même fichier aide rarement. Demandez un meilleur scan ou une valeur saisie. |
| `doctype:` en échec | Le client a envoyé le mauvais document. Indiquez celui que l'étape attend (le détail le précise). |
| La valeur saisie diffère de la valeur lue | Votre valeur saisie va dans `values`. Les entrées `documents` restent inchangées : le fichier montre donc toujours ce qui a été lu. |


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