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

# Parcours complet

> D'une fiche de paie et d'une carte nationale d'identité (CIN) à une évaluation du risque : quatre appels, du code complet en cURL, JavaScript et Python, et le résultat attendu.

Ce parcours mène un faux client, `Test Client`, de deux documents à une évaluation du risque. Il s'exécute sur la sandbox avec la clé que vous avez créée dans [Tester en sandbox](/fr/test-in-sandbox).

## Ce dont vous avez besoin

| Élément | Détail |
| - | - |
| Clé | Scopes `kyc:extract` et `kyc:verify`. Dans `SAHL_API_KEY`. |
| `payslip-test.pdf` | Une fausse fiche de paie que vous avez fabriquée, avec un employeur inventé. Pas celle d'une vraie personne. |
| `cin-test.jpg` | Une fausse image de CIN que vous avez fabriquée. N'utilisez pas un spécimen portant le mot SPECIMEN ou un titulaire nommé `John Doe` : ils sont détectés volontairement (voir le [contrôle de spécimen](/fr/guides/verification#par-document)). |
| Outils | `curl` et `jq`, ou Node 18 ou supérieur, ou Python 3 avec `requests`. |

N'utilisez que des données fictives. Ces appels envoient une `reference` : les fichiers sont donc classés dans votre propre dossier.

## Le plan

```mermaid theme={null}
flowchart LR
    A[payslip-test.pdf] -->|extract| B[champs : employeur, profession]
    C[cin-test.jpg] -->|extract| D[champs : nom, date de naissance, numéro de pièce, expiration]
    B --> E[values + documents]
    D --> E
    F[Votre formulaire : revenu, réponses] --> E
    E -->|assess| G[vérification + évaluation]
```

1. `POST /v1/kyc/extract` avec la fiche de paie.
2. `POST /v1/kyc/extract` avec la CIN.
3. Construisez `values` à partir de ce qui a été lu et de ce que le client a déclaré.
4. `POST /v1/kyc/assess` avec `values` et les entrées `documents` inchangées.

Pourquoi deux lectures et non une : `doc_type` s'applique à tous les fichiers d'un appel, donc un type de document par appel.

Pourquoi une CIN dans un parcours sur la fiche de paie : avec les règles par défaut, une vérification exige une pièce d'identité officielle avec photo parmi les documents (`required:photo_id` est critique). Une fiche de paie seule serait bloquée. Une fiche de paie est un justificatif, pas une preuve d'identité.

Pourquoi `annual_income` est saisi à l'étape 3 : on demande au lecteur de ne jamais estimer. Une fiche de paie montre la paie d'une période : `annual_income` ne revient donc que si la fiche l'imprime. Le parcours prend le revenu dans le formulaire de demande du client, et la fiche de paie fournit l'employeur et la profession.

## Code complet

