---
title: How to integrate a KYC verification API into your platform
slug: integrate-kyc-api
excerpt: "You can integrate a KYC verification API into your platform using Next.js and Whop, without building a document upload form or ever holding somebody's passport yourself. Learn how to in this guide."
customExcerpt: "You can integrate a KYC verification API into your platform using Next.js and Whop, without building a document upload form or ever holding somebody's passport yourself. Learn how to in this guide."
featureImage: "https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/08/blog-KYC-verification.png"
status: published
publishedAt: "2026-08-07T17:28:00.000Z"
updatedAt: "2026-08-11T12:55:05.000Z"
createdAt: "2026-08-07T17:28:01.393Z"
tags:
  - { name: Tutorials, slug: tutorials }
  - { name: Developers, slug: developers }
authors:
  - { name: East, slug: east }
  - { name: Destinee Walston, slug: destinee }
---

# How to integrate a KYC verification API into your platform

## Key takeaways

- You can verify who someone is inside your own platform without building a document upload form or ever storing a passport, because Whop hosts the page that collects it.
- Start a check with one call, hand the person the link it returns, and Whop reports the outcome on a webhook against a record whose id starts with idpf_.
- A webhook says something changed, not what is now true, so every branch should read the record again rather than writing a status straight from the payload.

<div class="ai-prompt-widget">
  <div class="ai-prompt-widget__header">
    <span class="ai-prompt-widget__icon">✨</span>
    <span class="ai-prompt-widget__title">Build this with AI</span>
  </div>
  <p class="ai-prompt-widget__description">Open the tutorial prompt in your favorite AI coding tool:</p>
  <div class="ai-prompt-widget__buttons" id="ai-prompt-buttons"></div>
</div>

You can integrate a KYC verification API into your platform using Next.js and Whop, without building a document upload form or ever holding somebody's passport yourself.

Instead, we'll get help from Whop, which can confirm users on our behalf. We can start this check with a single call, hand that person a link, and read their confirmed name, date of birth and address back in our own records. This way, we don't have to build the form or hold important documents.

In this tutorial we're going to start a check for one person with some fields pre-filled, send them to the Whop-hosted page, get the results from a webhook, answer the follow-up question a reviewer can come back with, and parse the finished record.

