> For the complete documentation index, see [llms.txt](https://docs.fonbnk.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.fonbnk.com/server-to-server/kyc-flow.md).

# KYC flow

Most flows need the user's KYC level checked before you create an order. Two endpoints do the work: [Get user KYC state](/server-to-server/api-endpoints/get-user-kyc-state.md) tells you what is needed, [Submit user KYC](/server-to-server/api-endpoints/submit-user-kyc.md) satisfies it.

You have a choice about who collects the documents. Either you collect them and post them yourself, or you send the user a link and we collect them. The check, the rules and the result are the same; only step 5 differs.

{% hint style="warning" %}
Both endpoints require the end-user-creation capability on your account. Without it they return `403` — "This feature is not available for this merchant, please contact support". Ask support to enable it before you build against them.
{% endhint %}

### The short version

1. Call [Get user KYC state](/server-to-server/api-endpoints/get-user-kyc-state.md) with the user's email, the order's country, **and the amounts of the order you are about to create**.
2. If `isNgBvnBlocked` is `true` and you are creating a Nigerian **on-ramp**, stop — handle the BVN gate first (below). It is a separate check from `requiredKycType`.
3. If `requiredKycType` is `null`, go to quoting.
4. Otherwise compare `requiredKycType` with `passedKycType`. Already at that tier or above? Proceed.
5. Not yet? Either collect the documents yourself, or send the user a hosted link:
   * **Collect them yourself.** Pick the document from `kycDocuments` whose `type` matches `requiredKycType`, collect its `requiredFields`, and post it to [Submit user KYC](/server-to-server/api-endpoints/submit-user-kyc.md).
   * **Let us collect them.** Send the user `kycUrls[requiredKycType]` from the same response. See [Letting Fonbnk collect it](#letting-fonbnk-collect-it).
6. Wait until `passedKycType` reaches the tier — by polling, or with the `kyc` [webhook](/server-to-server/webhooks/kyc-and-auth-webhooks.md). This is the same either way.

{% hint style="success" %}
**Pass the amounts, and pass the right ones.** Send `depositCurrencyType`, `payoutCurrencyType`, `depositAmountUsd` and `payoutAmountUsd` together and `requiredKycType` accounts for the order in front of you rather than only the user's history.

Use the same two figures order creation uses: the deposit amount **before** fees and the payout amount **after** fees. Both are on the quote as `deposit.cashout.amountBeforeFeesUsd` and `payout.cashout.amountAfterFeesUsd`. Several countries switch tier at exactly $100, which is well inside a typical fee spread — send the wrong basis and your pre-check will disagree with enforcement.
{% endhint %}

```mermaid
flowchart TD
  A[Get user KYC state<br/>with the order amounts] --> N{isNgBvnBlocked<br/>on an NG on-ramp?}
  N -->|yes| P{isNgBvnSupportLocked?}
  P -->|yes| Q[Send the user to support]
  P -->|no| R[Submit the BVN document]
  R --> A
  N -->|no| B{requiredKycType null?}
  B -->|yes| Z[Create quote and order]
  B -->|no| C{passedKycType at that tier or above?}
  C -->|yes| Z
  C -->|no| D{reachedKycLimit?}
  D -->|yes| E[Send the user to support]
  D -->|no| W{Who collects the documents?}
  W -->|you| F[Pick the matching document]
  F --> G[Submit user KYC]
  W -->|we do| K[Send the user kycUrls basic or advanced]
  G --> H[Poll, or wait for the kyc webhook]
  K --> H
  H --> A
```

### Letting Fonbnk collect it

`kycUrls` on the [Get user KYC state](/server-to-server/api-endpoints/get-user-kyc-state.md) response holds one link per tier. Send the user the one that matches `requiredKycType`. The link opens our widget, asks for the email we already hold for them, sends a one-time code to it, and then shows the document form for that tier.

You do not build a form, you never handle identity documents, and you read the result exactly as before: poll `passedKycType`, or take the `kyc` [webhook](/server-to-server/webhooks/kyc-and-auth-webhooks.md).

{% hint style="warning" %}
**Your project needs an active URL signature secret.** Create one on the Integration page of the merchant dashboard. Without it the response carries no `kycUrls` and a `message` that says so. The signature is what ties the check to your account, and a link that is not signed reports its result to Fonbnk instead of to you.
{% endhint %}

**Send the user back to your site** with `callbackUrl`, and relabel the button with `callbackBtnText`. Both are query params on Get user KYC state, and they are baked into the links. The button appears once the user has submitted a check.

#### What can come back with no link

* **A tier with no enabled document in that country.** `kycUrls.advanced` is absent in Nigeria today, because the basic BVN is the only enabled Nigerian document. An absent field means the tier cannot be satisfied there, not that the user has passed it.
* **A user at `reachedKycLimit`.** The page could show them nothing but a support message, so no link is issued.
* **A project with no active URL signature secret.** `message` names the fix.

#### Two things the user may see that you did not ask for

* **In Nigeria, an `advanced` link shows the BVN form first.** The Nigerian BVN gate is separate from the tier, so a user with no BVN on file is asked for it before anything else. They then do the advanced check. That is two records and two webhooks, with `passedKycType: "basic"` in between. A user who is `isNgBvnSupportLocked` cannot self-serve the BVN at all, so send them to support rather than a link.
* **The user can change the country they verify in.** The page offers our supported KYC countries. Verifying in a country other than the order country forces **advanced**, which is the same rule the API applies.

### Watch for the traps

**`reachedKycLimit` does not clear by waiting.** It counts every verification attempt on file that has not been voided — approved and rejected ones included, not just in-flight ones. Three attempts and the user can never submit again until support voids one. When it is `true`, route the user to support rather than telling them to wait.

**Poll `passedKycType`, not `currentKycStatus`.** Some historical records carry a status value outside the documented set, so a loop that waits for `currentKycStatus === "approved"` can hang. Waiting for `passedKycType` to reach the tier you need is the reliable test.

**An advanced-verified user can be BVN-blocked with nothing to submit.** `requiredKycType` is `null` for anyone who has passed advanced, but the Nigerian BVN gate is separate and can still be set. Such a user cannot self-serve — submitting any document is refused with "User already passed advanced KYC". Send them to support.

### Reading kycSettings yourself

You only need this if you cannot send the amounts — for a pricing page with no amount yet, say.

Each entry carries an `operationType`, a `currencyType` and the `type` (tier) it demands. `{deposit, fiat}` is the on-ramp (the user pays fiat in); `{deposit, crypto}` is the off-ramp (the user sends crypto in). Match the entry against the leg you care about, then:

* **Per-order rule** — `min` and `max` are set. It fires when that leg's USD value is in `[min, max)`. `max` may be the string `"Infinity"`.
* **Aggregate rule** — `maxAmountUsd` and/or `maxOrdersCount` are set. It fires when the user's lifetime **successful** volume or count in that bucket, **plus the order you are pre-checking**, exceeds the threshold. The order counts before it exists.
* An entry can be both at once. Both branches are evaluated.
* When several rules fire, the **highest** tier wins.

{% hint style="warning" %}
Where a country's `maxAmountUsd` is lower than its per-order `min`, the aggregate rule is what decides small orders. South Africa's per-order rule starts at $3 but its aggregate allowance is $1, so a $2 South African on-ramp still needs basic. This is the main reason to send amounts and let the API answer.
{% endhint %}

### Two rules that surprise people

**Off-ramp KYC is a per-country switch, and mostly off.** When a country does not require KYC to sell crypto, its `{deposit, crypto}` rules are dropped from `kycSettings` before you see them. That is the **only** filtering applied — the on-ramp rules always come back and you still have to match them against your leg yourself. `offrampKycRequired` tells you which case you are in. Today South Africa is the only country where it is on, but read the flag rather than assuming.

**Basic does not cross borders.** A basic pass counts only in the country it was earned in — `passedKycCountryIsoCode` says where. A user with `passedKycType: "basic"` ordering elsewhere gets `requiredKycType: "advanced"`. Advanced is global.

### The Nigerian BVN gate

A Nigerian order needs a BVN on file, and this is checked separately from `requiredKycType`.

* `isNgBvnBlocked` is computed **without a direction**. Honour it on an on-ramp. On an off-ramp, honour it only when `offrampKycRequired` is `true` for that country — and for Nigeria it is `false` today, so a Nigerian off-ramp is not gated even though the flag is set.
* `isNgBvnSupportLocked` means the user has a basic pass that is not an approved Nigerian BVN. That includes a basic earned in Nigeria with a NIN or voter ID, not only one earned abroad. They cannot self-serve the BVN — send them to support.

{% hint style="info" %}
Nigeria currently offers exactly one enabled KYC document, `BVN`. If `requiredKycType` comes back as `advanced` for a Nigerian order there is no document that can satisfy it — raise it with support rather than looping. `kycUrls.advanced` is absent there for the same reason.
{% endhint %}

### Response fields

* **`requiredKycType`** (<mark style="color:yellow;">`"basic" | "advanced" | null`</mark>) — the tier this order needs. `null` means nothing to do. Branch on this.
* **`passedKycType`** (<mark style="color:yellow;">`"basic" | "advanced" | undefined`</mark>) — the highest tier the user has passed.
* **`passedKycCountryIsoCode`** (<mark style="color:yellow;">`string | undefined`</mark>) — where that pass was earned.
* **`reachedKycLimit`** (<mark style="color:yellow;">`boolean`</mark>) — three un-voided attempts on file. Support only.
* **`currentKycType`** / **`currentKycStatus`** / **`currentKycStatusDescription`** — the latest submission's tier, status and reason. Treat the status set as open-ended.
* **`currentKycPhase`** — reserved for a Nigerian two-phase advanced flow that is currently switched off. Not emitted today.
* **`kycDocuments`** — the enabled documents for the selected country, each with `_id`, `type`, `title`, `value` and `requiredFields`.
* **`kycUrls`** (<mark style="color:yellow;">`{ basic?: string; advanced?: string } | undefined`</mark>) — hosted KYC links, one per tier the country can satisfy.
* **`kycSettings`** — the country's rules, with the off-ramp rules dropped when the switch is off.
* **`offrampKycRequired`** (<mark style="color:yellow;">`boolean`</mark>) — whether this country requires KYC to sell crypto.
* **`isNgBvnBlocked`** / **`isNgBvnSupportLocked`** (<mark style="color:yellow;">`boolean`</mark>) — the Nigerian BVN gate.
* **`message`** (<mark style="color:yellow;">`string | undefined`</mark>) — present when KYC is switched off for your account, and when a hosted KYC link could not be signed.

### Sample response

A brand-new Nigerian user, pre-checked for a $120 on-ramp. Optional fields with no value are omitted rather than returned as `null`.

{% code overflow="wrap" expandable="true" %}

```json
{
  "requiredKycType": "basic",
  "reachedKycLimit": false,
  "offrampKycRequired": false,
  "isNgBvnBlocked": true,
  "isNgBvnSupportLocked": false,
  "kycUrls": {
    "basic": "https://pay.fonbnk.com/kyc/login?source=xsdf_2&signature=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
  },
  "kycDocuments": [
    {
      "_id": "67da90f0b6f2529a09645219",
      "type": "basic",
      "title": "BVN",
      "value": "BVN",
      "requiredFields": [
        { "key": "first_name", "type": "string", "label": "First Name", "required": true },
        { "key": "last_name",  "type": "string", "label": "Last Name",  "required": true },
        { "key": "dob",        "type": "date",   "label": "Date of birth", "required": true },
        {
          "key": "id_number",
          "type": "string",
          "label": "BVN Number",
          "required": true,
          "format": "00000000000",
          "regexp": "^[0-9]{11}$"
        }
      ]
    }
  ],
  "kycSettings": [
    {
      "operationType": "deposit",
      "currencyType": "fiat",
      "type": "basic",
      "min": 1,
      "max": "Infinity",
      "maxAmountUsd": 1
    }
  ]
}
```

{% endcode %}

One document, because `BVN` is the only one enabled for Nigeria. One link, for the same reason. One rule, because Nigeria's crypto-deposit rule was dropped — `offrampKycRequired` is `false`. And `isNgBvnBlocked` is `true` because this user has no BVN on file.

### Submitting

Basic documents take a name, a date of birth and an ID number. Advanced documents take base64 images instead of the number — `image_type_id` `2` for the selfie, `3` for the document front, `7` for the back — with the whole body under 10 MB. Full request examples are on [Submit user KYC](/server-to-server/api-endpoints/submit-user-kyc.md).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.fonbnk.com/server-to-server/kyc-flow.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
