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

# End-to-end walkthrough

> A payslip and a Moroccan national ID (CIN) to a risk assessment: four calls, complete code in cURL, JavaScript and Python, and the output to expect.

This walkthrough takes a fake client, `Test Client`, from two documents to a risk assessment. It runs against the sandbox with the key you created in [Test in the sandbox](/test-in-sandbox).

## What you need

| Item | Detail |
| - | - |
| Key | Scopes `kyc:extract` and `kyc:verify`. In `SAHL_API_KEY`. |
| `payslip-test.pdf` | A fake payslip you made, with a made-up employer. Not a real person's. |
| `cin-test.jpg` | A fake CIN image you made. Do not use a specimen that carries the word SPECIMEN or a holder called `John Doe`: those are caught on purpose (see [specimen check](/guides/verification#per-document)). |
| Tools | `curl` and `jq`, or Node 18 or later, or Python 3 with `requests`. |

Use fake data only. These calls send a `reference`, so the files are filed on your own case instead.

## The plan

```mermaid theme={null}
flowchart LR
    A[payslip-test.pdf] -->|extract| B[fields: employer, occupation]
    C[cin-test.jpg] -->|extract| D[fields: name, date of birth, ID number, expiry]
    B --> E[values + documents]
    D --> E
    F[Your form: income, answers] --> E
    E -->|assess| G[verification + assessment]
```

1. `POST /v1/kyc/extract` with the payslip.
2. `POST /v1/kyc/extract` with the CIN.
3. Build `values` from what was read plus what the client declared.
4. `POST /v1/kyc/assess` with `values` and the `documents` entries unchanged.

Why two reads and not one: `doc_type` applies to every file in a call, so one document type per call.

Why a CIN in a payslip walkthrough: with the default rules a verification needs a government photo ID among the documents (`required:photo_id` is critical). A payslip alone would be blocked. A payslip is supporting evidence, not identity.

Why `annual_income` is typed in step 3: the reader is told never to estimate. A payslip shows one period's pay, so `annual_income` comes back only if the payslip prints it. The walkthrough takes income from the client's own application form, and the payslip supplies the employer and the occupation.

## Complete code

<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 and 2. Read the payslip and the 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. Build the request body. Income and answers come from your own form.
  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. Assess.
  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 and 2. Read the payslip and the 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. Build the profile. Read values come from /extract. Income and answers come from your own form.
  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", // declared by the client: the payslip did not state it
    net_liquid_assets: "20000",
    total_net_worth: "60000",
    objective: "Balanced",
    horizon: "5-10 years",
    investment_knowledge: "Good",
    investment_experience: "< 5 years",
  };

  // 4. Assess: verification plus the risk assessment.
  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], // unchanged, strongest ID first
    }),
  });

  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 and 2. Read the payslip and the 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. Build the profile. Read values come from /extract. Income and answers come from your own form.
  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",  # declared by the client: the payslip did not state it
      "net_liquid_assets": "20000",
      "total_net_worth": "60000",
      "objective": "Balanced",
      "horizon": "5-10 years",
      "investment_knowledge": "Good",
      "investment_experience": "< 5 years",
  }

  # 4. Assess: verification plus the risk assessment.
  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"],  # unchanged, strongest ID first
      },
      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>

Run it:

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

All three versions were run against a local stand-in server that returns canned `/extract` answers and runs Sahl's verification and scoring code on the body they send, so the printed numbers below are what the engine computes for this input. The live reader will return the fields it sees on your files, which can differ.

## Expected output

```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` is a real id in your answer. The order of the payslip fields can differ. In a production environment with the full sanctions list loaded, the screening line is the info check `Sanctions screening — no matches; PEP not list-screened`. If your environment only has the 30-name sample list, `flags` also shows `screening` and compliance risk becomes `Medium` (2 points).

## What each step returned

### 1. Payslip

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

A payslip has no document checks, so `checks` is empty. It gives employer, occupation and the holder name. It is also what `kyc.documents_read` reports on.

### 2. National ID (CIN)

`checks` shows four passes: its key fields were read, it is not expired, the CIN number is well-formed (`format:cin:`, one or two letters then five to seven digits, for example `BK123456`) and the holder is an adult. The sample uses country `MA` and `id_type` `National ID`, which the engine accepts as they are.

### 4. Assessment

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

Shortened. The full answer repeats every check and the policy block.

How to read it:

* `passed: true`: no critical check failed.
* The single flag is `completeness` at 61 percent: the walkthrough sent no address, phone or email, no PEP answer. A person should fill them in. The engine's list of required data points is North American: it also asks for a `province` and a `sin/ssn`, which a Moroccan file cannot always give, so a Moroccan file stays below 100 percent. It also costs one risk point, so compliance risk is `Low` (the ceiling for Low is 1 point).
* Risk profile 58 is the arithmetic in [Risk assessment](/guides/risk-assessment#risk-tolerance). Capacity 20 comes from income 84,000, liquid assets 20,000 and net worth 60,000, read here as MAD. The engine reads amounts as plain numbers against fixed bands that are not currency specific.

## Try the failure paths

| Change | Result |
| - | - |
| Set the CIN's expiry in your file to a past date | `expiry:national_id` and `expiry:recorded:id_expiry` fail with `expired YYYY-MM-DD`, `passed` is `false`, compliance risk is `High` and suitability is `Blocked — document verification failed`. |
| Drop the CIN from `documents` | `required:photo_id` fails: `no readable government photo ID among the uploads`. |
| Remove `objective` from `values` | `risk_profile` has `score: null`, `band: null`, `missing: ["objective"]`, and suitability becomes `Incomplete — suitability answers missing` (if nothing else matched first). |
| Send a stale bank statement as proof of address | `recency:*` fails. Critical on a proof-of-address step, a warning elsewhere. |

## In the console

Open **Cases**, find `client-0001` in `sandbox`. The documents, the checks and the verdict are filed there. The same calls are in **Developers, Call log**.

## Next

<CardGroup cols={2}>
  <Card title="Recipes" icon="book-open" href="/guides/recipes">Five working use cases.</Card>
  <Card title="Go live" icon="rocket" href="/go-live">What to check before production.</Card>
</CardGroup>


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