---
title: "How to integrate a checkout API, step by step"
slug: integrate-checkout-api
excerpt: "You can integrate a checkout API into your app using Next.js and Whop, without building a payment form or touching card details. Learn how in this guide."
customExcerpt: "You can integrate a checkout API into your app using Next.js and Whop, without building a payment form or touching card details. Learn how in this guide."
featureImage: "https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/08/blog-API-Checkout.png"
status: published
publishedAt: "2026-08-03T21:42:17.000Z"
updatedAt: "2026-08-04T11:36:09.000Z"
createdAt: "2026-08-01T06:00:13.631Z"
tags:
  - { name: Tutorials, slug: tutorials }
  - { name: Developers, slug: developers }
authors:
  - { name: East, slug: east }
  - { name: Destinee Walston, slug: destinee }
---

# How to integrate a checkout API, step by step

## Key takeaways

- You can take payments inside your own app with Next.js and Whop, without building a payment form or storing cards.
- Every checkout carries your own order ID, so each payment comes back tied to the right sale.
- The webhook unlocks the purchase even when the buyer closes the tab before the page finishes.

<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 sell products in your app without building a payment form, storing card information, or handling the entire payment backend by yourself.

By integrating the Whop checkout API, you can drop a checkout in your page and tag it with your own order ID before the buyer ever sees it.

In this tutorial we're going to cover creating a product and a price from the terminal using the [Whop CLI](https://whop.com/blog/cli/), tagging a checkout with your own order number, putting the payment form inside your page so the buyer never leaves it.

Confirming the payment on your server before trusting it, and handing over what was bought from a webhook so it still happens when the buyer's laptop dies halfway through.

