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

# Recipes

> Five real use cases with working code: loan file, account opening, income check, eID, and a field that was not read.

Each recipe is a complete script in JavaScript and Python. They use fake clients and the sandbox. Run them as they are after you put your own files next to them. The JavaScript recipes share one small helper, shown first.

All recipes were run against a local stand-in server that returns canned `/extract` answers and runs Sahl's verification and scoring code on the bodies they send (the eID recipe polls a stand-in that answers `pending`, then `complete`). They were not run against the live API.

## Helper for the JavaScript recipes

Save as `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();
}
```

The helper throws an error that carries `status`, `body` and `requestId` (the `X-Request-ID` header). Quote the request id when you write to Sahl.

## 1. Loan file onboarding

An applicant sends an ID, a proof of address and a payslip. You want one verdict for the file and a route: continue, human review, or refuse.

| Step | Call | Notes |
| - | - | - |
| Read the ID | `/extract`, `doc_type=national_id`, `step_key=photo_id` | Identity first: the first non-empty value wins when fields are merged. |
| Read the proof of address | `/extract`, `doc_type=utility_bill`, `step_key=proof_of_address` | Recency is critical here: 90 days by default. |
| Read the payslip | `/extract`, `doc_type=payslip` | Fields only. |
| Verdict | `/verify` with all `documents` entries unchanged | |

