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

# KYC via web link

> Use a Contro-hosted KYC page so your users complete verification without you handling sensitive data

The web link method lets you delegate KYC entirely to Contro. You request a hosted KYC session, redirect your user to the URL, and create the cardholder after the session completes. You never handle identity documents directly.

Contro manages the underlying identity verification, so there is nothing for you to integrate or configure beyond the endpoints below.

## Flow

```
1. POST /partner/kyc-sessions         → { id, url, expiresAt }
2. User opens the URL and completes KYC on the Contro-hosted page
3. Verification result received → session status updated to "completed"
4. Poll GET /partner/kyc-sessions/{id} or listen for kyc_session.completed webhook
5. POST /partner/cardholders           with kycSource: "web" + kycSessionId
```

## Step 1: Create a KYC session

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.contro.me/v1/partner/kyc-sessions \
    -H "x-contro-api-key: $CONTRO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "externalUserId": "user_42"
    }'
  ```

  ```typescript SDK (Node) theme={null}
  const session = await contro.kycSessions.create({
    externalUserId: "user_42",
  });
  // session = { id: "ks_abc123", url: "https://kyc.contro.me/s/ks_abc123", expiresAt: "..." }
  ```

  ```python SDK (Python) theme={null}
  session = contro.kyc_sessions.create(external_user_id="user_42")
  # session = { id: "ks_abc123", url: "https://kyc.contro.me/s/ks_abc123", expires_at: "..." }
  ```

  ```typescript TypeScript (fetch) theme={null}
  const session = await fetch(`${BASE_URL}/partner/kyc-sessions`, {
    method: "POST",
    headers,
    body: JSON.stringify({ externalUserId: "user_42" }),
  }).then((r) => r.json());

  // session = { id: "ks_abc123", url: "https://kyc.contro.me/s/ks_abc123", expiresAt: "..." }
  ```
</CodeGroup>

The response includes a `url` that you open or embed for your user. Sessions expire after 1 hour.

## Step 2: User completes KYC

Open or embed the `url` in your application. The user completes identity verification on the Contro-hosted page.

## Step 3: Check session status

Poll the session status or subscribe to the `kyc_session.completed` [webhook event](/partner/webhooks).

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.contro.me/v1/partner/kyc-sessions/ks_abc123 \
    -H "x-contro-api-key: $CONTRO_API_KEY"
  ```

  ```typescript SDK (Node) theme={null}
  const session = await contro.kycSessions.retrieve(sessionId);
  // session.status = "pending" | "completed" | "expired" | "failed"
  ```

  ```python SDK (Python) theme={null}
  session = contro.kyc_sessions.retrieve(session_id)
  # session.status = "pending" | "completed" | "expired" | "failed"
  ```

  ```typescript TypeScript (fetch) theme={null}
  const session = await fetch(`${BASE_URL}/partner/kyc-sessions/${sessionId}`, {
    headers,
  }).then((r) => r.json());

  // session.status = "pending" | "completed" | "expired" | "failed"
  ```
</CodeGroup>

### Session statuses

| Status | Description |
| - | - |
| `creating` | Session is being provisioned |
| `pending` | Waiting for user to complete KYC |
| `completed` | KYC approved — ready to create cardholder |
| `expired` | Session expired (1 hour TTL) |
| `failed` | KYC verification was rejected |

## Step 4: Create the cardholder

Once the session status is `completed`, create the cardholder referencing the session:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.contro.me/v1/partner/cardholders \
    -H "x-contro-api-key: $CONTRO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "externalUserId": "user_42",
      "kycSource": "web",
      "kycSessionId": "ks_abc123",
      "email": "jane@example.com",
      "phoneNumber": "+14155552671"
    }'
  ```

  ```typescript SDK (Node) theme={null}
  const cardholder = await contro.cardholders.create({
    externalUserId: "user_42",
    kycSource: "web",
    kycSessionId: "ks_abc123",
    email: "jane@example.com",
    phoneNumber: "+14155552671",
  });
  ```

  ```python SDK (Python) theme={null}
  cardholder = contro.cardholders.create(
      external_user_id="user_42",
      kyc_source="web",
      kyc_session_id="ks_abc123",
      email="jane@example.com",
      phone_number="+14155552671",
  )
  ```

  ```typescript TypeScript (fetch) theme={null}
  const cardholder = await fetch(`${BASE_URL}/partner/cardholders`, {
    method: "POST",
    headers,
    body: JSON.stringify({
      externalUserId: "user_42",
      kycSource: "web",
      kycSessionId: "ks_abc123",
      email: "jane@example.com",
      phoneNumber: "+14155552671",
    }),
  }).then((r) => r.json());
  ```
</CodeGroup>

## Key details

* **`firstName` and `lastName`** are extracted automatically from the completed verification.
* Each session can only be used for **one cardholder** (enforced by a unique constraint). Attempting to reuse a session returns `409 Conflict`.
* The session must be `completed` before cardholder creation. If the session is still `pending`, the endpoint re-checks the latest verification result synchronously as a fallback for delayed updates.

## Required fields

| Field | Type | Description |
| - | - | - |
| `externalUserId` | string | Your unique identifier for this user |
| `kycSource` | string | Must be `"web"` |
| `kycSessionId` | string | KYC session ID from step 1 |
| `email` | string | Valid email address |
| `phoneNumber` | string | E.164 formatted phone number |

## Sandbox behavior

In sandbox mode, `POST /partner/kyc-sessions` returns a mock URL and the session auto-completes immediately. No real identity verification is performed.