<CodeGroup>
  ```bash cURL theme={null}
  #!/usr/bin/env bash
  set -euo pipefail
  BASE="https://app.sahlfinancial.com/api"
  REF="client-0001"

  read_doc() { # file, mime, doc_type
    curl -sS --fail-with-body -X POST "$BASE/v1/kyc/extract" \
      -H "Authorization: Bearer $SAHL_API_KEY" \
      -F "files=@$1;type=$2" -F "doc_type=$3" -F "reference=$REF" \
      -F "environment=sandbox" -F "subject=Test Client" -F "kind=individual"
  }

  # 1 et 2. Lire la fiche de paie et la CIN.
  read_doc payslip-test.pdf application/pdf payslip > payslip.json
  read_doc cin-test.jpg image/jpeg national_id > cin.json
  jq -r '"payslip fields: " + (.fields | keys | join(", "))' payslip.json
  jq -r '"ID checks: " + ([.checks[] | "\(.id)=\(.passed)"] | join(" "))' cin.json

  # 3. Construire le corps de la requête. Le revenu et les réponses viennent de votre formulaire.
  jq -n --slurpfile pass cin.json --slurpfile slip payslip.json '
    ($slip[0].fields + $pass[0].fields) as $r | {
      reference: "client-0001", environment: "sandbox", subject: "Test Client", kind: "individual",
      values: {
        first_name: $r.first_name, last_name: $r.last_name, date_of_birth: $r.date_of_birth,
        citizenship: $r.citizenship, country: "MA", id_type: $r.id_type, id_number: $r.id_number,
        id_expiry: $r.id_expiry, occupation: $r.occupation, employer_name: $r.employer_name,
        annual_income: "84000", net_liquid_assets: "20000", total_net_worth: "60000",
        objective: "Balanced", horizon: "5-10 years",
        investment_knowledge: "Good", investment_experience: "< 5 years"
      },
      documents: ($pass[0].documents + $slip[0].documents)
    }' > assess-body.json

  # 4. Évaluer.
  curl -sS --fail-with-body -X POST "$BASE/v1/kyc/assess" \
    -H "Authorization: Bearer $SAHL_API_KEY" -H "Content-Type: application/json" \
    -d @assess-body.json > assess.json

  jq -r '"passed: \(.verification.passed)",
         "flags: \([.verification.flags[].id] | join(", "))",
         "risk profile: \(.assessment.risk_profile.score) \(.assessment.risk_profile.band)",
         "capacity: \(.assessment.capacity.score) \(.assessment.capacity.band)",
         "compliance risk: \(.assessment.compliance_risk.level) (\(.assessment.compliance_risk.score) points)",
         "suitability: \(.assessment.suitability)",
         "case: \(.case_id)"' assess.json
  ```

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

  const BASE = "https://app.sahlfinancial.com/api";
  const KEY = process.env.SAHL_API_KEY;
  const REFERENCE = "client-0001";

  async function call(path, init) {
    const res = await fetch(`${BASE}${path}`, {
      ...init,
      headers: { Authorization: `Bearer ${KEY}`, ...init.headers },
    });
    const text = await res.text();
    if (!res.ok) throw new Error(`${path} -> ${res.status} ${text}`);
    return JSON.parse(text);
  }

  async function extract(file, mime, docType) {
    const form = new FormData();
    form.append("files", new Blob([await readFile(file)], { type: mime }), file);
    form.append("doc_type", docType);
    form.append("reference", REFERENCE);
    form.append("environment", "sandbox");
    form.append("subject", "Test Client");
    form.append("kind", "individual");
    return call("/v1/kyc/extract", { method: "POST", body: form });
  }

  // 1 et 2. Lire la fiche de paie et la CIN.
  const payslip = await extract("payslip-test.pdf", "application/pdf", "payslip");
  const cin = await extract("cin-test.jpg", "image/jpeg", "national_id");
  console.log("payslip fields:", Object.keys(payslip.fields).join(", "));
  console.log("ID checks:", cin.checks.map((c) => `${c.id}=${c.passed}`).join(" "));
  if (payslip.reader_unavailable || cin.reader_unavailable) throw new Error("a file was not read, retry later");

  // 3. Construire le profil. Les valeurs lues viennent de /extract. Le revenu et les réponses viennent de votre formulaire.
  const read = { ...payslip.fields, ...cin.fields };
  const values = {
    first_name: read.first_name,
    last_name: read.last_name,
    date_of_birth: read.date_of_birth,
    citizenship: read.citizenship,
    country: "MA",
    id_type: read.id_type,
    id_number: read.id_number,
    id_expiry: read.id_expiry,
    occupation: read.occupation,
    employer_name: read.employer_name,
    annual_income: "84000", // déclaré par le client : la fiche de paie ne l'indiquait pas
    net_liquid_assets: "20000",
    total_net_worth: "60000",
    objective: "Balanced",
    horizon: "5-10 years",
    investment_knowledge: "Good",
    investment_experience: "< 5 years",
  };

  // 4. Évaluer : vérification et évaluation du risque.
  const result = await call("/v1/kyc/assess", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      reference: REFERENCE,
      environment: "sandbox",
      subject: "Test Client",
      kind: "individual",
      values,
      documents: [...cin.documents, ...payslip.documents], // inchangées, pièce d'identité la plus forte en premier
    }),
  });

  const { verification, assessment } = result;
  console.log("passed:", verification.passed);
  console.log("flags:", verification.flags.map((f) => f.id).join(", ") || "none");
  console.log("risk profile:", assessment.risk_profile.score, assessment.risk_profile.band);
  console.log("capacity:", assessment.capacity.score, assessment.capacity.band);
  console.log("compliance risk:", assessment.compliance_risk.level, `(${assessment.compliance_risk.score} points)`);
  console.log("suitability:", assessment.suitability);
  console.log("case:", result.case_id);
  ```

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

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


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


  # 1 et 2. Lire la fiche de paie et la CIN.
  payslip = extract("payslip-test.pdf", "application/pdf", "payslip")
  cin = extract("cin-test.jpg", "image/jpeg", "national_id")
  print("payslip fields:", ", ".join(payslip["fields"]))
  print("ID checks:", " ".join(f"{c['id']}={c['passed']}" for c in cin["checks"]))
  if payslip["reader_unavailable"] or cin["reader_unavailable"]:
      raise SystemExit("a file was not read, retry later")

  # 3. Construire le profil. Les valeurs lues viennent de /extract. Le revenu et les réponses viennent de votre formulaire.
  read = {**payslip["fields"], **cin["fields"]}
  values = {
      "first_name": read["first_name"],
      "last_name": read["last_name"],
      "date_of_birth": read["date_of_birth"],
      "citizenship": read["citizenship"],
      "country": "MA",
      "id_type": read["id_type"],
      "id_number": read["id_number"],
      "id_expiry": read["id_expiry"],
      "occupation": read["occupation"],
      "employer_name": read["employer_name"],
      "annual_income": "84000",  # déclaré par le client : la fiche de paie ne l'indiquait pas
      "net_liquid_assets": "20000",
      "total_net_worth": "60000",
      "objective": "Balanced",
      "horizon": "5-10 years",
      "investment_knowledge": "Good",
      "investment_experience": "< 5 years",
  }

  # 4. Évaluer : vérification et évaluation du risque.
  res = requests.post(
      f"{BASE}/v1/kyc/assess",
      headers=HEADERS,
      json={
          "reference": REFERENCE,
          "environment": "sandbox",
          "subject": "Test Client",
          "kind": "individual",
          "values": values,
          "documents": cin["documents"] + payslip["documents"],  # inchangées, pièce d'identité la plus forte en premier
      },
      timeout=60,
  )
  res.raise_for_status()
  result = res.json()

  verification, assessment = result["verification"], result["assessment"]
  print("passed:", verification["passed"])
  print("flags:", ", ".join(f["id"] for f in verification["flags"]) or "none")
  print("risk profile:", assessment["risk_profile"]["score"], assessment["risk_profile"]["band"])
  print("capacity:", assessment["capacity"]["score"], assessment["capacity"]["band"])
  print("compliance risk:", assessment["compliance_risk"]["level"], f"({assessment['compliance_risk']['score']} points)")
  print("suitability:", assessment["suitability"])
  print("case:", result["case_id"])
  ```