You can check out the entire flow in our [companion demo here](https://nextjs-whop-checkout-api-demo.vercel.app) and its [repository here](https://github.com/whopio/whop-tutorials/tree/main/checkout-api).

## Prerequisites

For the sake of this tutorial, we're going to work on an existing Next.js app and we'll add files under `lib/`, `components/`, `constants/`, `app/pricing/` and `app/api/`, plus one content security policy line in `next.config.ts`.

The code we'll use is TypeScript, but Whop provides SDKs for Python and Ruby as well. The payment form is a script tag that can be used on any page that serves HTML, and the webhook is an ordinary signed POST.

A Rails or Django app does all of this in the same order with the same four objects.

### Set up a sandbox account

We're going to start off by using the Whop sandbox. It's going to allow us to test how payments work without touching real money.

Go to [Sandbox.Whop.com](https://sandbox.whop.com/), create a sandbox account, create a whop using the Start a business button on the left sidebar, and open its dashboard once created.

We're going to cover switching to the production environment at Whop.com later in the guide.

### Get an API key

While you're viewing the dashboard of your new company, go to the Developer page of the dashboard and find the Company API keys section. There, click the Create button to create a new API key and grant the following permissions:

- `access_pass:create`
- `access_pass:basic:read`
- `plan:create`,
- `plan:basic:read`
- `checkout_configuration:create`
- `payment:basic:read`,
- `developer:manage_webhook`
- `webhook_receive:payments`
- `member:basic:read`
- `member:email:read`
- `member:phone:read`
- `promo_code:basic:read`
- `payment:dispute:read`
- `payment:resolution_center_case:read`

After you create the API key, take note of it. We're going to add it to the environment variables soon.

### Install the packages

Now, let's install the Whop SDK, the Whop checkout, and Zod using the command:

<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 @whop/checkout zod</code></pre>
  </div>
</div>

`@whop/sdk` allows us to talk to Whop from our server, `@whop/checkout` is the payment form we're going to drop into our page, and `zod` checks that things arriving from outside are shaped the way we expect.

### Environment variables

To add the necessary environment variables, go to or create the `.env.local` file in the project root and populate it with:

<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>The key we just made</td>
</tr>
<tr>
<td><code>WHOP_WEBHOOK_SECRET</code></td>
<td><code>whsec_...</code></td>
<td>Shown when we create the webhook, later</td>
</tr>
<tr>
<td><code>WHOP_SANDBOX</code></td>
<td><code>true</code></td>
<td>Typed by hand. Remove it or set to <code>false</code> for production</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 the env vars on startup

Go to `lib/` and create a file called `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().min(1, &quot;WHOP_COMPANY_API_KEY is missing&quot;),
  WHOP_WEBHOOK_SECRET: z
    .string()
    .optional()
    .transform((value) =&gt; (value &amp;&amp; value.length &gt; 0 ? value : undefined)),
  WHOP_SANDBOX: z
    .string()
    .optional()
    .transform((value) =&gt; value === &quot;true&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 parsed = schema.safeParse(process.env);

  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 a sale moves through this

All the following sections build a part of the bigger money flow so let's take a look at the bigger picture before we start.

There are four elements involved: a product (`prod_`) is that we sell. A plan (`plan_`) is its price. The checkout (`ch_`) is a prefilled payment form, and the payment (`pay_`) is the money landing.

When a customer clicks on "Buy," our server creates a pending order and asks Whop for a checkout. Whop returns a checkout ID and the embedded payment form loads in our page, attached to that ID.

After the buyer completes the payment, we confirm it with Whop and Whop posts to our server to let us know the money has landed.

Both of the last two steps find the same order, because we attached its ID before the buyer saw the card field.

## Connect to Whop

Go to `lib/` and create a file called `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>

> 

## Create something to sell

Now, we need a product and a price to make things work. One of the easiest ways to create them is using the Whop CLI. You can download it using the command:

<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 -g @whop/cli</code></pre>
  </div>
</div>

> 

Then, use the context menu (three dots to the right of the product created) to copy its product ID. After that, go to the Checkout links page to find the product, and copy its plan ID from there the same way.

After downloading the CLI, sign in with `whop auth login` and pick the API key option rather than the browser one and use the key you created in the sandbox dashboard.

Browser logins always sign you in to Whop.com and cannot reach the sandbox. You can also follow the commands below to do it:

<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"># macOS and Linux
export WHOP_API_BASE_URL=&quot;https://sandbox-api.whop.com/api/v1&quot;

# Windows PowerShell
$env:WHOP_API_BASE_URL = &quot;https://sandbox-api.whop.com/api/v1&quot;

whop auth login --method api-key --api-key &lt;your key&gt;</code></pre>
  </div>
</div>

> 

Let's create the product first using the command:

<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">whop products create --title &quot;Pro pass&quot; --description &quot;Sold through our own checkout.&quot; --visibility visible</code></pre>
  </div>
</div>

> 

The create product command we used prints a `prod_` ID. Paste it into the plan command, which will not run without it:

<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">whop plans create --product_id prod_XXXXXXXXX --plan_type one_time --initial_price 49.99 --visibility visible</code></pre>
  </div>
</div>

That prints a `plan_` id. Go to `constants/` and create a file called `whop-ids.ts`, and put both in it:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">whop-ids.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">export const WHOP_IDS = {
  productId: &quot;prod_XXXXXXXXX&quot;,
  planId: &quot;plan_XXXXXXXXX&quot;,
  priceUsd: 49.99,
} as const;</code></pre>
  </div>
</div>

## Keep our own record of the sale

Now, Whop is about to hold the money. But first, we need something of our own for it to point back at. Go to `lib/` and create a file called `orders.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">orders.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 { randomUUID } from &quot;node:crypto&quot;;

export type OrderStatus = &quot;pending&quot; | &quot;paid&quot;;

export interface Order {
  id: string;
  status: OrderStatus;
  userId: string;
  paymentId?: string;
  createdAt: string;
  paidAt?: string;
}

const orders = new Map&lt;string, Order&gt;();

export function createOrder(userId: string): Order {
  const order: Order = {
    id: randomUUID(),
    status: &quot;pending&quot;,
    userId,
    createdAt: new Date().toISOString(),
  };

  orders.set(order.id, order);
  return order;
}

export function markPaid(id: string, paymentId: string): Order | undefined {
  const order = orders.get(id);
  if (!order) return undefined;
  if (order.status === &quot;paid&quot;) return order;

  const paid: Order = {
    ...order,
    status: &quot;paid&quot;,
    paymentId,
    paidAt: new Date().toISOString(),
  };

  orders.set(id, paid);
  return paid;
}</code></pre>
  </div>
</div>

> 

The user ID has to come from somewhere that the browser cannot reach. Go to `lib/` and create a file called `auth.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">auth.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">// Replace this with however the app already knows who is browsing: a session
// cookie, a JWT, whatever is in place. It has to run on the server, and it must
// never read an identity out of the request body, because the browser can put
// anything there.
export async function currentUserId(): Promise&lt;string | null&gt; {
  return &quot;user_demo&quot;;
}</code></pre>
  </div>
</div>

## Put our order number on the checkout

In this route, we write our own order first, then ask Whop for a checkout that carries that order's ID. We attach the ID we get to the checkout form. Go to `app/api/checkout/` and create a file called `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 { currentUserId } from &quot;@/lib/auth&quot;;
import { createOrder } from &quot;@/lib/orders&quot;;
import { WHOP_IDS } from &quot;@/constants/whop-ids&quot;;

export async function POST() {
  const userId = await currentUserId();

  if (!userId) {
    return NextResponse.json({ error: &quot;Sign in first.&quot; }, { status: 401 });
  }

  const order = createOrder(userId);

  const checkout = await whop().checkoutConfigurations.create(
    {
      plan_id: WHOP_IDS.planId,
      mode: &quot;payment&quot;,
      metadata: { order_id: order.id },
    },
    { headers: { &quot;Idempotency-Key&quot;: `checkout-${order.id}` } },
  );

  return NextResponse.json({ orderId: order.id, sessionId: checkout.id });
}</code></pre>
  </div>
</div>

The route asks who is signed in and works from that, so a buyer cannot check out as somebody else. That `Idempotency-Key` header stops a retry of the same request from turning into two checkouts.

> 

## Put the checkout on our page

We can use an embedded checkout or a Whop-hosted checkout page, but we don't want the buyers to leave our platform, so we'll go ahead with the embedded option.

`sessionId` is the checkout we just made, so the form that appears is already carrying our order number.

Go to `components/` and create a file called `Checkout.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Checkout.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 { WhopCheckoutEmbed } from &quot;@whop/checkout/react&quot;;

type Stage =
  | { name: &quot;idle&quot; }
  | { name: &quot;paying&quot;; sessionId: string }
  | { name: &quot;checking&quot; }
  | { name: &quot;done&quot; }
  | { name: &quot;failed&quot;; message: string };

export function Checkout({
  environment,
  returnUrl,
}: {
  environment: &quot;production&quot; | &quot;sandbox&quot;;
  returnUrl: string;
}) {
  const [stage, setStage] = useState&lt;Stage&gt;({ name: &quot;idle&quot; });

  async function start() {
    const response = await fetch(&quot;/api/checkout&quot;, { method: &quot;POST&quot; });

    if (!response.ok) {
      setStage({ name: &quot;failed&quot;, message: &quot;We could not start the checkout.&quot; });
      return;
    }

    const { sessionId } = (await response.json()) as { sessionId: string };
    setStage({ name: &quot;paying&quot;, sessionId });
  }

  async function confirm(receiptId: string) {
    setStage({ name: &quot;checking&quot; });

    for (let attempt = 0; attempt &lt; 10; attempt++) {
      const response = await fetch(&quot;/api/verify&quot;, {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify({ receiptId }),
      });

      if (response.ok) {
        setStage({ name: &quot;done&quot; });
        return;
      }

      if (response.status !== 202) {
        setStage({ name: &quot;failed&quot;, message: &quot;We could not confirm that payment.&quot; });
        return;
      }

      await new Promise((resolve) =&gt; setTimeout(resolve, 2000));
    }

    setStage({ name: &quot;failed&quot;, message: &quot;Whop is taking longer than usual to confirm this.&quot; });
  }

  if (stage.name === &quot;done&quot;) {
    return &lt;p className=&quot;text-lg font-medium&quot;&gt;Payment confirmed. Your Pro pass is active.&lt;/p&gt;;
  }

  if (stage.name === &quot;checking&quot;) {
    return &lt;p className=&quot;text-neutral-600&quot;&gt;Checking the payment with Whop.&lt;/p&gt;;
  }

  if (stage.name === &quot;paying&quot;) {
    return (
      &lt;WhopCheckoutEmbed
        sessionId={stage.sessionId}
        environment={environment}
        returnUrl={returnUrl}
        theme=&quot;light&quot;
        onComplete={(_planId, receiptId) =&gt; {
          if (receiptId) void confirm(receiptId);
        }}
        fallback={&lt;p className=&quot;text-neutral-600&quot;&gt;Loading the checkout.&lt;/p&gt;}
      /&gt;
    );
  }

  return (
    &lt;div className=&quot;flex flex-col gap-2&quot;&gt;
      &lt;button
        type=&quot;button&quot;
        onClick={() =&gt; void start()}
        className=&quot;rounded-lg bg-neutral-900 px-5 py-3 font-medium text-white&quot;
      &gt;
        Buy
      &lt;/button&gt;
      {stage.name === &quot;failed&quot; ? (
        &lt;p className=&quot;text-sm text-red-600&quot;&gt;{stage.message}&lt;/p&gt;
      ) : null}
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

Since we pass `onComplete` in the code above, the buyer stays on the same page after completing the purchase instead of getting redirected to somewhere else.

The `returnUrl` still has to be a full address, since some payment methods do bounce through a bank and need somewhere to land.

Go to `app/pricing/` and create a file called `page.tsx`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.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">import { Checkout } from &quot;@/components/Checkout&quot;;
import { getEnv } from &quot;@/lib/env&quot;;
import { WHOP_IDS } from &quot;@/constants/whop-ids&quot;;

export default function PricingPage() {
  const env = getEnv();

  return (
    &lt;main className=&quot;mx-auto flex min-h-screen max-w-md flex-col justify-center gap-6 px-6&quot;&gt;
      &lt;div&gt;
        &lt;h1 className=&quot;text-2xl font-semibold&quot;&gt;Pro pass&lt;/h1&gt;
        &lt;p className=&quot;mt-1 text-neutral-600&quot;&gt;One payment, lifetime access.&lt;/p&gt;
      &lt;/div&gt;

      &lt;p className=&quot;text-4xl font-semibold tabular-nums&quot;&gt;
        ${WHOP_IDS.priceUsd.toFixed(2)}
      &lt;/p&gt;

      &lt;Checkout
        environment={env.WHOP_SANDBOX ? &quot;sandbox&quot; : &quot;production&quot;}
        returnUrl={`${env.APP_URL}/pricing`}
      /&gt;
    &lt;/main&gt;
  );
}</code></pre>
  </div>
</div>

> 

Now, run `npm run dev` and open `/pricing`. Clicking the Buy button should load the payment form with the price you set. It's time to build the route that handles the finished payments.

## Confirm the payment on our server

After a payment is complete, the browser tells us a payment finished and hands us a receipt ID, which is the `pay_` ID from earlier. But we always want to confirm it with Whop.

Go to `app/api/verify/` and create a file called `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 { WHOP_IDS } from &quot;@/constants/whop-ids&quot;;

const bodySchema = z.object({
  receiptId: z.string().min(3),
});

export async function POST(request: Request) {
  const parsed = bodySchema.safeParse(await request.json());

  if (!parsed.success) {
    return NextResponse.json({ error: &quot;missing_receipt&quot; }, { status: 400 });
  }

  let payment;

  try {
    payment = await whop().payments.retrieve(parsed.data.receiptId);
  } catch {
    return NextResponse.json({ error: &quot;not_readable_yet&quot; }, { status: 202 });
  }

  if (payment.plan?.id &amp;&amp; payment.plan.id !== WHOP_IDS.planId) {
    return NextResponse.json({ error: &quot;wrong_product&quot; }, { status: 400 });
  }

  if (payment.substatus !== &quot;succeeded&quot;) {
    return NextResponse.json({ error: &quot;not_paid&quot; }, { status: 202 });
  }

  const orderId =
    payment.metadata &amp;&amp; typeof payment.metadata.order_id === &quot;string&quot;
      ? payment.metadata.order_id
      : undefined;

  return NextResponse.json({ orderId: orderId ?? null, paymentId: payment.id });
}</code></pre>
  </div>
</div>

That `payment.metadata.order_id` is the number we attached before the buyer typed anything.

Marking the order paid and handing over the bought product happen in the next section, so this route doesn't change anything. It just answers a single question for the browser: is that payment real?

> 

A 202 means the payment is real but not readable yet, so the component asks again. With the test card it usually settles instantly and this never runs, which is exactly why it is worth leaving in.

Now pay. Open `/pricing`, click Buy, and use `4242 4242 4242 4242` with any future date and any three digits. The page should end up saying the payment is confirmed.

## Get told when the money lands

We verify the payment on our server but it only helps while the buyer is still on the page. In case they close the tab, nothing is going to let us know the payment went through. This is why we need webhooks.

> 

In the sandbox dashboard, open the Developer page and create a webhook under the Webhooks section, pointing at your app's address plus `/api/webhooks/whop`. Then, enable the `payment.succeeded` and `payment.failed` events.

We only act on the first one for now, but subscribing to both means failures already reach the handler when you want to do something with them.

Make sure the API version is set to `v1`, and then copy the signing secret into `WHOP_WEBHOOK_SECRET` in the environment variables.

Go to `lib/` and create a file called `webhook-log.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">webhook-log.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 seen = new Set&lt;string&gt;();

export function recordDelivery(webhookId: string): { firstTime: boolean } {
  if (seen.has(webhookId)) return { firstTime: false };

  seen.add(webhookId);
  return { firstTime: true };
}</code></pre>
  </div>
</div>

Then go to `lib/` and create a file called `fulfil.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">fulfil.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 { markPaid } from &quot;@/lib/orders&quot;;
import { recordDelivery } from &quot;@/lib/webhook-log&quot;;

export interface WhopWebhookEvent {
  type: string;
  id: string;
  data: Record&lt;string, unknown&gt;;
}

export type FulfilResult =
  | { outcome: &quot;fulfilled&quot;; orderId: string }
  | { outcome: &quot;already_done&quot;; orderId?: string }
  | { outcome: &quot;ignored&quot;; reason: string };

function readOrderId(data: Record&lt;string, unknown&gt;): string | undefined {
  const metadata = data.metadata;
  if (!metadata || typeof metadata !== &quot;object&quot;) return undefined;

  const orderId = (metadata as Record&lt;string, unknown&gt;).order_id;
  return typeof orderId === &quot;string&quot; ? orderId : undefined;
}

// Replace this with what &quot;they bought it&quot; means in this app: flip a column,
// insert a row, send the licence key, add them to the group.
async function grantAccess(userId: string): Promise&lt;void&gt; {
  console.log(`granting access to ${userId}`);
}

export async function fulfil(
  event: WhopWebhookEvent,
  deliveryId: string,
): Promise&lt;FulfilResult&gt; {
  if (event.type !== &quot;payment.succeeded&quot;) {
    return { outcome: &quot;ignored&quot;, reason: `nothing to do for ${event.type}` };
  }

  const orderId = readOrderId(event.data);
  if (!orderId) {
    return { outcome: &quot;ignored&quot;, reason: &quot;no order_id in metadata&quot; };
  }

  const { firstTime } = recordDelivery(deliveryId);
  if (!firstTime) {
    return { outcome: &quot;already_done&quot;, orderId };
  }

  const paid = markPaid(orderId, typeof event.data.id === &quot;string&quot; ? event.data.id : &quot;unknown&quot;);
  if (!paid) {
    return { outcome: &quot;ignored&quot;, reason: `no order matching ${orderId}` };
  }

  await grantAccess(paid.userId);

  return { outcome: &quot;fulfilled&quot;, orderId };
}</code></pre>
  </div>
</div>

`grantAccess` is where we give the buyer what they paid for. We intentionally keep it after the duplicate check so that if the same message arrives twice we do not unlock twice.

Whatever it writes is what the rest of the app reads later. When a page needs to know if someone paid, it looks at that, not at the order. The order says a sale happened. The unlock says what the buyer can do.

Finally, go to `app/api/webhooks/whop/` and create a file called `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 { fulfil, type WhopWebhookEvent } from &quot;@/lib/fulfil&quot;;

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: WhopWebhookEvent;

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

  const deliveryId = headers[&quot;webhook-id&quot;] ?? event.id;
  const result = await fulfil(event, deliveryId);

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

## Check it worked

Before moving on, let's check if the checkout works. On localhost, with `npm run dev` running:

- Clicking Buy writes a pending order before the payment form appears.
- The form shows the price set in `whop plans create`.
- `4242 4242 4242 4242` completes it, and the page says the payment is confirmed.
- `/api/verify` answers with our own `orderId`, not just Whop's payment ID.

Once deployed, with the webhook pointed at the real address, see if:

- The webhook endpoint answers 2xx, visible in the dashboard's delivery list.
- The `payment.succeeded` payload carries our `metadata.order_id`.
- Replaying the same delivery from the dashboard answers `already_done` instead of fulfilling again.

## Going live

- Go to Whop.com and create a production API key with the same permissions.
- Remove `WHOP_SANDBOX` or set it to `false`, and clear `WHOP_API_BASE_URL` from any terminal that still has it.
- Sign the CLI in again with a production key. Run the same two commands to make the product and the plan on the live business, then put the new IDs in `constants/whop-ids.ts`.
- Create the webhook again on the live dashboard.
- Move both in-memory stores to a real database. Forgetting the `Set` in `webhook-log.ts` is the easy mistake, and losing it means a repeated message unlocks twice. Save the order and the unlock together, so a crash cannot leave one without the other.
- Replace `grantAccess` with the real unlock, and make sure whatever it writes is what the rest of the app reads.

## Use more of Whop in your platform

Your app now has the Whop checkout integrated. But that's not the only thing Whop can help you with.

After you integrate the checkout, you might want to [add a paywall](https://whop.com/blog/add-paywall/) feature to gate your products, or add [free trials](https://whop.com/blog/add-free-trials/) so members can get a feel for your product before making a payment.

If you don't already have an app or platform of your own, our other [tutorials](https://whop.com/blog/t/tutorials/) like building a [Gumroad](https://whop.com/blog/build-gumroad-clone/) or a [YouTube clone](https://whop.com/blog/youtube-clone/) can help you get started.

If you want to learn more about how Whop can help you, check out the [Whop developer docs](https://docs.whop.com/).

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