> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sumvin.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Identity Verification (KYC)

> Integrate identity verification into your platform using three verification modes — embedded SDK, hybrid API-plus-SDK, or document-only — with webhook-driven status updates.

<Snippet file="product-term-disambiguation.mdx" />

## Overview

Identity verification is how a user earns a **Sigil** — their portable, verified Proof of Personhood. This guide covers the full KYC (Know Your Customer) flow that backs it. Three verification modes are available, controlled per integration via a server-side configuration:

* **WebSDK mode** (default) — the embedded third-party SDK handles everything: document upload, selfie capture, and liveness checks
* **Hybrid mode** — your app uploads documents via the API, then launches the SDK for a liveness-only step
* **Document-only mode** — your app uploads documents via the API with no SDK or liveness step required

The active mode is communicated to your frontend via `meta.kyc_mode` on the `kyc_verification` onboarding step. The value is `"websdk"`, `"hybrid"`, or `"document_only"`.

The KYC system is:

* **Webhook-updated** — verification results arrive asynchronously; your client polls for the final outcome
* **GDPR-compliant** — only the user's name is stored locally; all identity documents and PII are held by the verification provider
* **Onboarding-integrated** — when KYC is approved while the user is on the `kyc_verification` onboarding step, onboarding auto-advances to the next step

**Sumvin holds the outcome, not the documents. The envelope around the verification is ours; the payload stays with the provider.**

## Quick Start

### WebSDK Mode

<Info>
  **No additional CORS configuration is required.** The WebSDK runs in a SumSub-hosted iframe — your origin only needs CORS allowed on the Sumvin API itself (handled by the standard API config), not on `id.sumsub.com` or `api.sumsub.com`. The SIS dashboard CORS origin allowlist governs blockchain RPC traffic, not KYC.
</Info>

#### 1. Create the applicant (idempotent)

Before requesting an access token, create the SumSub applicant for the user. This is idempotent — safe to call on every entry into the verification flow.

```bash theme={null}
curl -X POST /v0/kyc/applicant \
  -H "x-juno-jwt: <token>"
```

```json theme={null}
{
  "applicant_id": "65abc123def456",
  "user_id": "usr_abc123",
  "already_existed": false,
  "_links": { ... }
}
```

If the user already has an applicant, the response includes `"already_existed": true`. Either way, proceed to step 2.

<Note>
  You can skip this step and call `/v0/kyc/access-token` directly — the WebSDK will create the applicant on first interaction. Calling `/v0/kyc/applicant` first is preferred because it pins the `sumsub_applicant_id` server-side immediately, which makes status polling and webhook correlation more reliable.
</Note>

#### 2. Generate an SDK access token

```bash theme={null}
curl -X POST /v0/kyc/access-token \
  -H "x-juno-jwt: <token>"
```

```json theme={null}
{
  "access_token": "_act-sbx-...",
  "expires_in": 600,
  "user_id": "usr_abc123",
  "_links": {
    "self": { "href": "/v0/kyc/access-token", "method": "POST" },
    "status": { "href": "/v0/kyc/status" }
  }
}
```

The token is valid for 600 seconds (10 minutes). Generate a new one if it expires before the user finishes — see the `expirationHandler` below.

#### 3. Embed the SumSub SDK

Pick the SumSub SDK that matches your platform. Both consume the same `access_token` from step 2.