You can walk the whole flow in our [companion demo here](https://nextjs-whop-kyc-demo.vercel.app) and read its [repository here](https://github.com/whopio/whop-tutorials/tree/main/kyc-verification).

## Prerequisites

In this tutorial, we're going to work on an existing Next.js app and add files under `lib/`, `components/` and `app/api/`.

### Set up a sandbox account

Until the end section of this tutorial, we're going to use the Whop sandbox. This is going to help us test the KYC without having to complete a real check.

To set up a sandbox account:

- Go to [sandbox.whop.com](https://sandbox.whop.com/), create an account, then create a business with the Start a business button in the sidebar
- Click on the Dashboard button on your business' sidebar to go to its dashboard
- Once you're in the dashboard, look at the URL in your browser and note your company ID which starts with `biz_`. We'll use it later

### Get an API key

To get an API key, go to the Developer page of your business dashboard and find the Company API keys section. There, click the Create button. This will prompt you to give your API key a name and select which permissions you want to grant to it. You should select:

- `identity:write` to start a check and to answer a follow-up question
- `identity:read` to read it back
- `webhook_receive:identity_profiles` to receive the result events

### Install the packages

Now, let's run the command below to install the packages we're going to use for the KYC integration:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npm install @whop/sdk zod iron-session</code></pre>
  </div>
</div>

We use `@whop/sdk` to let our server work with Whop, `zod` to check that anything that arrives is in the shape we want, and `iron-session` to get a place to keep the IDs we get.

### Environment variables

Create or open `.env.local` in the project root:

<table>
<thead>
<tr>
<th>Variable</th>
<th>Example</th>
<th>Where it comes from</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>WHOP_COMPANY_API_KEY</code></td>
<td><code>apik_...</code></td>
<td>Dashboard, Developer, Company API keys</td>
</tr>
<tr>
<td><code>WHOP_PLATFORM_ACCOUNT_ID</code></td>
<td><code>biz_...</code></td>
<td>Only for the demo, which makes itself a throwaway account to verify</td>
</tr>
<tr>
<td><code>WHOP_WEBHOOK_SECRET</code></td>
<td><code>ws_...</code></td>
<td>Shown when the webhook is created, later</td>
</tr>
<tr>
<td><code>DEMO_SELLER_EMAIL</code></td>
<td><code>you@example.com</code></td>
<td>Only for the demo, a mailbox you own standing in for the person's own</td>
</tr>
<tr>
<td><code>WHOP_SANDBOX</code></td>
<td><code>true</code></td>
<td>Typed by hand. Remove it or set <code>false</code> for production</td>
</tr>
<tr>
<td><code>SESSION_PASSWORD</code></td>
<td>32 random characters</td>
<td>Typed by hand. Encrypts our own cookie</td>
</tr>
<tr>
<td><code>APP_URL</code></td>
<td><code>http://localhost:3000</code></td>
<td>Our app's own address</td>
</tr>
</tbody>
</table>

### Check your env vars on startup

Now, let's create the environment validator so nothing fails without clear errors. Create `lib/env.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">env.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { z } from &quot;zod&quot;;

const schema = z.object({
  WHOP_COMPANY_API_KEY: z
    .string()
    .startsWith(&quot;apik_&quot;, &quot;WHOP_COMPANY_API_KEY must start with apik_&quot;),
  WHOP_PLATFORM_ACCOUNT_ID: z
    .string()
    .startsWith(&quot;biz_&quot;, &quot;WHOP_PLATFORM_ACCOUNT_ID must start with biz_&quot;),
  WHOP_WEBHOOK_SECRET: z.string().optional(),
  DEMO_SELLER_EMAIL: z.string().email(&quot;DEMO_SELLER_EMAIL must be an email address&quot;),
  WHOP_SANDBOX: z
    .string()
    .optional()
    .transform((value) =&gt; value === &quot;true&quot;),
  SESSION_PASSWORD: z
    .string()
    .min(32, &quot;SESSION_PASSWORD must be at least 32 characters&quot;),
  APP_URL: z.string().url(&quot;APP_URL must be a full URL&quot;),
});

export type Env = z.infer&lt;typeof schema&gt;;

let cached: Env | undefined;

export function getEnv(): Env {
  if (cached) return cached;

  const raw: Record&lt;string, string | undefined&gt; = { ...process.env };
  for (const key of Object.keys(raw)) {
    if (raw[key] === &quot;&quot;) delete raw[key];
  }

  const parsed = schema.safeParse(raw);

  if (!parsed.success) {
    const problems = parsed.error.issues
      .map((issue) =&gt; `${issue.path.join(&quot;.&quot;)}: ${issue.message}`)
      .join(&quot;\n&quot;);
    throw new Error(`Environment is not configured.\n${problems}`);
  }

  cached = parsed.data;
  return cached;
}</code></pre>
  </div>
</div>

## How verification works

![Breakdown of the verification details](https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/08/trust-boundary.webp)

Users complete the KYC in a Whop hosted page and we never have to build a form, get camera access or build a doc picker.

We create the verification against an account ID and get back a record of whose ID starts with `idpf_` and a link to that page. Whop reviews the submitted information and lets us know the result using a webhook.

This is required for Whop to pay them out, so it's the gate that any [marketplace paying its sellers](https://whop.com/blog/build-fiverr-clone/) has to clear first.

Verifying a company runs through the same calls with `kind` set to `business`, and the extra fields it wants are in [Whop's business verification docs](https://docs.whop.com/developer/verification/business-structures).

## Connect to Whop

Now, let's build the client. Keep in mind that the `baseURL` is with a capital `URL`. A lowercase `baseUrl` compiles fine, but it's ignored by the SDK and all calls we make would go to production instead of the sandbox environment.

Create `lib/whop.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">whop.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { Whop } from &quot;@whop/sdk&quot;;
import { getEnv } from &quot;@/lib/env&quot;;

let cached: Whop | undefined;

export function whop(): Whop {
  if (cached) return cached;

  const env = getEnv();

  cached = new Whop({
    apiKey: env.WHOP_COMPANY_API_KEY,
    baseURL: env.WHOP_SANDBOX
      ? &quot;https://sandbox-api.whop.com/api/v1&quot;
      : &quot;https://api.whop.com/api/v1&quot;,
    ...(env.WHOP_WEBHOOK_SECRET
      ? { webhookKey: Buffer.from(env.WHOP_WEBHOOK_SECRET).toString(&quot;base64&quot;) }
      : {}),
  });

  return cached;
}</code></pre>
  </div>
</div>

## Start a verification

![Breakdown of document details on verification](https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/08/document-wins.webp)

We need to store the check somewhere. The `lib/store.ts` stands in for the database we already have and it has the account IDs we're verifying, the verification ID, which status we saw last, and the IDs of the webhooks we already handled.

Create `lib/store.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">store.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { getIronSession, type IronSession } from &quot;iron-session&quot;;
import { cookies } from &quot;next/headers&quot;;
import { getEnv } from &quot;@/lib/env&quot;;

export type VerificationStatus =
  | &quot;not_started&quot;
  | &quot;pending&quot;
  | &quot;processing&quot;
  | &quot;manual_review&quot;
  | &quot;action_required&quot;
  | &quot;approved&quot;
  | &quot;rejected&quot;;

export type DemoState = {
  accountId?: string;
  verificationId?: string;
  status?: VerificationStatus;
};

async function session(): Promise&lt;IronSession&lt;DemoState&gt;&gt; {
  const env = getEnv();
  return getIronSession&lt;DemoState&gt;(await cookies(), {
    password: env.SESSION_PASSWORD,
    cookieName: &quot;kyc_demo&quot;,
    cookieOptions: {
      httpOnly: true,
      sameSite: &quot;lax&quot;,
      secure: env.APP_URL.startsWith(&quot;https://&quot;),
      path: &quot;/&quot;,
    },
  });
}

export async function readState(): Promise&lt;DemoState&gt; {
  return { ...(await session()) };
}

export async function writeState(patch: Partial&lt;DemoState&gt;): Promise&lt;DemoState&gt; {
  const current = await session();
  Object.assign(current, patch);
  await current.save();
  return { ...current };
}

export async function clearState(): Promise&lt;void&gt; {
  (await session()).destroy();
}

const delivered = new Set&lt;string&gt;();

export function markDelivered(deliveryId: string): boolean {
  if (delivered.has(deliveryId)) return false;
  delivered.add(deliveryId);
  if (delivered.size &gt; 200) {
    delivered.delete(delivered.values().next().value as string);
  }
  return true;
}</code></pre>
  </div>
</div>

The route we're about to write is public, and every call to it creates something real on Whop, so let's put a rate limit in front of it first. Create `lib/rate-limit.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">rate-limit.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">const hits = new Map&lt;string, { count: number; resetAt: number }&gt;();

export function checkRateLimit(ip: string, limit = 10, windowMs = 60_000): boolean {
  const now = Date.now();
  const entry = hits.get(ip);

  if (!entry || now &gt; entry.resetAt) {
    hits.set(ip, { count: 1, resetAt: now + windowMs });
    return true;
  }

  if (entry.count &gt;= limit) return false;

  entry.count += 1;
  return true;
}

export function clientIp(request: Request): string {
  return request.headers.get(&quot;x-forwarded-for&quot;)?.split(&quot;,&quot;)[0]?.trim() ?? &quot;local&quot;;
}</code></pre>
  </div>
</div>

Now, let's write the route that starts the check. It starts by taking the details we already have about the person, sends them to Whop, and gets back a record ID plus a link to the page where they'll finish. If your app doesn't have signed-in people to verify yet, [Whop OAuth](https://whop.com/blog/add-user-authentication/) is the quickest way to add them.

> 

Create `app/api/verification/start/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;
import { z } from &quot;zod&quot;;
import { whop } from &quot;@/lib/whop&quot;;
import { writeState, type VerificationStatus } from &quot;@/lib/store&quot;;
import { checkRateLimit, clientIp } from &quot;@/lib/rate-limit&quot;;

const body = z
  .object({
    account_id: z.string().min(1),
    first_name: z.string().min(1).max(60),
    last_name: z.string().min(1).max(60),
    date_of_birth: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
    country: z.string().length(2),
    tax_identification_number: z.string().optional(),
    address: z.object({
      line1: z.string().min(1),
      city: z.string().min(1),
      state: z.string().min(1),
      postal_code: z.string().min(1),
    }),
  })
  .refine(
    (value) =&gt;
      value.country.toUpperCase() !== &quot;US&quot; || Boolean(value.tax_identification_number),
    { message: &quot;A tax number is required when the country is US&quot; },
  );

export async function POST(request: Request) {
  if (!checkRateLimit(clientIp(request), 5)) {
    return NextResponse.json({ error: &quot;Too many requests&quot; }, { status: 429 });
  }

  const parsed = body.safeParse(await request.json());
  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.issues[0]?.message ?? &quot;Invalid request&quot; },
      { status: 400 },
    );
  }

  const { account_id, address, ...prefill } = parsed.data;

  try {
              const created = await whop().verifications.create(
      {
        account_id,
        kind: &quot;individual&quot;,
        ...prefill,
        address: { ...address, country: prefill.country },
      },
      { headers: { &quot;Idempotency-Key&quot;: `verify-${account_id}-individual` } },
    );

    if (!created.id) {
      return NextResponse.json({ error: &quot;Whop returned no record id&quot; }, { status: 502 });
    }

    await writeState({
      accountId: account_id,
      verificationId: created.id,
      status: created.status as VerificationStatus | undefined,
    });

          return NextResponse.json({
      id: created.id,
      sessionUrl: created.session_url ?? null,
      status: created.status ?? &quot;pending&quot;,
    });
  } catch (error) {
    console.error(&quot;verification_create_failed&quot;, error);
    return NextResponse.json(
      { error: whopMessage(error) ?? &quot;Could not start the check&quot; },
      { status: 502 },
    );
  }
}

function whopMessage(error: unknown): string | null {
  if (typeof error !== &quot;object&quot; || error === null) return null;
  const shape = error as { error?: { error?: { message?: unknown } } };
  const message = shape.error?.error?.message;
  return typeof message === &quot;string&quot; ? message : null;
}</code></pre>
  </div>
</div>

The fields we sent save the person from typing every detail again, and Whop keeps them unless their document says otherwise.

Keep in mind that we don't want to treat only `201` as a success, because calling this again for the same account doesn't fail. It just hands back the same record and link with a `200`.

## Send the person to the hosted page

The hosted page is a page that we don't build the UI for. We simply hand over the link to the user and Whop takes it from there.

Whop will ask for some details like the user's name and date of birth, because they have to answer somewhere we can't see.

The link we create expires after 7 days and disappears from the record completely once the user fully completes it. Whenever we need the link, we read the record again and use whatever comes back.

Create `app/api/verification/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;
import { whop } from &quot;@/lib/whop&quot;;
import { readState, writeState } from &quot;@/lib/store&quot;;
import { parseVerification } from &quot;@/lib/verification-schema&quot;;

export async function GET() {
  const state = await readState();

  if (!state.verificationId) {
    return NextResponse.json({ record: null });
  }

  try {
    const record = parseVerification(
      await whop().verifications.retrieve(state.verificationId),
    );
    await writeState({ status: record.status });
    return NextResponse.json({ record });
  } catch (error) {
    console.error(&quot;verification_read_failed&quot;, error);
    return NextResponse.json({ error: &quot;Could not read the check&quot; }, { status: 502 });
  }
}</code></pre>
  </div>
</div>

Read the record back with the same ID we started it with. Asking with a different one returns an empty result and a `200`, which reads exactly like the person was never verified.

Now, let's build the form that collects those details and sends them off.

Create `components/StartVerification.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">StartVerification.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import { useState } from &quot;react&quot;;
import { Button, Select, Text, TextField } from &quot;@whop/react/components&quot;;

const VERIFY_COUNTRIES = [
  { code: &quot;us&quot;, label: &quot;United States&quot; },
  { code: &quot;br&quot;, label: &quot;Brazil&quot; },
  { code: &quot;de&quot;, label: &quot;Germany&quot; },
  { code: &quot;tr&quot;, label: &quot;Turkey&quot; },
  { code: &quot;ng&quot;, label: &quot;Nigeria&quot; },
  { code: &quot;ph&quot;, label: &quot;Philippines&quot; },
] as const;

const DEFAULT_COUNTRY = &quot;br&quot;;

export interface StartedVerification {
  id: string;
  sessionUrl: string | null;
  status: string;
}

function Field({
  label,
  children,
  hint,
}: {
  label: string;
  children: React.ReactNode;
  hint?: string;
}) {
  return (
    &lt;label className=&quot;flex flex-col gap-1.5&quot;&gt;
      &lt;Text size=&quot;1&quot; weight=&quot;medium&quot; color=&quot;gray&quot;&gt;
        {label}
      &lt;/Text&gt;
      {children}
      {hint ? (
        &lt;Text size=&quot;1&quot; color=&quot;gray&quot;&gt;
          {hint}
        &lt;/Text&gt;
      ) : null}
    &lt;/label&gt;
  );
}

export function StartVerification({
  accountId,
  defaults,
  onStarted,
  onLog,
}: {
  accountId: string | null;
  defaults: { first: string; last: string };
  onStarted: (started: StartedVerification) =&gt; void;
  onLog: (kind: string, detail: string) =&gt; void;
}) {
  const [country, setCountry] = useState&lt;string&gt;(DEFAULT_COUNTRY);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState&lt;string | null&gt;(null);

  const needsTaxNumber = country.toUpperCase() === &quot;US&quot;;

  async function submit(event: React.FormEvent&lt;HTMLFormElement&gt;) {
    event.preventDefault();
    if (!accountId) return;
    setError(null);
    setBusy(true);

    const form = new FormData(event.currentTarget);
    const payload = {
      account_id: accountId,
      first_name: String(form.get(&quot;first_name&quot;)),
      last_name: String(form.get(&quot;last_name&quot;)),
      date_of_birth: String(form.get(&quot;date_of_birth&quot;)),
      country,
      tax_identification_number:
        String(form.get(&quot;tax_identification_number&quot;) || &quot;&quot;) || undefined,
      address: {
        line1: String(form.get(&quot;line1&quot;)),
        city: String(form.get(&quot;city&quot;)),
        state: String(form.get(&quot;state&quot;)),
        postal_code: String(form.get(&quot;postal_code&quot;)),
      },
    };

    onLog(&quot;POST&quot;, &quot;/api/verification/start&quot;);

    const response = await fetch(&quot;/api/verification/start&quot;, {
      method: &quot;POST&quot;,
      headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
      body: JSON.stringify(payload),
    });
    const data = await response.json();
    setBusy(false);

    if (!response.ok) {
      setError(String(data.error));
      onLog(&quot;error&quot;, String(data.error));
      return;
    }

    onLog(&quot;idpf&quot;, data.id);
    onStarted(data as StartedVerification);
  }

  return (
    &lt;form onSubmit={submit} className=&quot;flex flex-col gap-4&quot;&gt;
      &lt;div className=&quot;grid gap-4 sm:grid-cols-2&quot;&gt;
        &lt;Field label=&quot;First name&quot;&gt;
          &lt;TextField.Root size=&quot;2&quot; variant=&quot;soft&quot;&gt;
            &lt;TextField.Input name=&quot;first_name&quot; defaultValue={defaults.first} required /&gt;
          &lt;/TextField.Root&gt;
        &lt;/Field&gt;

        &lt;Field label=&quot;Last name&quot;&gt;
          &lt;TextField.Root size=&quot;2&quot; variant=&quot;soft&quot;&gt;
            &lt;TextField.Input name=&quot;last_name&quot; defaultValue={defaults.last} required /&gt;
          &lt;/TextField.Root&gt;
        &lt;/Field&gt;

        &lt;Field label=&quot;Date of birth&quot;&gt;
          &lt;TextField.Root size=&quot;2&quot; variant=&quot;soft&quot;&gt;
            &lt;TextField.Input
              name=&quot;date_of_birth&quot;
              type=&quot;date&quot;
              defaultValue=&quot;1990-04-12&quot;
              required
            /&gt;
          &lt;/TextField.Root&gt;
        &lt;/Field&gt;

        &lt;Field label=&quot;Country&quot;&gt;
          &lt;Select.Root size=&quot;2&quot; value={country} onValueChange={setCountry}&gt;
            &lt;Select.Trigger variant=&quot;soft&quot; color=&quot;gray&quot; /&gt;
            &lt;Select.Content&gt;
              {VERIFY_COUNTRIES.map((c) =&gt; (
                &lt;Select.Item key={c.code} value={c.code}&gt;
                  {c.label}
                &lt;/Select.Item&gt;
              ))}
            &lt;/Select.Content&gt;
          &lt;/Select.Root&gt;
        &lt;/Field&gt;
      &lt;/div&gt;

      &lt;Field
        label=&quot;Tax number&quot;
        hint={
          needsTaxNumber
            ? &quot;Whop needs this for someone in the United States. Leave it out and the check still starts and can still be approved, and then the payouts account cannot be created later.&quot;
            : undefined
        }
      &gt;
        &lt;TextField.Root size=&quot;2&quot; variant=&quot;soft&quot;&gt;
          &lt;TextField.Input
            name=&quot;tax_identification_number&quot;
            required={needsTaxNumber}
            placeholder={needsTaxNumber ? &quot;Required&quot; : &quot;Optional&quot;}
          /&gt;
        &lt;/TextField.Root&gt;
      &lt;/Field&gt;

      &lt;Field label=&quot;Address&quot;&gt;
        &lt;TextField.Root size=&quot;2&quot; variant=&quot;soft&quot;&gt;
          &lt;TextField.Input name=&quot;line1&quot; defaultValue=&quot;Rua das Flores 210&quot; required /&gt;
        &lt;/TextField.Root&gt;
      &lt;/Field&gt;

      &lt;div className=&quot;grid gap-4 sm:grid-cols-3&quot;&gt;
        &lt;Field label=&quot;City&quot;&gt;
          &lt;TextField.Root size=&quot;2&quot; variant=&quot;soft&quot;&gt;
            &lt;TextField.Input name=&quot;city&quot; defaultValue=&quot;Sao Paulo&quot; required /&gt;
          &lt;/TextField.Root&gt;
        &lt;/Field&gt;

        &lt;Field label=&quot;State&quot;&gt;
          &lt;TextField.Root size=&quot;2&quot; variant=&quot;soft&quot;&gt;
            &lt;TextField.Input name=&quot;state&quot; defaultValue=&quot;SP&quot; required /&gt;
          &lt;/TextField.Root&gt;
        &lt;/Field&gt;

        &lt;Field label=&quot;Postal code&quot;&gt;
          &lt;TextField.Root size=&quot;2&quot; variant=&quot;soft&quot;&gt;
            &lt;TextField.Input name=&quot;postal_code&quot; defaultValue=&quot;01452-000&quot; required /&gt;
          &lt;/TextField.Root&gt;
        &lt;/Field&gt;
      &lt;/div&gt;

      {error ? (
        &lt;Text size=&quot;1&quot; className=&quot;text-[#FA4616]&quot;&gt;
          {error}
        &lt;/Text&gt;
      ) : null}

      &lt;Button type=&quot;submit&quot; size=&quot;2&quot; disabled={busy || !accountId} className=&quot;self-start&quot;&gt;
        {busy ? &quot;Starting&quot; : &quot;Start the check&quot;}
      &lt;/Button&gt;
    &lt;/form&gt;
  );
}</code></pre>
  </div>
</div>

## Register the webhook

Now, let's set up the webhook so Whop can tell us when the check moves. Whop does that by calling a URL of ours, which means the URL has to be reachable from the internet.

This is the pont where we deploy or open a tunnel, because a localhost address won't receive anything.

To create the webhook:

- Go to your business' Whop dashboard and open the Developer page
- Find the Webhooks section and click the Create button
- Point the webhook to your app's address plus `/api/webhooks/whop`
- Subscribe to the events `identity_profile_approved`, `identity_profile_needs_action`, `identity_profile_rejected` and `identity_profile_updated`.

Once you create the webhook, copy the secret it shows into the `WHOP_WEBHOOK_SECRET` environment variable.

## Handle the webhook

Now, let's write the route that receives those events. It does three things: it checks the delivery is really from Whop, it ignores anything it has already seen, and it reads the record again to find out what actually changed.

That last one is the part worth remembering. A webhook tells us something changed, not what is now true, so we never write a status straight from what it says.

> 

Create `app/api/webhooks/whop/route.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;
import { whop } from &quot;@/lib/whop&quot;;
import { getEnv } from &quot;@/lib/env&quot;;
import { markDelivered } from &quot;@/lib/store&quot;;

type IdentityProfileEvent = {
  id: string;
  type: string;
  data: { id?: string; status?: string };
};

export async function POST(request: Request) {
  if (!getEnv().WHOP_WEBHOOK_SECRET) {
    return NextResponse.json(
      { error: &quot;webhook secret is not configured&quot; },
      { status: 500 },
    );
  }

  const raw = await request.text();
  const headers = Object.fromEntries(request.headers.entries());

  let event: IdentityProfileEvent;
  try {
    event = whop().webhooks.unwrap(raw, { headers }) as unknown as IdentityProfileEvent;
  } catch (error) {
    console.error(&quot;webhook_signature_failed&quot;, error);
    return NextResponse.json({ error: &quot;bad signature&quot; }, { status: 401 });
  }

  if (!event.type?.startsWith(&quot;identity_profile.&quot;)) {
    return NextResponse.json({ received: true, outcome: &quot;ignored&quot; });
  }

  if (!markDelivered(headers[&quot;webhook-id&quot;] ?? event.id)) {
    return NextResponse.json({ received: true, outcome: &quot;duplicate&quot; });
  }

  console.log(
    JSON.stringify({
      at: &quot;identity_webhook&quot;,
      type: event.type,
      profile: event.data?.id,
      status: event.data?.status,
      delivery: headers[&quot;webhook-id&quot;],
    }),
  );

  return NextResponse.json({ received: true, outcome: &quot;applied&quot; });
}</code></pre>
  </div>
</div>

Keep in mind that the same event can arrive more than once, so we store the delivery ID from the `webhook-id` header so we can skip anything we have already handled.

> 

## Read the verified details back

![Verified content for the KYC verification](https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/08/approval-switches.webp)

It's time to read the finished record and display it. What we get back is the document's account of the person rather than ours, so the name, date of birth and country all belong to whatever they used to prove themselves.

Other details like tax numbers, email and phone are never sent back at all.

Create `lib/verification-schema.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">verification-schema.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { z } from &quot;zod&quot;;
import type { VerificationStatus } from &quot;@/lib/store&quot;;

export type RequestedItemType = &quot;text&quot; | &quot;date&quot; | &quot;phone&quot; | &quot;address&quot; | &quot;select&quot; | &quot;files&quot;;

export interface RequestedItem {
  id: string;
  field: string;
  type: RequestedItemType;
  label: string;
  description: string | null;
  options: string[];
  errorMessage: string | null;
}

export interface VerifiedAddress {
  line1: string | null;
  line2: string | null;
  city: string | null;
  state: string | null;
  postalCode: string | null;
  country: string | null;
}

export interface VerifiedRecord {
  id: string;
  kind: &quot;individual&quot; | &quot;business&quot;;
  status: VerificationStatus;
  firstName: string | null;
  lastName: string | null;
  dateOfBirth: string | null;
  country: string | null;
  address: VerifiedAddress;
  sessionUrl: string | null;
  requestedInformation: RequestedItem[];
  createdAt: string;
  updatedAt: string | null;
  normalised: string[];
}

const ALPHA3: Record&lt;string, string&gt; = {
  USA: &quot;US&quot;,
  BRA: &quot;BR&quot;,
  NGA: &quot;NG&quot;,
  PHL: &quot;PH&quot;,
  DEU: &quot;DE&quot;,
  TUR: &quot;TR&quot;,
};

const raw = z
  .object({
    id: z.string(),
    kind: z.enum([&quot;individual&quot;, &quot;business&quot;]).default(&quot;individual&quot;),
    status: z.enum([
      &quot;not_started&quot;,
      &quot;pending&quot;,
      &quot;processing&quot;,
      &quot;manual_review&quot;,
      &quot;action_required&quot;,
      &quot;approved&quot;,
      &quot;rejected&quot;,
    ]),
    first_name: z.string().nullish(),
    last_name: z.string().nullish(),
    date_of_birth: z.string().nullish(),
    country: z.string().nullish(),
    address: z
      .object({
        line1: z.string().nullish(),
        line2: z.string().nullish(),
        city: z.string().nullish(),
        state: z.string().nullish(),
        postal_code: z.string().nullish(),
        country: z.string().nullish(),
      })
      .nullish(),
    session_url: z.string().nullish(),
    requested_information: z
      .array(
        z.object({
          id: z.string(),
          field: z.string(),
          type: z.enum([&quot;text&quot;, &quot;date&quot;, &quot;phone&quot;, &quot;address&quot;, &quot;select&quot;, &quot;files&quot;]),
          label: z.string(),
          description: z.string().nullish(),
          options: z.array(z.string()).nullish(),
          error_message: z.string().nullish(),
        }),
      )
      .nullish(),
    created_at: z.iso.datetime({ offset: true }),
    updated_at: z.iso.datetime({ offset: true }).nullish(),
  })
  .passthrough();

export function parseVerification(input: unknown): VerifiedRecord {
  const source = raw.parse(input);
  const normalised: string[] = [];

  const clean = (value: string | null | undefined, path: string): string | null =&gt; {
    if (value === undefined || value === null) return null;
    if (value === &quot;&quot; || value === &quot;null&quot;) {
      normalised.push(path);
      return null;
    }
    return value;
  };

  const country = (value: string | null | undefined, path: string): string | null =&gt; {
    const cleaned = clean(value, path);
    if (cleaned === null) return null;
    if (cleaned.length === 2) return cleaned.toUpperCase();
    normalised.push(path);
    return ALPHA3[cleaned.toUpperCase()] ?? cleaned.toUpperCase();
  };

  const address = source.address ?? {};

  return {
    id: source.id,
    kind: source.kind,
    status: source.status,
    firstName: clean(source.first_name, &quot;firstName&quot;),
    lastName: clean(source.last_name, &quot;lastName&quot;),
    dateOfBirth: clean(source.date_of_birth, &quot;dateOfBirth&quot;),
    country: country(source.country, &quot;country&quot;),
    address: {
      line1: clean(address.line1, &quot;address.line1&quot;),
      line2: clean(address.line2, &quot;address.line2&quot;),
      city: clean(address.city, &quot;address.city&quot;),
      state: clean(address.state, &quot;address.state&quot;),
      postalCode: clean(address.postal_code, &quot;address.postalCode&quot;),
      country: country(address.country, &quot;address.country&quot;),
    },
    sessionUrl: source.session_url ?? null,
    requestedInformation: (source.requested_information ?? []).map((item) =&gt; ({
      id: item.id,
      field: item.field,
      type: item.type,
      label: item.label,
      description: item.description ?? null,
      options: item.options ?? [],
      errorMessage: item.error_message ?? null,
    })),
    createdAt: source.created_at,
    updatedAt: source.updated_at ?? null,
    normalised,
  };
}</code></pre>
  </div>
</div>

Then, let's create `components/VerifiedDetails.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">VerifiedDetails.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import type { VerifiedRecord } from &quot;@/lib/verification-schema&quot;;

function Row({
  label,
  value,
  path,
  normalised,
}: {
  label: string;
  value: string | null;
  path: string;
  normalised: string[];
}) {
  const repaired = normalised.includes(path);

  return (
    &lt;div className=&quot;flex items-baseline justify-between gap-4 border-b border-[#E3E2DE] py-2 last:border-0&quot;&gt;
      &lt;span className=&quot;text-xs text-[#9A9993]&quot;&gt;{label}&lt;/span&gt;
      &lt;span className=&quot;flex items-center gap-1.5 font-mono text-sm&quot;&gt;
        {value ?? &lt;span className=&quot;text-[#B6B5B0]&quot;&gt;not given&lt;/span&gt;}
        {repaired ? (
          &lt;span className=&quot;text-[#FA4616]&quot; title=&quot;Repaired on the way in&quot;&gt;
            *
          &lt;/span&gt;
        ) : null}
      &lt;/span&gt;
    &lt;/div&gt;
  );
}

export function VerifiedDetails({ record }: { record: VerifiedRecord }) {
  const rows: Array&lt;[string, string | null, string]&gt; = [
    [&quot;First name&quot;, record.firstName, &quot;firstName&quot;],
    [&quot;Last name&quot;, record.lastName, &quot;lastName&quot;],
    [&quot;Date of birth&quot;, record.dateOfBirth, &quot;dateOfBirth&quot;],
    [&quot;Country&quot;, record.country, &quot;country&quot;],
    [&quot;Address line 1&quot;, record.address.line1, &quot;address.line1&quot;],
    [&quot;City&quot;, record.address.city, &quot;address.city&quot;],
    [&quot;State&quot;, record.address.state, &quot;address.state&quot;],
    [&quot;Postal code&quot;, record.address.postalCode, &quot;address.postalCode&quot;],
    [&quot;Address country&quot;, record.address.country, &quot;address.country&quot;],
  ];

  return (
    &lt;div className=&quot;flex flex-col&quot;&gt;
      {rows.map(([label, value, path]) =&gt; (
        &lt;Row
          key={path}
          label={label}
          value={value}
          path={path}
          normalised={record.normalised}
        /&gt;
      ))}
      {record.normalised.length &gt; 0 ? (
        &lt;p className=&quot;mt-3 text-xs text-[#6B6A66]&quot;&gt;
          A star marks a value that arrived in a shape the schema had to repair.
        &lt;/p&gt;
      ) : null}
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

Accounts get enabled when they get approval. Before that, `standard_payout`, `crypto_payout` and `transfer` all sit at `inactive`, and `required_actions` still asks for identity verification.

After it, those three flip to active and `required_actions` is empty. Money coming the other way, from your customers into your app, is a separate integration that the [checkout API](https://whop.com/blog/integrate-checkout-api/) covers.

## Switching to production

At this point we can start an identity check from our own app, follow it to approval, answer the question a reviewer comes back with, and read the verified details into our own records.

- Create a production Company API key with the same three permissions and swap `WHOP_COMPANY_API_KEY`.
- Remove `WHOP_SANDBOX` or set it to `false`. The SDK's address switches with it.
- Register the webhook again in the production dashboard against the production URL, and swap `WHOP_WEBHOOK_SECRET`.
- Point `APP_URL` at the real domain.
- Pin `Api-Version-Date`, because these endpoints are marked experimental and a spec change would otherwise move the response shape underneath us.
- Run one real check end to end before anybody else does.

## Build the rest of your platform with Whop

Your app now has an integrated KYC system to confirm who someone is, without ever storing an identity document. That's one piece of running a platform, and Whop can help you with lots of different pieces.

If you don't have a platform of your own yet, our building guides like [How to build a Gumroad clone](https://whop.com/blog/build-gumroad-clone/) can help.

To see everything else Whop can do, check out our other [tutorials](https://whop.com/blog/t/tutorials/) and the [Whop developer docs](https://docs.whop.com/).

**[Go to Whop developer docs](https://docs.whop.com/)**