</CodeGroup>

Exécutez-le :

```bash theme={null}
export SAHL_API_KEY="paste your key here"
bash walkthrough.sh        # ou : node walkthrough.mjs   ou : python3 walkthrough.py
```

Les trois versions 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 le corps envoyé : les nombres affichés ci-dessous sont donc ceux que le moteur calcule pour cette entrée. Le lecteur réel renvoie les champs qu'il voit sur vos fichiers, ce qui peut différer.

## Résultat attendu

```text theme={null}
payslip fields: first_name, last_name, document_holder_name, employer_name, occupation, document_date
ID checks: legible:national_id=true expiry:national_id=true format:cin:national_id=true adult:national_id=true
passed: true
flags: completeness
risk profile: 58 Balanced
capacity: 20 Low
compliance risk: Low (1 points)
suitability: Suitable
case: 00000000-0000-4000-8000-000000000001
```

`case` est un vrai identifiant dans votre réponse. L'ordre des champs de la fiche de paie peut différer. Dans un environnement de production avec la liste de sanctions complète chargée, la ligne de filtrage est le contrôle d'information `Sanctions screening — no matches; PEP not list-screened`. Si votre environnement n'a que la liste d'exemple de 30 noms, `flags` affiche aussi `screening` et le risque de conformité passe à `Medium` (2 points).

