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

# Risk assessment

> POST /v1/kyc/assess: inputs, point tables, bands and the suitability rule, exactly as the code computes them.

`POST /v1/kyc/assess` takes the [same request as `/verify`](/guides/verification#request), runs the same verification, and builds an assessment on top of it. Scope: `kyc:verify`. It is not a credit score. It returns four read-outs for an advisor: risk tolerance, financial capacity, compliance risk and suitability.

Every number on this page is computed by a deterministic function of `values` and the verdict. Same input, same output.

## Response

```json theme={null}
{
  "verification": { "passed": true, "checks": [], "critical_failures": [], "flags": [], "completeness": {}, "policy": {} },
  "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",
    "verification": { "passed": true }
  },
  "registry": null,
  "case_id": "00000000-0000-4000-8000-000000000001"
}
```

`verification` is shortened here; it is the full verdict of `/verify`. The assessment also repeats the verdict under `assessment.verification`. The example is the engine's real output for fake data (see the [walkthrough](/guides/walkthrough)).

| Key | Meaning |
| - | - |
| `verification` | The verdict of `/verify`, with `policy`. |
| `assessment.risk_profile` | The client's risk tolerance, 0 to 100, and its band. |
| `assessment.capacity` | The client's financial capacity, 0 to 100, and its band. |
| `assessment.compliance_risk` | AML and due-diligence rating: `level`, `score` in points, `factors` in words. |
| `assessment.suitability` | One sentence from a fixed list. |
| `assessment.risk_level` | The `risk_profile` band. It is **not** the compliance level. |
| `registry`, `case_id` | As in `/verify`. |

## Risk tolerance

Needs all four answers in `values`. If any is empty, `score` and `band` are `null` and `missing` lists the absent keys. No partial score is made and no default is assumed.

| Input | Accepted values and points |
| - | - |
| `objective` (case-insensitive) | `capital preservation` 5, `income` 25, `balanced` 50, `growth` 78, `aggressive growth` 95 |
| `horizon` | `< 3 years` 12, `3-5 years` 38, `5-10 years` 68, `> 10 years` 92 |
| `investment_knowledge` | `None` 10, `Limited` 40, `Good` 70, `Excellent` 92 |
| `investment_experience` | `None` 15, `< 5 years` 50, `> 5 years` 85 |

An answer that is present but not in the table scores a default: objective 50, horizon 50, knowledge 40, experience 40. Use the exact strings above.

```text theme={null}
score = objective x 0.35 + horizon x 0.25 + knowledge x 0.20 + experience x 0.20
```

If `uses_leverage` is `true` (boolean), `"true"` or `"Yes"`, add 8. The result is rounded and held between 0 and 100.

| Score | Band |
| - | - |
| under 20 | Conservative |
| 20 to 39 | Moderate |
| 40 to 59 | Balanced |
| 60 to 79 | Growth |
| 80 and over | Aggressive |

Example: balanced (50), 5-10 years (68), Good (70), under 5 years (50) gives 50 x 0.35 + 68 x 0.25 + 70 x 0.20 + 50 x 0.20 = 58.5, rounded to 58, `Balanced`.

## Capacity

Needs at least one of `annual_income`, `net_liquid_assets`, `total_net_worth`. With none, `score` and `band` are `null`. With one or two, the score is computed and `missing` lists the others. A missing amount counts as a value of 0, which falls in the lowest bracket (sub-score 12 or 15), so a missing answer pulls the score down. Values are parsed from strings such as `84000`, `150,000`, `$1.2M` or `84k`.

| Annual income | Sub-score | Net liquid assets | Sub-score | Total net worth | Sub-score |
| - | - | - | - | - | - |
| under 50,000 | 15 | under 50,000 | 12 | under 100,000 | 15 |
| under 100,000 | 35 | under 250,000 | 35 | under 500,000 | 38 |
| under 250,000 | 55 | under 1,000,000 | 60 | under 2,000,000 | 62 |
| under 1,000,000 | 80 | under 5,000,000 | 82 | under 10,000,000 | 85 |
| 1,000,000 and over | 95 | 5,000,000 and over | 96 | 10,000,000 and over | 97 |

```text theme={null}
score = income x 0.30 + liquid x 0.35 + net worth x 0.35
```

| Score | Band |
| - | - |
| under 30 | Low |
| 30 to 54 | Moderate |
| 55 to 79 | High |
| 80 and over | Very High |

Example: income 84,000 (35), liquid 20,000 (12), net worth 60,000 (15) gives 10.5 + 4.2 + 5.25 = 19.95, rounded to 20, `Low`. The sample amounts are read as MAD. The engine reads amounts as plain numbers against fixed bands that are not currency specific, so 84,000 scores the same in any currency.

The API does not convert currencies. The brackets are in the unit you send.

## Compliance risk

Points add up from the profile and the verdict.

| Factor | Points |
| - | - |
| Politically exposed person: any of `pep`, `pep_foreign`, `pep_domestic`, `pep_hio` equal to `yes` (any case), or a PEP match from screening | 2 (once, even if both) |
| Geography: the worst tier among `citizenships` (or `citizenship`), `country`, and `entity_country` (falling back on `country`) | prohibited 6, high 3, elevated 1, standard 0 |
| `high_risk_jurisdiction` equal to `yes` | 1 |
| `industry` is `Virtual assets / Crypto`, `Money services business`, `Gaming` or `Cannabis` | 2 |
| `third_party` equal to `yes` | 1 |
| Each failed critical check in the verdict | 3, and the level is forced to High |
| Each failed warning check in the verdict | 1 |

| Level | Rule |
| - | - |
| High | A critical check failed, or points above 3 |
| Medium | 2 or 3 points |
| Low | 0 or 1 point |

A workspace policy can lower the Low ceiling (default 1) and the Medium ceiling (default 3), never raise them. `factors` lists each contribution in words, for example `Flag: <label> (<detail>)` or `Verification failed: <label> (<detail>)`.

Info checks add nothing. Note that a warning counts even if it is one the client cannot fix, such as `completeness` below 80 percent: a thin file scores one point.

### Geography tiers

Derived from the FATF public lists as of 19 June 2026 (`FATF_LISTS_AS_OF`). The set is a snapshot in the code and is refreshed after each FATF plenary. Codes are ISO alpha-2. A few alpha-3 codes and names are understood (`IRN`, `iran`, `usa`, `canada`). Anything else counts as standard.

| Tier | Points | Codes |
| - | - | - |
| prohibited | 6 | `IR`, `KP`, `MM`, `SY`, `CU` |
| high | 3 | `AO`, `BO`, `BA`, `BG`, `CM`, `CI`, `CD`, `HT`, `IQ`, `KE`, `KW`, `LA`, `LB`, `MC`, `NP`, `PG`, `SS`, `VE`, `VN`, `VG`, `YE` |
| elevated | 1 | `PA`, `SC`, `KY`, `BZ` |
| standard | 0 | every other code |

A prohibited country alone scores 6, which is High. Do not hard-code this table in your application: it changes with each FATF publication.

## Suitability

The first rule that matches wins.

| Order | Rule | `suitability` |
| - | - | - |
| 1 | `verification.passed` is false | `Blocked — document verification failed` |
| 2 | risk tolerance score is 75 or more and capacity score is under 40 | `Review — objective exceeds capacity` |
| 3 | compliance level is High | `Enhanced due diligence required` |
| 4 | risk tolerance withheld | `Incomplete — suitability answers missing` |
| 5 | capacity withheld | `Incomplete — financial capacity answers missing` |
| 6 | otherwise | `Suitable` |

`Suitable` is a read-out for an advisor, not a regulatory determination of suitability. The firm makes that determination.

## Worked examples

A complete, clean file gives:

```json theme={null}
{
  "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): street1, city, province, postal_code, phone, email, source_of_funds, account_type)"
    ]
  },
  "suitability": "Suitable",
  "risk_level": "Balanced"
}
```

The same file with an expired national ID (critical failures add 3 points each and force High):

```json theme={null}
{
  "level": "High",
  "score": 7,
  "factors": [
    "Verification failed: national_id — not expired (expired 2024-01-01)",
    "Verification failed: The identity document on file is not expired (expired 2024-01-01)",
    "Flag: KYC/KYB data completeness (61%) (missing 11 required data point(s): street1, city, province, postal_code, phone, email, source_of_funds, account_type)"
  ]
}
```

Its `suitability` is `Blocked — document verification failed`, and `risk_level` still reads `Balanced`.

## Example request

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://app.sahlfinancial.com/api/v1/kyc/assess \
    -H "Authorization: Bearer $SAHL_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "reference": "client-0001",
      "environment": "sandbox",
      "subject": "Test Client",
      "kind": "individual",
      "require_documents": false,
      "values": {
        "first_name": "Test", "last_name": "Client", "country": "MA", "citizenship": "MA",
        "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": []
    }'
  ```

  ```javascript JavaScript theme={null}
  const res = await fetch("https://app.sahlfinancial.com/api/v1/kyc/assess", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SAHL_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      reference: "client-0001",
      environment: "sandbox",
      subject: "Test Client",
      kind: "individual",
      require_documents: false,
      values: {
        first_name: "Test", last_name: "Client", country: "MA", citizenship: "MA",
        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: [],
    }),
  });
  const { assessment } = await res.json();
  console.log(assessment.risk_profile.band, assessment.capacity.band, assessment.compliance_risk.level, assessment.suitability);
  ```

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

  res = requests.post(
      "https://app.sahlfinancial.com/api/v1/kyc/assess",
      headers={"Authorization": f"Bearer {os.environ['SAHL_API_KEY']}"},
      json={
          "reference": "client-0001",
          "environment": "sandbox",
          "subject": "Test Client",
          "kind": "individual",
          "require_documents": False,
          "values": {
              "first_name": "Test", "last_name": "Client", "country": "MA", "citizenship": "MA",
              "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": [],
      },
      timeout=60,
  )
  res.raise_for_status()
  a = res.json()["assessment"]
  print(a["risk_profile"]["band"], a["capacity"]["band"], a["compliance_risk"]["level"], a["suitability"])
  ```
</CodeGroup>

With `documents: []` and `require_documents: false` the example above has no document checks, so compliance risk depends on the `completeness` warning and the screening line of your environment. The expected `risk_profile` and `capacity` are the same as above.

## Webhook summary

The `kyc.case_assessed` event carries `risk_level` and `suitability` in its `verdict` summary. There, `risk_level` is `compliance_risk.level` (`Low`, `Medium`, `High`), not the risk tolerance band. See [Webhooks](/guides/webhooks).


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