<CodeGroup>
  ```javascript JavaScript theme={null}
  // Recipe 1. Loan file onboarding: ID, proof of address, payslip, then one 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 })); // identity first

  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()); // first file wins
  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), // unchanged
  });

  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}
  # Recipe 1. Loan file onboarding: ID, proof of address, payslip, then one verdict.
  import os
  import requests

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

  FILES = [  # identity first
      ("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:  # first file wins, as in the API's own merge
      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"]],  # unchanged
      },
  )
  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>

Output for the fake file (no street, phone or email in `values`, so completeness is below 100 percent):

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

How it decides: a critical failure (`passed: false`) means refuse or fix. Flags without a critical failure mean a person reviews. Nothing at all means continue. These routes are your policy, not Sahl's.

## 2. Account opening file

The client fills your application form and uploads a photo ID. You verify the whole file, fold in your own duplicate check, and keep the `case_id`.

<CodeGroup>
  ```javascript JavaScript theme={null}
  // Recipe 2. KYC for an account opening: application form + photo ID + your own duplicate check.
  import { extract, postJson } from "./common.mjs";

  const reference = "acct-0001";
  const form = {                       // what the client typed in your application
    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.");

  // The ID you read is the source of truth for the identity fields.
  const values = { ...form, ...pick(id.fields, ["first_name", "last_name", "date_of_birth", "id_type", "id_number", "id_expiry"]) };

  const duplicate = await isDuplicateInMyDatabase(values); // your own lookup
  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}
  # Recipe 2. KYC for an account opening: application form + photo ID + your own duplicate check.
  import os
  import requests

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

  form = {  # what the client typed in your application
      "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):  # your own lookup
      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.")

  # The ID you read is the source of truth for the identity fields.
  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>

Output for the fake data, which fills every required data point except `sin/ssn`. The engine's list of required data points is North American and a Moroccan client has no SIN, so `sin/ssn` stays in `missing` and completeness is 96 percent. That is above the 80 percent warning line, so nothing is flagged:

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

Points to copy:

* The recipe overwrites the form's identity values with the ones read from the ID. If you would rather catch a typo in the form, leave the form's names in `values` and let `consistency:profile_name_id` compare them with the ID (critical when they differ).
* A failing `extra_checks` entry with severity `critical` makes `passed` false, so your own rule can block the file.
* `policy.overrides_refused` is empty unless you sent a switch your workspace policy locks.
* For an entity, use `kind: "corporation"` (or another entity kind), send `legal_name`, `business_number`, `director_names` and `beneficial_owners`, and read the constituting document with `step_key=articles_of_incorporation`. See the [required data points](/guides/verification#values).

## 3. Income check from a payslip

The client declares an income and sends a payslip. The API reads the payslip. It sets no income rule, and it returns a figure only when the payslip prints one. So the rules here are yours: holder, employer, date, and income if stated.

<CodeGroup>
  ```javascript JavaScript theme={null}
  // Recipe 3. Does this payslip support the income the client declared?
  // The API reads the payslip. The rules below are yours: the API sets no income rule.
  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");

  // Payslip recency is not checked by the API (only address documents are). Your rule: 90 days.
  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");

  // Income: only when the payslip states it. Otherwise the API has nothing to compare.
  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");
  }

  // File-level signals the API already computed.
  for (const c of read.checks) if (!c.passed && c.severity !== "info") findings.push(c.id);

  // Ask for the capacity band the declared income gives, without any document check.
  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}
  # Recipe 3. Does this payslip support the income the client declared?
  # The API reads the payslip. The rules below are yours: the API sets no income rule.
  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")

  # Payslip recency is not checked by the API (only address documents are). Your rule: 90 days.
  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")

  # Income: only when the payslip states it. Otherwise the API has nothing to compare.
  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")

  # File-level signals the API already computed.
  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>

Output for the fake payslip, which does not print an annual figure:

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

What this recipe relies on:

* `annual_income` comes back only if the payslip states it. The reader is told never to estimate.
* The API checks the age of address documents, not payslips. The 90 days here are your rule.
* `capacity` uses the declared income only. With `net_liquid_assets` and `total_net_worth` missing the score is pulled down; send them if you have them. See [Capacity](/guides/risk-assessment#capacity).

## 4. eID check

A Canadian client opens an account remotely. eID covers Canadian clients only in this version, so this recipe is the one that keeps country `CA`. You start the check, wait for the client, keep the PDF report, then verify under the same reference.

<Warning>
  This sends a real email through the eID provider, in sandbox too. Replace the address with one you control. Your workspace needs its own eID provider account.
</Warning>

<CodeGroup>
  ```javascript JavaScript theme={null}
  // Recipe 4. eID check: start, poll until the client finishes, keep the PDF, then verify.
  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. Start. The client is emailed now, even in sandbox. Use an address you control.
  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. Poll: every 30 s for 10 minutes, then every 5 minutes, for up to 24 hours.
  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. Keep the report within about seven days: the provider then deletes the personal details.
  const pdf = Buffer.from(await (await sahl(`/v1/kyc/eid/${started.key}/report`)).arrayBuffer());
  await writeFile(`eid-${started.key}.pdf`, pdf);

  // 4. Verify with the SAME reference and environment, so a policy eID requirement can be met.
  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}
  # Recipe 4. eID check: start, poll until the client finishes, keep the PDF, then verify.
  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. Start. The client is emailed now, even in sandbox. Use an address you control.
  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. Poll: every 30 s for 10 minutes, then every 5 minutes, for up to 24 hours.
  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. Keep the report within about seven days: the provider then deletes the personal details.
  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. Verify with the SAME reference and environment, so a policy eID requirement can be met.
  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>

Output, with a client who finishes after the third poll:

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

Keep the PDF: the provider deletes the personal details after about seven days. See [eID check](/guides/eid).

## 5. Handle a field the reader did not read

The API returns no per-field confidence. A field is either in `fields` or it is not, and the checks say when the key fields of a document are missing. This recipe turns those signals into what to ask the client, retries only when the file was never read, and lets what the client typed override what was read.

<CodeGroup>
  ```javascript JavaScript theme={null}
  // Recipe 5. A field was not read. The API has no per-field confidence, so look at what is absent.
  import { extract, postJson } from "./common.mjs";

  // What the checks need per document type (see "Document types and fields").
  const EXPECTED = {
    passport: ["first_name", "last_name", "date_of_birth", "id_number"],
    national_id: ["first_name", "last_name", "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));

  // Retry only when the file was never read (reader_unavailable). A blank read is not retried: send a better file.
  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");
    // What the person types overrides what was read. Send it in `values`; the entries go back unchanged.
    const typed = { id_number: "BK123456" }; // from your form, only for the fields in `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}
  # Recipe 5. A field was not read. The API has no per-field confidence, so look at what is absent.
  import os
  import time

  import requests

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

  # What the checks need per document type (see "Document types and fields").
  EXPECTED = {
      "passport": ["first_name", "last_name", "date_of_birth", "id_number"],
      "national_id": ["first_name", "last_name", "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):
      """Retry only when the file was never read (reader_unavailable). A blank read is not retried."""
      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"}  # from your form, only for the fields in `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>

When a CIN comes back without `id_number`, the output is:

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

Rules to keep:

| Situation | Do |
| - | - |
| `reader_unavailable: true` | Retry with a wait. The read counted. If it persists, queue the file and quote `X-Request-ID` to Sahl. |
| Key absent, `reader_unavailable: false` | The reader saw the file and found nothing. Retrying the same file rarely helps. Ask for a better scan or a typed value. |
| `doctype:` failed | The client uploaded the wrong document. Say which one the step takes (the detail has it). |
| Typed value differs from the read value | Your typed value goes in `values`. The `documents` entries stay unchanged, so the file still shows what was read. |


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