## Ce que chaque étape a renvoyé

### 1. Fiche de paie

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

Une fiche de paie n'a pas de contrôles de document : `checks` est donc vide. Elle fournit l'employeur, la profession et le nom du titulaire. C'est aussi sur elle que porte `kyc.documents_read`.

### 2. Carte nationale (CIN)

`checks` affiche quatre réussites : ses champs clés ont été lus, il n'est pas expiré, le numéro de CIN est bien formé (`format:cin:`, une ou deux lettres puis cinq à sept chiffres, par exemple `BK123456`) et le titulaire est majeur. L'exemple utilise le pays `MA` et `id_type` `National ID`, que le moteur accepte tels quels.

### 4. Évaluation

```json theme={null}
{
  "verification": {
    "passed": true,
    "critical_failures": [],
    "flags": [
      { "id": "completeness", "label": "KYC/KYB data completeness (61%)", "severity": "warning", "passed": false,
        "detail": "missing 11 required data point(s): street1, city, province, postal_code, phone, email, source_of_funds, account_type" }
    ],
    "completeness": { "required": 28, "present": 17, "percent": 61 }
  },
  "assessment": {
    "risk_profile": { "score": 58, "band": "Balanced", "missing": [] },
    "capacity": { "score": 20, "band": "Low", "missing": [] },
    "compliance_risk": { "level": "Low", "score": 1, "factors": ["Flag: KYC/KYB data completeness (61%) (missing 11 required data point(s): ...)"] },
    "suitability": "Suitable",
    "risk_level": "Balanced"
  },
  "registry": null,
  "case_id": "00000000-0000-4000-8000-000000000001"
}
```

Abrégé. La réponse complète répète chaque contrôle et le bloc de politique.

Comment la lire :

* `passed: true` : aucun contrôle critique n'a échoué.
* Le seul drapeau est `completeness` à 61 % : le parcours n'a envoyé ni adresse, ni téléphone, ni email, ni réponse sur la PPE. Une personne doit les compléter. La liste des données requises du moteur est nord-américaine : elle demande aussi une `province` et un `sin/ssn`, qu'un dossier marocain ne peut pas toujours fournir, donc un dossier marocain reste sous 100 %. Cela coûte aussi un point de risque : le risque de conformité est donc `Low` (le plafond de Low est de 1 point).
* Le profil de risque 58 est le calcul décrit dans [Évaluation du risque](/fr/guides/risk-assessment#tolérance-au-risque). La capacité 20 vient d'un revenu de 84 000, d'actifs liquides de 20 000 et d'un patrimoine net de 60 000, lus ici en MAD. Le moteur lit les montants comme de simples nombres, avec des tranches fixes qui ne dépendent pas de la devise.

## Essayer les cas d'échec

| Changement | Résultat |
| - | - |
| Mettre l'expiration de la CIN de votre fichier à une date passée | `expiry:national_id` et `expiry:recorded:id_expiry` échouent avec `expired YYYY-MM-DD`, `passed` vaut `false`, le risque de conformité est `High` et l'adéquation est `Blocked — document verification failed`. |
| Retirer la CIN de `documents` | `required:photo_id` échoue : `no readable government photo ID among the uploads`. |
| Retirer `objective` de `values` | `risk_profile` a `score: null`, `band: null`, `missing: ["objective"]`, et l'adéquation devient `Incomplete — suitability answers missing` (si rien d'autre n'a correspondu avant). |
| Envoyer un relevé bancaire trop ancien comme justificatif de domicile | `recency:*` échoue. Critique sur une étape de justificatif de domicile, avertissement ailleurs. |

## Dans la console

Ouvrez **Cases** et trouvez `client-0001` dans `sandbox`. Les documents, les contrôles et le verdict y sont classés. Les mêmes appels figurent dans **Developers, Call log**.

## Suite

<CardGroup cols={2}>
  <Card title="Recettes" icon="book-open" href="/fr/guides/recipes">Cinq cas d'usage fonctionnels.</Card>
  <Card title="Mise en production" icon="rocket" href="/fr/go-live">Ce qu'il faut vérifier avant la production.</Card>
</CardGroup>


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