<Tabs>
  <Tab title="Web (React)">
    The WebSDK runs in a SumSub-hosted iframe; your origin does not need to be allowlisted with SumSub for the iframe to load. The reference integration in our B2C app uses [`@sumsub/websdk-react`](https://www.npmjs.com/package/@sumsub/websdk-react):

    ```tsx theme={null}
    import SumsubWebSdk from '@sumsub/websdk-react';

    function VerifyKyc({ accessToken, sumvinJwt, onComplete, onError }) {
      const expirationHandler = async () => {
        // Refetch a new access token when the current one expires.
        // `sumvinJwt` is your Sumvin session JWT (the same one used elsewhere
        // in the app for `x-juno-jwt`) — pass it in from your auth context.
        const res = await fetch('/v0/kyc/access-token', {
          method: 'POST',
          headers: { 'x-juno-jwt': sumvinJwt },
        });
        const { access_token } = await res.json();
        return access_token;
      };

      const handleMessage = (type) => {
        // When the user finishes uploads, advance your UI. Final approval is
        // delivered asynchronously by webhook — do not block on it here.
        if (
          type === 'idCheck.onApplicantSubmitted' ||
          type === 'idCheck.onApplicantResubmitted' ||
          type === 'idCheck.onApplicantActionCompleted'
        ) {
          onComplete();
        }
      };

      return (
        <SumsubWebSdk
          accessToken={accessToken}
          expirationHandler={expirationHandler}
          config={{ lang: 'en', theme: 'dark' }}
          options={{ addViewportTag: false, adaptIframeHeight: true }}
          onMessage={handleMessage}
          onError={onError}
        />
      );
    }
    ```
  </Tab>

  <Tab title="React Native">
    For mobile apps use [`@sumsub/react-native-mobilesdk-module`](https://www.npmjs.com/package/@sumsub/react-native-mobilesdk-module). The native SDK opens a full-screen verification flow rather than an iframe:

    ```bash theme={null}
    yarn add @sumsub/react-native-mobilesdk-module
    # or
    npm install @sumsub/react-native-mobilesdk-module
    ```

    ```tsx theme={null}
    import SNSMobileSDK from '@sumsub/react-native-mobilesdk-module';

    async function fetchAccessToken(sumvinJwt: string): Promise<string> {
      const res = await fetch('https://api.sumvin.com/v0/kyc/access-token', {
        method: 'POST',
        headers: { 'x-juno-jwt': sumvinJwt },
      });
      const { access_token } = await res.json();
      return access_token;
    }

    async function launchVerification(sumvinJwt: string, onComplete: () => void) {
      const accessToken = await fetchAccessToken(sumvinJwt);

      const sdk = SNSMobileSDK
        .init(accessToken, () => fetchAccessToken(sumvinJwt))   // 2nd arg: token expiration handler
        .withHandlers({
          onStatusChanged: (event) => {
            // The user has progressed through (or finished) the verification.
            // The launch() promise also resolves on close — see below.
            console.log('[Sumsub]', event.prevStatus, '→', event.newStatus);
          },
        })
        .withLocale('en')
        .build();

      try {
        const result = await sdk.launch();
        // SDK closed. Final approval comes from the Sumvin webhook —
        // do NOT block on this result for the verification outcome.
        // Just advance your UI; the user's KYC state will update via /v0/kyc/status.
        onComplete();
      } catch (err) {
        console.error('[Sumsub] launch error', err);
      }
    }
    ```

    **iOS / Android native config required.** The SDK needs camera and (optionally) NFC permissions in `Info.plist` and `AndroidManifest.xml`. Follow [SumSub's React Native module guide](https://docs.sumsub.com/docs/react-native-module) for the platform-specific build steps.

    <Warning>
      The `@sumsub/react-native-mobilesdk-module` JS package wraps native iOS/Android binaries that update independently. If you hit build errors after a SumSub change, bump to the latest tagged release and check the [changelog](https://docs.sumsub.com/docs/react-native-module-changelog) — the JS layer can be stable while the underlying native pods/AARs need a fresh `pod install` or Gradle sync.
    </Warning>
  </Tab>
</Tabs>

The token expiration handler is **required** for sessions that may exceed 10 minutes — SumSub calls it transparently to refresh the token. Wire it up via the `expirationHandler` prop on `<SumsubWebSdk>` for the Web React SDK, or as the second argument to `SNSMobileSDK.init()` for React Native. Without it, long-running verification sessions break mid-flow. This is the most common integration mistake.

<Note>
  **Do not use `/v0/kyc/sumsub/connect/authorize`.** That endpoint starts the SumSub ID Connect (OIDC cross-partner identity sharing) flow, which is a separate feature requiring distinct provisioning with SumSub. For the standard onboarding flow described here, you only need `/v0/kyc/applicant` and `/v0/kyc/access-token`.
</Note>

#### 4. Advance your UI on completion

When the SDK signals completion, the user has finished their part. **Do not poll for approval here.** SumSub's review runs asynchronously (seconds to minutes); the result arrives via webhook to `/v0/webhooks/kyc/sumsub/events` and updates `kyc_verified_at` / `kyc_rejected_at` on the user.

The completion signal differs by platform:

* **Web (`@sumsub/websdk-react`):** the `onMessage` callback fires with `idCheck.onApplicantSubmitted`, `idCheck.onApplicantResubmitted`, or `idCheck.onApplicantActionCompleted`.
* **React Native (`@sumsub/react-native-mobilesdk-module`):** the `launch()` promise resolves when the user closes the SDK; `withHandlers({ onStatusChanged })` fires earlier as the user progresses.

In our B2C app, completion of the WebSDK simply submits the `kyc_verification` onboarding step; the user proceeds to the next step. Subsequent reads of `/v0/user/me` or `/v0/kyc/status` reflect the webhook-driven state once review completes.

If you need to display the verification result on a "checking…" screen, call:

```bash theme={null}
curl /v0/kyc/status?refresh=true \
  -H "x-juno-jwt: <token>"
```

```json theme={null}
{
  "status": "approved",
  "applicant_id": "65abc123def456",
  "verified_at": 1704067200000,
  "rejected_at": null,
  "reject_reason": null,
  "_links": { ... }
}
```

<Warning>
  `GET /v0/kyc/status?refresh=true` returns **404** if the user has no SumSub applicant yet. If you skipped step 1 and the user has not yet interacted with the WebSDK, treat 404 as `pending` in your client. The non-refresh variant (`?refresh=false`, the default) returns 200 with locally cached state and never 404s.
</Warning>

#### 5. Onboarding auto-advances

If the user is on the `kyc_verification` onboarding step when the webhook delivers an `approved` review, onboarding automatically advances to the next step. Re-fetch onboarding state after webhook delivery (or on the user's next session):

```bash theme={null}
curl /v0/user/me/onboarding/steps \
  -H "x-juno-jwt: <token>"
```

### Hybrid Mode

#### 1. Create an applicant

```bash theme={null}
curl -X POST /v0/kyc/applicant \
  -H "x-juno-jwt: <token>"
```

```json theme={null}
{
  "applicant_id": "65abc123def456",
  "user_id": "usr_abc123",
  "already_existed": false,
  "_links": {
    "self": { "href": "/v0/kyc/applicant", "method": "POST" },
    "documents": { "href": "/v0/kyc/documents", "method": "POST" },
    "required-docs": { "href": "/v0/kyc/documents/required" },
    "status": { "href": "/v0/kyc/status" }
  }
}
```

#### 2. Upload identity documents

```bash theme={null}
curl -X POST /v0/kyc/documents \
  -H "x-juno-jwt: <token>" \
  -F "document_type=PASSPORT" \
  -F "country=GBR" \
  -F "file=@passport.jpg"
```

```json theme={null}
{
  "document_type": "PASSPORT",
  "country": "GBR",
  "side": null,
  "image_id": "img_abc123",
  "_links": {
    "self": { "href": "/v0/kyc/documents", "method": "POST" },
    "required-docs": { "href": "/v0/kyc/documents/required" },
    "applicant": { "href": "/v0/kyc/applicant", "method": "POST" },
    "status": { "href": "/v0/kyc/status" }
  }
}
```

#### 3. Check all required documents are uploaded

```bash theme={null}
curl /v0/kyc/documents/required \
  -H "x-juno-jwt: <token>"
```

```json theme={null}
{
  "steps": [
    {
      "step_type": "IDENTITY",
      "has_documents": true,
      "review_answer": null,
      "moderation_comment": null
    }
  ],
  "all_uploaded": true,
  "_links": {
    "self": { "href": "/v0/kyc/documents/required" },
    "documents": { "href": "/v0/kyc/documents", "method": "POST" },
    "status": { "href": "/v0/kyc/status" },
    "submit": { "href": "/v0/kyc/submit", "method": "POST" }
  }
}
```

#### 4. Launch liveness-only SDK

Generate a liveness-scoped access token and initialize the SDK for the liveness step only:

```bash theme={null}
curl -X POST /v0/kyc/access-token?level=liveness \
  -H "x-juno-jwt: <token>"
```

```json theme={null}
{
  "access_token": "_act-sbx-...",
  "expires_in": 600,
  "user_id": "usr_abc123",
  "_links": {
    "self": { "href": "/v0/kyc/access-token", "method": "POST" },
    "status": { "href": "/v0/kyc/status" }
  }
}
```

Pass the token to the SDK. The SDK opens directly to the liveness capture step — no document upload screens.

#### 5. Submit for review

After the liveness step completes, submit the applicant for review:

```bash theme={null}
curl -X POST /v0/kyc/submit \
  -H "x-juno-jwt: <token>"
```

```json theme={null}
{
  "status": "pending",
  "applicant_id": "65abc123def456",
  "_links": {
    "self": { "href": "/v0/kyc/submit", "method": "POST" },
    "status": { "href": "/v0/kyc/status" },
    "details": { "href": "/v0/kyc/details" }
  }
}
```

#### 6. Poll for verification status

Same as WebSDK mode — poll the status endpoint with `?refresh=true` until a terminal state is reached.

[`GET /v0/kyc/status`](/api-reference/kyc/get-current-kyc-verification-status) — Poll current status.

### Document-Only Mode

#### 1. Create an applicant

```bash theme={null}
curl -X POST /v0/kyc/applicant \
  -H "x-juno-jwt: <token>"
```

```json theme={null}
{
  "applicant_id": "65abc123def456",
  "user_id": "usr_abc123",
  "already_existed": false,
  "_links": {
    "self": { "href": "/v0/kyc/applicant", "method": "POST" },
    "documents": { "href": "/v0/kyc/documents", "method": "POST" },
    "required-docs": { "href": "/v0/kyc/documents/required" },
    "status": { "href": "/v0/kyc/status" }
  }
}
```

#### 2. Upload identity documents

```bash theme={null}
curl -X POST /v0/kyc/documents \
  -H "x-juno-jwt: <token>" \
  -F "document_type=PASSPORT" \
  -F "country=GBR" \
  -F "file=@passport.jpg"
```

#### 3. Check all required documents are uploaded

```bash theme={null}
curl /v0/kyc/documents/required \
  -H "x-juno-jwt: <token>"
```

Confirm `all_uploaded` is `true` before submitting.

#### 4. Submit for review

No liveness step is required. Submit directly after documents are uploaded:

```bash theme={null}
curl -X POST /v0/kyc/submit \
  -H "x-juno-jwt: <token>"
```

#### 5. Poll for verification status

Same as WebSDK mode — poll the status endpoint with `?refresh=true` until a terminal state is reached.

[`GET /v0/kyc/status`](/api-reference/kyc/get-current-kyc-verification-status) — Poll current status.

## KYC Modes

The verification mode is selected per integration server-side. Your frontend reads the mode from `meta.kyc_mode` on the `kyc_verification` onboarding step and renders the appropriate flow.

| Mode          | `meta.kyc_mode`   | Document Upload        | Liveness Check    | When to Use                            |
| ------------- | ----------------- | ---------------------- | ----------------- | -------------------------------------- |
| WebSDK        | `"websdk"`        | SDK handles            | SDK handles       | Default — simplest integration         |
| Hybrid        | `"hybrid"`        | API upload (see below) | SDK liveness-only | Custom document capture UI             |
| Document-only | `"document_only"` | API upload (see below) | None              | Document verification without liveness |

[`POST /v0/kyc/documents`](/api-reference/kyc/upload-a-kyc-document-image) — Upload a document image.

In WebSDK mode, you generate an access token and hand control to the SDK. In hybrid mode, you create an applicant, upload documents through the API, launch the SDK for liveness only, then submit for review. In document-only mode, you create an applicant, upload documents, and submit — no SDK initialization is needed.

<Note>
  **`meta.kyc_mode` is always populated for the `kyc_verification` step.** When the server-side configuration is missing, unreachable, or returns an unrecognized value, the API falls back to `"websdk"`. Frontends do not need to defend against `meta` being `null` or `kyc_mode` being absent — branching on the three documented values is sufficient.
</Note>

<Warning>
  The mode can change between sessions if the configuration is updated server-side. Always read `meta.kyc_mode` from the current onboarding step rather than hardcoding a mode.
</Warning>

### Per-organisation override

Partner organisations can pin their KYC mode independently of the global default by enabling the `kyc` feature on an environment:

```http theme={null}
PUT /v0/organisation/{org_id}/environments/{env_id}/features/kyc
Content-Type: application/json

{ "enabled": true, "config": { "mode": "websdk" } }
```

Allowed `mode` values: `websdk`, `hybrid`, `document_only`. The `config` object must either be omitted (or empty) or include a recognised `mode` value — supplying an unknown key (e.g. `{ "kyc_mode": "websdk" }`) or an unknown value returns `422 Unprocessable Entity` with `error_code: KYC-422-001` and the allowed values listed in the response detail, so silent typos can't mask an operator's intent. If the feature row is disabled or no `config` is supplied, the global default applies.

Operators can change a tenant's mode at any time. Existing in-flight verification sessions are unaffected — the mode is read at the start of each onboarding state response, so a new selection takes effect from the next `meta.kyc_mode` your frontend reads.

## KYC State

### Reading Status

Fetch the current KYC status at any time:

```
GET /v0/kyc/status
```

Add `?refresh=true` to pull the latest result from the verification provider instead of relying on the locally cached state. Use `refresh=true` during active verification when you need the most current status.

### Response Shape

```json theme={null}
{
  "status": "in_progress",
  "applicant_id": "65abc123def456",
  "verified_at": null,
  "rejected_at": null,
  "reject_reason": null,
  "_links": {
    "self": { "href": "/v0/kyc/status" },
    "user": { "href": "/v0/user/me" },
    "refresh": { "href": "/v0/kyc/status?refresh=true" },
    "start-verification": { "href": "/v0/kyc/access-token", "method": "POST" }
  }
}
```

| Field           | Type            | Description                                             |
| --------------- | --------------- | ------------------------------------------------------- |
| `status`        | string          | Current KYC status (see [KYC Statuses](#kyc-statuses))  |
| `applicant_id`  | string or null  | Provider applicant ID. Null if verification not started |
| `verified_at`   | integer or null | Approval timestamp (epoch milliseconds)                 |
| `rejected_at`   | integer or null | Rejection timestamp (epoch milliseconds)                |
| `reject_reason` | string or null  | Human-readable rejection or retry reason                |

The `_links` object includes a `start-verification` link when the status is `pending` or `retry`, guiding the client to begin or restart the verification flow.

## KYC Lifecycle

```mermaid theme={null}
stateDiagram-v2
    [*] --> pending : account created
    pending --> in_progress : verification started
    in_progress --> approved : verification passed
    in_progress --> retry : additional info requested
    in_progress --> rejected : verification failed (terminal)
    retry --> in_progress : user resubmits
    approved --> [*]
    rejected --> [*]

    note right of retry : User can fix and resubmit
    note right of rejected : Terminal — cannot retry
```

Status transitions happen asynchronously. The API receives webhook events from the verification provider and updates the user's KYC state. Your client discovers the new state by polling the status endpoint with `?refresh=true`.

[`GET /v0/kyc/status`](/api-reference/kyc/get-current-kyc-verification-status) — Poll current status.

## Endpoints

### Generate SDK Access Token

```
POST /v0/kyc/access-token
```

Returns a short-lived access token for initializing the verification SDK in your frontend.

**Query parameters:**

| Parameter | Type   | Default | Description                                                                                         |
| --------- | ------ | ------- | --------------------------------------------------------------------------------------------------- |
| `level`   | string | none    | Set to `"liveness"` to generate a token scoped to the liveness-only verification step (hybrid mode) |

**Response:** 200 OK

```json theme={null}
{
  "access_token": "_act-sbx-...",
  "expires_in": 600,
  "user_id": "usr_abc123",
  "_links": {
    "self": { "href": "/v0/kyc/access-token", "method": "POST" },
    "status": { "href": "/v0/kyc/status" }
  }
}
```

| Field          | Type    | Description                                 |
| -------------- | ------- | ------------------------------------------- |
| `access_token` | string  | Token to pass to the verification SDK       |
| `expires_in`   | integer | Token validity in seconds (default 600)     |
| `user_id`      | string  | External user ID associated with this token |

When `level=liveness`, the returned token restricts the SDK to the liveness verification step only. Use this in hybrid mode after documents have been uploaded via the API.

### Check KYC Status

```
GET /v0/kyc/status
```

<Tip>
  In React, `useKycStatus()` from our [query patterns](/guides/tanstack-query-patterns#query-patterns) handles auth gating and shares status across components — pair it with a short `refetchInterval` while polling for approval.
</Tip>

**Query parameters:**

| Parameter | Type    | Default | Description                                        |
| --------- | ------- | ------- | -------------------------------------------------- |
| `refresh` | boolean | `false` | Fetch latest status from the verification provider |

**Response:** 200 OK — see [Response Shape](#response-shape) above.

When `refresh=true`:

* If the user has no applicant record, returns 404 Not Found
* If the provider is unreachable, returns 502 Bad Gateway
* If KYC is approved and the user is on the `kyc_verification` onboarding step, onboarding auto-advances

### Get KYC Details

```
GET /v0/kyc/details
```

Returns GDPR-compliant verification details including personal information (name only), document submission status, and rejection details.

**Query parameters:**

| Parameter | Type    | Default | Description                                           |
| --------- | ------- | ------- | ----------------------------------------------------- |
| `refresh` | boolean | `false` | Bypass the 5-minute cache and fetch from the provider |

**Response:** 200 OK

```json theme={null}
{
  "status": "approved",
  "verified_at": 1704067200000,
  "rejected_at": null,
  "started_at": 1704060000000,
  "personal_info": {
    "first_name": "Jane",
    "last_name": "Smith",
    "date_of_birth": "1990-05-15",
    "nationality": "GBR",
    "country_of_residence": "GBR"
  },
  "documents": [
    {
      "document_type": "PASSPORT",
      "country": "GBR",
      "status": "verified",
      "submitted_at": 1704061000000
    }
  ],
  "verification_complete": true,
  "verification_passed": true,
  "rejection": null,
  "_links": {
    "self": { "href": "/v0/kyc/details" },
    "refresh": { "href": "/v0/kyc/details?refresh=true" },
    "status": { "href": "/v0/kyc/status" },
    "user": { "href": "/v0/user/me" }
  }
}
```

| Field                   | Type            | Description                                        |
| ----------------------- | --------------- | -------------------------------------------------- |
| `status`                | string          | Current KYC status                                 |
| `verified_at`           | integer or null | Approval timestamp (epoch milliseconds)            |
| `rejected_at`           | integer or null | Rejection timestamp (epoch milliseconds)           |
| `started_at`            | integer or null | When verification was started (epoch milliseconds) |
| `personal_info`         | object or null  | Name and nationality extracted from documents      |
| `documents`             | array           | Submitted documents with type, country, and status |
| `verification_complete` | boolean         | Whether all required documents have been submitted |
| `verification_passed`   | boolean         | Whether verification passed all checks             |
| `rejection`             | object or null  | Rejection details (see below)                      |

**Rejection object:**

| Field       | Type    | Description                                                      |
| ----------- | ------- | ---------------------------------------------------------------- |
| `can_retry` | boolean | `true` if the user can resubmit, `false` for terminal rejections |
| `message`   | string  | Human-readable rejection reason                                  |

<Warning>
  The `personal_info` fields other than name (`date_of_birth`, `nationality`, `country_of_residence`) are fetched from the provider on each request and are not stored locally. They are only available while the provider retains the applicant data.
</Warning>

### Create Applicant

```
POST /v0/kyc/applicant
```

Creates a verification provider applicant for the user. Required before uploading documents in hybrid and document-only modes.

This endpoint is idempotent. If an applicant already exists, it returns the existing applicant with a `200 OK` response and `already_existed: true`. A new applicant returns `201 Created`.

**Response:** 200 OK / 201 Created

```json theme={null}
{
  "applicant_id": "65abc123def456",
  "user_id": "usr_abc123",
  "already_existed": false,
  "_links": {
    "self": { "href": "/v0/kyc/applicant", "method": "POST" },
    "documents": { "href": "/v0/kyc/documents", "method": "POST" },
    "required-docs": { "href": "/v0/kyc/documents/required" },
    "status": { "href": "/v0/kyc/status" }
  }
}
```

| Field             | Type    | Description                                 |
| ----------------- | ------- | ------------------------------------------- |
| `applicant_id`    | string  | Provider applicant ID                       |
| `user_id`         | string  | User ID associated with this applicant      |
| `already_existed` | boolean | `true` if the applicant was already created |

### Upload Document

```
POST /v0/kyc/documents
```

Uploads an identity document for the user's applicant. Used in hybrid and document-only modes to submit documents via the API.

An applicant must exist before uploading documents — create one first.

[`POST /v0/kyc/applicant`](/api-reference/kyc/create-kyc-applicant) — Create a KYC applicant.

<Tip>
  Building a React UI? `useUploadKycDocument()` from our [mutation patterns](/guides/tanstack-query-patterns#mutation-patterns) wraps the multipart upload and invalidates `useKycRequiredDocs` on success so your UI re-checks `all_uploaded` automatically.
</Tip>

**Request:** `multipart/form-data`

| Field           | Type   | Required | Description                                                                                                     |
| --------------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------- |
| `document_type` | string | Yes      | Document type (see [Document Types](#document-types))                                                           |
| `country`       | string | Yes      | Issuing country (ISO 3166-1 alpha-3, e.g. `GBR`)                                                                |
| `side`          | string | No       | Document side: `FRONT_SIDE` or `BACK_SIDE` (see [Document Sides](#document-sides))                              |
| `file`          | file   | Yes      | Document image or PDF. See [Document Upload Constraints](#document-upload-constraints) for size and MIME limits |

**Response:** 201 Created

```json theme={null}
{
  "document_type": "ID_CARD",
  "country": "GBR",
  "side": "FRONT_SIDE",
  "image_id": "img_abc123",
  "_links": {
    "self": { "href": "/v0/kyc/documents", "method": "POST" },
    "required-docs": { "href": "/v0/kyc/documents/required" },
    "applicant": { "href": "/v0/kyc/applicant", "method": "POST" },
    "status": { "href": "/v0/kyc/status" }
  }
}
```

| Field           | Type           | Description                                 |
| --------------- | -------------- | ------------------------------------------- |
| `document_type` | string         | Type of document uploaded                   |
| `country`       | string         | Issuing country (ISO 3166-1 alpha-3)        |
| `side`          | string or null | Document side, if provided                  |
| `image_id`      | string or null | Provider image ID for the uploaded document |

### Check Required Documents

```
GET /v0/kyc/documents/required
```

Returns the required document steps for the user's verification level and whether each step has documents uploaded. Use this to determine which documents still need to be uploaded before submission.

An applicant must exist before checking required documents.

[`POST /v0/kyc/applicant`](/api-reference/kyc/create-kyc-applicant) — Create a KYC applicant.

<Tip>
  In React, `useKycRequiredDocs()` from our [query patterns](/guides/tanstack-query-patterns#query-patterns) is the canonical "what's left to upload?" hook — it auto-revalidates after `useUploadKycDocument` mutations and exposes `all_uploaded` for gating the submit button.
</Tip>

**Response:** 200 OK

```json theme={null}
{
  "steps": [
    {
      "step_type": "IDENTITY",
      "has_documents": true,
      "review_answer": null,
      "moderation_comment": null
    },
    {
      "step_type": "SELFIE",
      "has_documents": false,
      "review_answer": null,
      "moderation_comment": null
    }
  ],
  "all_uploaded": false,
  "_links": {
    "self": { "href": "/v0/kyc/documents/required" },
    "documents": { "href": "/v0/kyc/documents", "method": "POST" },
    "status": { "href": "/v0/kyc/status" }
  }
}
```

| Field                        | Type           | Description                                                          |
| ---------------------------- | -------------- | -------------------------------------------------------------------- |
| `steps`                      | array          | List of required document steps                                      |
| `steps[].step_type`          | string         | Step type — see [Required Step Types](#required-step-types)          |
| `steps[].has_documents`      | boolean        | Whether documents have been uploaded for this step                   |
| `steps[].review_answer`      | string or null | Review answer if reviewed (`GREEN`, `RED`, or null)                  |
| `steps[].moderation_comment` | string or null | Moderation comment if any                                            |
| `all_uploaded`               | boolean        | `true` when all required steps have documents — ready for submission |

When `all_uploaded` is `true`, the `_links` object includes a `submit` link:

```json theme={null}
{
  "_links": {
    "submit": { "href": "/v0/kyc/submit", "method": "POST" }
  }
}
```

### Submit for Review

```
POST /v0/kyc/submit
```

Submits the applicant for review after all required documents have been uploaded. Used in hybrid mode (after document upload and liveness completion) and document-only mode (after document upload).

An applicant must exist before calling submit; otherwise the call returns `404 Not Found` (`KYC-404-001`). When required-document state can be fetched from the provider, the API checks `all_uploaded` first and returns `409 Conflict` (`KYC-409-001`) if any required step is still missing documents — the same gate that backs the `_links.submit` field on `GET /v0/kyc/documents/required`. Always confirm `all_uploaded` is `true` before calling submit. If the required-docs lookup itself fails (provider unreachable), the API logs a warning and forwards the submission anyway — the provider then enforces its own completeness check and the call surfaces as `500 Internal Server Error` (`KYC-500-004`) if the submission is rejected.

**Response:** 200 OK

```json theme={null}
{
  "status": "pending",
  "applicant_id": "65abc123def456",
  "_links": {
    "self": { "href": "/v0/kyc/submit", "method": "POST" },
    "status": { "href": "/v0/kyc/status" },
    "details": { "href": "/v0/kyc/details" }
  }
}
```

| Field          | Type   | Description                     |
| -------------- | ------ | ------------------------------- |
| `status`       | string | Submission status (`"pending"`) |
| `applicant_id` | string | Provider applicant ID           |

After submission, poll the status endpoint with `?refresh=true` for the verification result.

[`GET /v0/kyc/status`](/api-reference/kyc/get-current-kyc-verification-status) — Poll current status.

## Integration Patterns

### WebSDK Flow

A typical frontend integration using WebSDK mode follows this sequence:

1. Check current status.
2. If `pending` or `retry`, create the applicant (idempotent), then generate an SDK access token.
3. Initialize the verification SDK with the token.
4. When the SDK signals completion, advance your UI. Approval is delivered asynchronously by webhook — do not block on it here.
5. On subsequent reads of `/v0/kyc/status` (or `/v0/user/me`), the new state is reflected once the webhook has been processed. If you need an immediate read-through to the provider, call `/v0/kyc/status?refresh=true`.

**Endpoints used in this flow:**

* [`GET /v0/kyc/status`](/api-reference/kyc/get-current-kyc-verification-status) — Check current status.
* [`POST /v0/kyc/applicant`](/api-reference/kyc/create-kyc-applicant) — Create the SumSub applicant (idempotent).
* [`POST /v0/kyc/access-token`](/api-reference/kyc/get-access-token) — Generate an SDK access token.

```
status = GET /v0/kyc/status

if status == "pending" or status == "retry":
    POST /v0/kyc/applicant         # idempotent — pins applicant_id server-side
    token = POST /v0/kyc/access-token
    launch_sdk(token.access_token)
    await sdk_completion_event()   # idCheck.onApplicantSubmitted (and friends)
    proceed_to_next_step()         # do NOT poll for approval here

# Later, on next status read (or after webhook fires):
status = GET /v0/kyc/status

if status == "approved":
    user_can_use_kyc_gated_features()
elif status == "rejected":
    show_rejection_message(status.reject_reason)
elif status == "retry":
    show_retry_prompt(status.reject_reason)
```

**Why no polling loop?** SumSub review is asynchronous and may take seconds to minutes. Blocking the user on a "checking…" spinner is poor UX. Instead, advance the user to the next step on WebSDK submission, and let the webhook deliver the final state. If you genuinely need a synchronous check (e.g., a verification result screen), use `/v0/kyc/status?refresh=true` which fetches live state from the provider on demand.

### Hybrid Flow

A frontend integration using hybrid mode follows this sequence:

1. Check current status.
2. If `pending` or `retry`, create an applicant.
3. Upload each required document.
4. Check required documents to confirm `all_uploaded` is `true`.
5. Generate a liveness-scoped access token (`?level=liveness`).
6. Initialize the SDK with the liveness token — the SDK opens directly to the liveness step.
7. When the SDK signals completion, submit for review.
8. Poll status with `?refresh=true` for the final result.

**Endpoints used in this flow:**

* [`GET /v0/kyc/status`](/api-reference/kyc/get-current-kyc-verification-status) — Check current status.
* [`POST /v0/kyc/applicant`](/api-reference/kyc/create-kyc-applicant) — Create a KYC applicant.
* [`POST /v0/kyc/documents`](/api-reference/kyc/upload-a-kyc-document-image) — Upload a document image.
* [`GET /v0/kyc/documents/required`](/api-reference/kyc/get-required-kyc-documents-statuses) — Check required documents status.
* [`POST /v0/kyc/access-token`](/api-reference/kyc/get-access-token) — Generate an SDK access token.
* [`POST /v0/kyc/submit`](/api-reference/kyc/submit-kyc-application-for-review) — Submit for review.

```
status = GET /v0/kyc/status

if status == "pending" or status == "retry":
    applicant = POST /v0/kyc/applicant

    for each document:
        POST /v0/kyc/documents (document_type, country, side, file)

    required = GET /v0/kyc/documents/required
    assert required.all_uploaded == true

    token = POST /v0/kyc/access-token?level=liveness
    launch_sdk(token.access_token)
    await sdk_completion()

    POST /v0/kyc/submit

    while status not in ["approved", "rejected"]:
        wait(3 seconds)
        status = GET /v0/kyc/status?refresh=true

if status == "approved":
    proceed_to_next_step()
elif status == "rejected":
    show_rejection_message(status.reject_reason)
elif status == "retry":
    show_retry_prompt(status.reject_reason)
```

### Document-Only Flow

A frontend integration using document-only mode follows this sequence:

1. Check current status.
2. If `pending` or `retry`, create an applicant.
3. Upload each required document.
4. Check required documents to confirm `all_uploaded` is `true`.
5. Submit for review.
6. Poll status with `?refresh=true` for the final result.

**Endpoints used in this flow:**

* [`GET /v0/kyc/status`](/api-reference/kyc/get-current-kyc-verification-status) — Check current status.
* [`POST /v0/kyc/applicant`](/api-reference/kyc/create-kyc-applicant) — Create a KYC applicant.
* [`POST /v0/kyc/documents`](/api-reference/kyc/upload-a-kyc-document-image) — Upload a document image.
* [`GET /v0/kyc/documents/required`](/api-reference/kyc/get-required-kyc-documents-statuses) — Check required documents status.
* [`POST /v0/kyc/submit`](/api-reference/kyc/submit-kyc-application-for-review) — Submit for review.

```
status = GET /v0/kyc/status

if status == "pending" or status == "retry":
    applicant = POST /v0/kyc/applicant

    for each document:
        POST /v0/kyc/documents (document_type, country, side, file)

    required = GET /v0/kyc/documents/required
    assert required.all_uploaded == true

    POST /v0/kyc/submit

    while status not in ["approved", "rejected"]:
        wait(3 seconds)
        status = GET /v0/kyc/status?refresh=true

if status == "approved":
    proceed_to_next_step()
elif status == "rejected":
    show_rejection_message(status.reject_reason)
elif status == "retry":
    show_retry_prompt(status.reject_reason)
```

### Polling for Approval

After the user completes the verification flow, the verification result arrives asynchronously via webhook. Poll the status endpoint with `refresh=true` at a reasonable interval until a terminal state is reached:

```bash theme={null}
# First poll — may still be in_progress
curl /v0/kyc/status?refresh=true \
  -H "x-juno-jwt: <token>"

# Response: { "status": "in_progress", ... }

# Wait 3-5 seconds, then poll again
curl /v0/kyc/status?refresh=true \
  -H "x-juno-jwt: <token>"

# Response: { "status": "approved", "verified_at": 1704067200000, ... }
```

<Warning>
  Avoid polling more frequently than every 3 seconds. Most verifications complete within 30 seconds to 2 minutes, but some require manual review and may take longer.
</Warning>

### Handling Rejection

There are two rejection outcomes with different behaviours:

**Retry** (`status: "retry"`) — the user can fix and resubmit. The `reject_reason` field explains what needs correction. In WebSDK or hybrid mode, generate a new access token and let the user re-enter the SDK flow. In document-only mode, re-upload corrected documents and submit again.

**Rejected** (`status: "rejected"`) — terminal. The user cannot retry with the same identity documents. Display the `reject_reason` and direct them to contact support.

```bash theme={null}
# Retry scenario
curl /v0/kyc/status \
  -H "x-juno-jwt: <token>"
```

```json theme={null}
{
  "status": "retry",
  "applicant_id": "65abc123def456",
  "verified_at": null,
  "rejected_at": null,
  "reject_reason": "Document image is blurry. Please resubmit a clear photo.",
  "_links": {
    "self": { "href": "/v0/kyc/status" },
    "user": { "href": "/v0/user/me" },
    "refresh": { "href": "/v0/kyc/status?refresh=true" },
    "start-verification": { "href": "/v0/kyc/access-token", "method": "POST" }
  }
}
```

When the status is `retry`, the `_links` object includes `start-verification` so your client can initiate a new verification attempt.

### Onboarding Integration

KYC verification is one step in the [onboarding flow](/guides/onboarding). When KYC is approved and the user is on the `kyc_verification` onboarding step, the step auto-advances without requiring an explicit submit call. This happens in two scenarios:

1. **Webhook-driven**: The verification provider sends a webhook with an approval, and the API advances onboarding automatically.
2. **Poll-driven**: Your client polls the status endpoint with `?refresh=true`, the API detects approval, and advances onboarding.

[`GET /v0/kyc/status`](/api-reference/kyc/get-current-kyc-verification-status) — Poll current status.

In both cases, re-fetch onboarding state afterward to render the next step:

```bash theme={null}
curl /v0/user/me/onboarding/steps \
  -H "x-juno-jwt: <token>"
```

## Error Handling

All error responses follow the RFC 7807 Problem Details format:

```json theme={null}
{
  "type": "https://api.sumvin.com/errors/kyc-500-002",
  "title": "KYC Token Generation Failed",
  "status": 500,
  "detail": "Failed to generate KYC access token",
  "instance": "/v0/kyc/access-token",
  "error_code": "KYC-500-002"
}
```

| Status                    | Error Code    | Meaning                                   | Action                                                                                     |
| ------------------------- | ------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------ |
| 400 Bad Request           | `KYC-400-001` | Invalid document file type                | Use a supported format: JPEG, PNG, WebP, or PDF                                            |
| 400 Bad Request           | `KYC-400-002` | File exceeds 10MB size limit              | Reduce file size and retry                                                                 |
| 401 Unauthorized          | `KYC-401-001` | Webhook signature validation failed       | Internal: check webhook secret configuration                                               |
| 403 Forbidden             | `KYC-403-001` | KYC verification required for this action | Complete KYC verification first                                                            |
| 404 Not Found             | `KYC-404-001` | No KYC applicant record found             | Start verification (generate an access token) or create an applicant — see endpoints below |
| 409 Conflict              | `KYC-409-001` | Not all required documents uploaded       | Upload remaining documents and re-check required status — see endpoints below              |
| 500 Internal Server Error | `KYC-500-001` | Internal error processing KYC             | Retry; contact support if persistent                                                       |
| 500 Internal Server Error | `KYC-500-002` | Failed to generate SDK access token       | Retry; contact support if persistent                                                       |
| 500 Internal Server Error | `KYC-500-003` | Document upload to provider failed        | Retry the upload; contact support if persistent                                            |
| 500 Internal Server Error | `KYC-500-004` | Submission for review failed              | Retry; contact support if persistent                                                       |
| 502 Bad Gateway           | `KYC-502-001` | Verification provider unavailable         | Retry after a short delay                                                                  |

### Examples by status

**`401 Unauthorized` — missing or invalid auth headers (any KYC endpoint):**

```json theme={null}
{
  "type": "https://api.sumvin.com/errors/usr-401-001",
  "title": "Unauthorized",
  "status": 401,
  "detail": "Missing or invalid authentication credentials",
  "instance": "/v0/kyc/status",
  "error_code": "USR-401-001"
}
```

**`403 Forbidden` — endpoint requires KYC and the user has not verified yet:**

```json theme={null}
{
  "type": "https://api.sumvin.com/errors/kyc-403-001",
  "title": "KYC Verification Required",
  "status": 403,
  "detail": "KYC verification required for this action",
  "instance": "/v0/card",
  "error_code": "KYC-403-001"
}
```

**`400 Bad Request` — file exceeds the 10 MB limit on `POST /v0/kyc/documents`:**

```json theme={null}
{
  "type": "https://api.sumvin.com/errors/kyc-400-002",
  "title": "File Too Large",
  "status": 400,
  "detail": "File size 12582912 exceeds maximum of 10485760 bytes (10MB).",
  "instance": "/v0/kyc/documents",
  "error_code": "KYC-400-002"
}
```

**`400 Bad Request` — unsupported MIME type on `POST /v0/kyc/documents`:**

```json theme={null}
{
  "type": "https://api.sumvin.com/errors/kyc-400-001",
  "title": "Invalid Document Type",
  "status": 400,
  "detail": "Unsupported file type: image/heic. Allowed: application/pdf, image/jpeg, image/png, image/webp",
  "instance": "/v0/kyc/documents",
  "error_code": "KYC-400-001"
}
```

**`409 Conflict` — `POST /v0/kyc/submit` called before all required documents uploaded:**

```json theme={null}
{
  "type": "https://api.sumvin.com/errors/kyc-409-001",
  "title": "KYC Not Ready For Submission",
  "status": 409,
  "detail": "Not all required documents have been uploaded. Check GET /v0/kyc/documents/required.",
  "instance": "/v0/kyc/submit",
  "error_code": "KYC-409-001"
}
```

**`502 Bad Gateway` — provider unreachable on `GET /v0/kyc/status?refresh=true` or `GET /v0/kyc/documents/required`:**

```json theme={null}
{
  "type": "https://api.sumvin.com/errors/kyc-502-001",
  "title": "KYC Provider Error",
  "status": 502,
  "detail": "Failed to fetch required documents status from KYC provider",
  "instance": "/v0/kyc/documents/required",
  "error_code": "KYC-502-001"
}
```

**Recovery endpoints referenced above:**

* [`POST /v0/kyc/access-token`](/api-reference/kyc/get-access-token) — Generate an SDK access token.
* [`POST /v0/kyc/applicant`](/api-reference/kyc/create-kyc-applicant) — Create a KYC applicant.
* [`POST /v0/kyc/documents`](/api-reference/kyc/upload-a-kyc-document-image) — Upload a document image.
* [`GET /v0/kyc/documents/required`](/api-reference/kyc/get-required-kyc-documents-statuses) — Check required documents status.

## Reference Tables

### KYC Statuses

| Value         | Description                                          | Terminal? |
| ------------- | ---------------------------------------------------- | --------- |
| `pending`     | User has not started verification                    | No        |
| `in_progress` | Verification started, awaiting result                | No        |
| `retry`       | Additional information requested — user can resubmit | No        |
| `approved`    | Verification passed                                  | **Yes**   |
| `rejected`    | Verification failed — user cannot retry              | **Yes**   |

**Polling stop conditions:** `approved` and `rejected` are the only terminal states. Stop polling when `status` is one of those two values. `pending` (verification not started), `in_progress` (verification underway), and `retry` (additional info requested) are all non-terminal — keep polling until the status becomes terminal or the user takes a corrective action.

**`retry` requires user action.** It is not auto-recoverable — show the `reject_reason` to the user and prompt them to resubmit. Polling will continue to return `retry` until the user re-enters the verification flow.

### Required Step Types

The `step_type` field on each entry of `GET /v0/kyc/documents/required` is set by the verification provider and depends on the configured KYC level. Treat the value as an open string — the same level can return additional step types over time without an API version change.

The values below are the ones the integration currently emits:

| Value              | Description                                                   |
| ------------------ | ------------------------------------------------------------- |
| `IDENTITY`         | Government-issued ID (passport, ID card, or driver's license) |
| `SELFIE`           | Selfie / liveness check                                       |
| `PROOF_OF_ADDRESS` | Address verification document (utility bill, bank statement)  |

In WebSDK mode, the SDK fulfils these steps internally — your client typically only inspects this list in **hybrid** and **document-only** modes, where you upload the documents yourself.

### Document Types

| Value              | Description                                                               |
| ------------------ | ------------------------------------------------------------------------- |
| `PASSPORT`         | International passport                                                    |
| `ID_CARD`          | National identity card                                                    |
| `DRIVERS`          | Driver's license                                                          |
| `SELFIE`           | Selfie / liveness photo (used when uploading via API rather than the SDK) |
| `PROOF_OF_ADDRESS` | Proof of address (utility bill, bank statement, etc.)                     |

### Document Sides

Some document types require both sides to be uploaded separately.

| Value        | Description           |
| ------------ | --------------------- |
| `FRONT_SIDE` | Front of the document |
| `BACK_SIDE`  | Back of the document  |

Passports typically require only a single upload (no side). ID cards and driver's licenses typically require both `FRONT_SIDE` and `BACK_SIDE`.

### Document Upload Constraints

Validated server-side before forwarding to the verification provider. A request that violates any of these constraints fails with a `400 Bad Request` (no provider call is made — see [Error Handling](#error-handling)).

| Constraint              | Value                                                                                                                              | Error code on violation  |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------ |
| Maximum file size       | **10 MB** per upload                                                                                                               | `KYC-400-002`            |
| Allowed MIME types      | `image/jpeg`, `image/png`, `image/webp`, `application/pdf`                                                                         | `KYC-400-001`            |
| Allowed extensions      | `.jpg`, `.jpeg`, `.png`, `.webp`, `.pdf`                                                                                           | `KYC-400-001`            |
| Country format          | ISO 3166-1 alpha-3 (e.g. `GBR`, `USA`, `DEU`) — passed through to the provider                                                     | Provider-side validation |
| Uploads per `step_type` | No fixed cap — re-upload appends another image. Both sides of an ID typically require one upload per side via the `side` parameter | —                        |

Uploading a `document_type=DRIVERS` or `document_type=ID_CARD` typically requires both `FRONT_SIDE` and `BACK_SIDE` uploads to satisfy the corresponding `IDENTITY` step. Passports require a single upload with no `side`.

### KYC-Related User Fields

These fields are populated on the user object after KYC verification:

| Field             | Type            | Description                                           |
| ----------------- | --------------- | ----------------------------------------------------- |
| `first_name`      | string or null  | First name from identity document (set on approval)   |
| `last_names`      | string or null  | Last name(s) from identity document (set on approval) |
| `kyc_verified_at` | integer or null | Approval timestamp (epoch milliseconds)               |
| `kyc_rejected_at` | integer or null | Rejection timestamp (epoch milliseconds)              |

## Next Steps

KYC is one step in the broader [onboarding](/guides/onboarding) flow — read that guide for how the state machine advances when a verification lands, and the [authentication](/authentication) reference for the JWT every KYC endpoint expects. **KYC is the boundary between the identity and the attestation. Everything downstream — scopes, cards, spend — reads off what this step writes.**
