You can add embedded or externally hosted checkouts to a Next.js app using the Whop infrastructure. Learn how to in this guide.

Key takeaways

  • Whop's API lets Next.js developers add payments via either an embedded checkout or a hosted redirect, both firing identical webhooks.
  • Developers should build in Whop's sandbox first, scripting product and plan creation with the SDK to populate environment variables cleanly.
  • A webhook endpoint paired with a WebhookEvent table flips the user's plan flag idempotently, enabling reliable feature gating after purchase.

You can add a checkout and connect a payment system to your Next.js app by using the Whop infrastructure. It might sound complex, but using the Whop API makes it easy for us.

In this guide, we're going to walk you through building a working checkout, a webhook that tells our app who paid, and a flag on the user row we can gate the features with.

By the end of this tutorial, your users will be able to click on an "Upgrade" button in your app, see an embedded Whop checkout, and get the benefits of their purchase.

You can take a look at our checkout demo to see a basic step by step walkthrough of how the checkout works.

If you're starting a SaaS from scratch, you can run npx create-whop-kit my-app to scaffold a full Next.js SaaS with auth, a pricing page, billing portal, subscription tiers, and a setup wizard pre-wired.

It's the CLI in front of the whopio/whop-saas-starter template. In this guide, we're going to focus on project that already exists.

Choosing a checkout type

Whop supports two checkout methods for Next.js projects: an embedded checkout that you can integrate into your app, and Whop-hosted checkout pages that redirect users back to your app once the checkout is complete.

Both accept the same metadata, fire the same webhook, and end in the same user.plan = "pro" flip.

Embedded Hosted
Where it renders Inside your own project whop.com/checkout/...
User leaves your domain No Yes
Setup Server route + iframe component One redirect or anchor tag
Metadata support Yes Yes, via checkoutConfigurations.create
Brand look Theme + accent color Whop-branded
Best for Primary upgrade flow Email links, experiments, static sites

In this guide, we're going to focus on using embedded Whop checkouts.

Prerequisites

Before diving deep into coding, let's break down the prerequisites of adding a checkout to your Next.js app.

Adding a checkout to your project

First, let's get the sandbox secrets. We're going to use the sandbox environment of Whop for the development phase. This allows us to simulate payments without moving real money.

We'll look at how you can switch from sandbox to the live Whop environment in the last section.

First, go to sandbox.whop.com, and create a whop. Once done, visit the Developer page of your whop (on the bottom left) and create a company API key. You can do this by:

  • Finding the "API keys" section at the top of the Developer page and clicking on the **Create** button of the Company API keys
  • Give your API key a name and select Admin from the inherit permissions from role dropdown, then click Create
  • Once created, copy the company API key (starts with apik_) and note it down, we'll populate our environment variables later

Create the product and plans

To let your users complete payments, you need products on Whop, and there are two easy ways to create them:

  • Manually creating them in the Whop dashboard
  • Using the Whop API to create them Let's break down both.

First option: create them in the dashboard

You can create a product (for one-time payments) and a plan (for recurring payments) in the dashboard by following these steps:

  1. Go to the Products section of your whop and click Create product at the top right
  2. In the product editor, give your product a name and a headline
  3. Select Paid access in the pricing section, leave the payment type as recurring (selected by default), give it a price, and select the payment internal (1 month by default)

This plan will be the recurring payment in your app. Now, let's create the one-time payment by following the exact steps as below, but only selecting one-time as the payment type.

Once you're done, go back to the Products page of your whop, click on the context menu buttons of the product and plan you've created, hover over the Details part, and copy their IDs (starts with prod_)

Second option: use the Whop API

To script the setup of the product and plan, go to scrips/ in your project, and create a file called setup-whop.ts with the code below:

setup-whop.ts
import { config } from "dotenv";
import Whop from "@whop/sdk";

config({ path: ".env.local" });
config();

const apiKey = process.env.WHOP_COMPANY_API_KEY;
const sandbox = process.env.WHOP_SANDBOX === "true";
const explicitCompanyId = process.env.WHOP_COMPANY_ID;

if (!apiKey) {
  console.error("Set WHOP_COMPANY_API_KEY in .env.local before running this script.");
  process.exit(1);
}

const whop = new Whop({
  apiKey,
  ...(sandbox && { baseURL: "https://sandbox-api.whop.com/api/v1" }),
});

async function resolveCompanyId(): Promise<string> {
  if (explicitCompanyId) return explicitCompanyId;
  try {
    const iterator = await whop.companies.list();
    for await (const company of iterator) {
      return (company as { id: string }).id;
    }
  } catch (err) {
    const error = err as { error?: { error?: { message?: string } } };
    const msg = error?.error?.error?.message ?? "";
    if (msg.includes("company:basic:read")) {
      throw new Error(
        "Your Company API Key cannot list companies (missing company:basic:read scope). " +
          "Set WHOP_COMPANY_ID=biz_... (find it in the Whop dashboard URL) and run this again.",
      );
    }
    throw err;
  }
  throw new Error(
    "No company found. Create one in the Whop dashboard before running setup.",
  );
}

async function main() {
  const companyId = await resolveCompanyId();
  console.log(`Using company: ${companyId}\n`);

  const product = await whop.products.create({
    company_id: companyId,
    title: "Pro",
    visibility: "hidden",
  });
  const productId = (product as { id: string }).id;
  console.log(`Created product: ${productId}`);

  const subscription = await whop.plans.create({
    company_id: companyId,
    product_id: productId,
    plan_type: "renewal",
    initial_price: 0,
    renewal_price: 29,
    billing_period: 30,
    currency: "usd",
    visibility: "hidden",
    release_method: "buy_now",
  });
  const subscriptionId = (subscription as { id: string }).id;

  const lifetime = await whop.plans.create({
    company_id: companyId,
    product_id: productId,
    plan_type: "one_time",
    initial_price: 199,
    currency: "usd",
    visibility: "hidden",
    release_method: "buy_now",
  });
  const lifetimeId = (lifetime as { id: string }).id;

  console.log("\nAdd these to your .env.local:");
  console.log(`NEXT_PUBLIC_WHOP_SUBSCRIPTION_PLAN_ID=${subscriptionId}`);
  console.log(`NEXT_PUBLIC_WHOP_LIFETIME_PLAN_ID=${lifetimeId}`);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

Then, use the command below to run the script:

Terminal
WHOP_COMPANY_API_KEY="apik_..." WHOP_SANDBOX="true" npx tsx scripts/setup-whop.ts
If the script errors with company:basic:read, grab the company ID from the dashboard URL (starts with biz_) and re-run the command with WHOP_COMPANY_ID="biz_..." prepended.

Create the webhook

Now, let's create the webhook that will listen to actions from Whop so your app can know when a user completes a payment. Go to the Developer page of your whop again, and under the Webhooks section, click Create webhook, give it a name, set the endpoint URL to https://your-domain.com/api/webhooks/whop, and enable the events below before clicking Save:

  • payment_succeeded
  • payment_failed
  • membership_activated
  • membership_deactivated
    Once created, copy your webhook secret, we'll use it later.

Installing dependencies

To be able to add the checkout to your project, you're going to have to install four packages, including the Whop server SDK:

Terminal
npm install @whop/sdk @whop/checkout @vercel/functions zod

Environment variables

Now, let's add all the secrets you've got so far into the environment variables of your project. We're going to use Vercel in this guide. Go to the Environment Variables page in the project settings on Vercel. There, add the environment variables:

Variable Example How to get it
WHOP_COMPANY_API_KEY apik_... Whop dashboard → Business Settings → API Keys → create.
WHOP_WEBHOOK_SECRET ... Shown when we created the webhook in the previous step.
WHOP_SANDBOX true Set manually. true during development; remove or set to false in production.
NEXT_PUBLIC_WHOP_SUBSCRIPTION_PLAN_ID plan_... The subscription plan ID (Option A: dashboard; Option B: printed by the setup script).
NEXT_PUBLIC_WHOP_LIFETIME_PLAN_ID plan_... The one-time plan ID (same source as above).
NEXT_PUBLIC_APP_URL http://localhost:3000 Our app's origin. http://localhost:3000 locally; the Vercel production URL once deployed.
DATABASE_URL postgresql://... Pooled connection string from our Postgres provider (Neon, Supabase, etc.). Vercel's Postgres integration auto-sets this.
DATABASE_URL_UNPOOLED postgresql://... Direct/unpooled connection string from the same provider, used by the Prisma CLI.

Validate environment variables at startup

In the lib/ folder of your project, create a file called env.ts with the content:

env.ts
import { z } from "zod";

const envSchema = z.object({
  WHOP_COMPANY_API_KEY: z.string().min(1),
  WHOP_WEBHOOK_SECRET: z.string().min(1),
  WHOP_SANDBOX: z
    .string()
    .optional()
    .transform((v) => v === "true"),
  NEXT_PUBLIC_WHOP_SUBSCRIPTION_PLAN_ID: z.string().startsWith("plan_"),
  NEXT_PUBLIC_WHOP_LIFETIME_PLAN_ID: z.string().startsWith("plan_"),
  NEXT_PUBLIC_APP_URL: z.string().url(),
});

type Env = z.infer<typeof envSchema>;

let cached: Env | null = null;

function parseAll(): Env {
  if (cached) return cached;
  cached = envSchema.parse({
    WHOP_COMPANY_API_KEY: process.env.WHOP_COMPANY_API_KEY,
    WHOP_WEBHOOK_SECRET: process.env.WHOP_WEBHOOK_SECRET,
    WHOP_SANDBOX: process.env.WHOP_SANDBOX,
    NEXT_PUBLIC_WHOP_SUBSCRIPTION_PLAN_ID:
      process.env.NEXT_PUBLIC_WHOP_SUBSCRIPTION_PLAN_ID,
    NEXT_PUBLIC_WHOP_LIFETIME_PLAN_ID:
      process.env.NEXT_PUBLIC_WHOP_LIFETIME_PLAN_ID,
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
  });
  return cached;
}

export const env = new Proxy({} as Env, {
  get(_target, prop: string) {
    return parseAll()[prop as keyof Env];
  },
});

SDK client

We're going to use a single shared Whop SDK client. Keep in mind that setting the WHOP_SANDBOX environment variable to true points it at the sandbox environment of Whop.

Go to lib/ and create a file called whop.ts with the content:

whop.ts
import Whop from "@whop/sdk";
import { env } from "@/lib/env";

let _whop: Whop | null = null;

export function getWhop(): Whop {
  if (!_whop) {
    _whop = new Whop({
      apiKey: env.WHOP_COMPANY_API_KEY,
      webhookKey: Buffer.from(env.WHOP_WEBHOOK_SECRET).toString("base64"),
      ...(env.WHOP_SANDBOX && {
        baseURL: "https://sandbox-api.whop.com/api/v1",
      }),
    });
  }
  return _whop;
}

Database additions

For the database, you're going to need to add a plan column to your users table and a small webhook_events table for idempotency. If you're using Prisma you can do this as:

schema.prisma
model User {
  id        String    @id @default(cuid())
  email     String    @unique
  plan      String    @default("free")
  planType  String?
  planSince DateTime?
  createdAt DateTime  @default(now())

  @@index([email])
}

model WebhookEvent {
  id         String   @id
  type       String
  receivedAt DateTime @default(now())
}

plan and planType are what our webhook will flip. WebhookEvent is how we guarantee each webhook is processed at most once (more on that in the webhook section).

Prisma config

Prisma 7 changed where the database URL lives. It no longer belongs in schema.prisma, so our datasource block shrinks to just the provider:

prisma
datasource db {
  provider = "postgresql"
}

The URL moves to a new file at the project root called prisma.config.ts:

prisma.config.ts
import { config } from "dotenv";
import type { PrismaConfig } from "prisma";

config({ path: ".env.local" });
config();

export default {
  schema: "./prisma/schema.prisma",
  migrations: {
    path: "./prisma/migrations",
  },
  datasource: {
    url: process.env.DATABASE_URL_UNPOOLED ?? process.env.DATABASE_URL ?? "",
  },
} satisfies PrismaConfig;

Prisma client singleton

There's a high chance your project already has a lib/db.ts or lib/prisma.ts file. If not, here's the standard Prisma singleton the rest of the article imports from. Go to lib/ and create a file called db.ts with the content:

db.ts
import { PrismaPg } from "@prisma/adapter-pg";
import { Pool } from "pg";
import { PrismaClient } from "@/generated/prisma/client";

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
  pool: Pool | undefined;
};

const pool =
  globalForPrisma.pool ??
  new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({ adapter: new PrismaPg(pool) });

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
  globalForPrisma.pool = pool;
}

User helper

Multiple pages we're working on like the checkout route, completion page, and the access helper call requireUser() and check fields like user.plan. So, we shouldn't return just the user ID and email.

If our existing auth only hands back a session (a user ID plus email), we wrap it with a DB lookup. Go to lib/ and create a file called auth.ts with the content:

auth.ts
import { redirect } from "next/navigation";
import { prisma } from "@/lib/db";
// Replace this import with whatever our existing auth exposes.
// It should return at least { userId: string } or null.
import { getSession } from "@/lib/session";

export async function getCurrentUser() {
  const session = await getSession();
  if (!session?.userId) return null;
  return prisma.user.findUnique({ where: { id: session.userId } });
}

export async function requireUser() {
  const user = await getCurrentUser();
  if (!user) redirect("/");
  return user;
}

Create a checkout session

When a user clicks the upgrade button, we create a Whop checkout session, tag it with the user's ID, and redirect to a page that render the embed.
We attach the user's ID to the session.

Later when the webhook fires, Whop gives us the ID back. This is how we know who paid.

Plan definitions

We're going to have a single source of truth for both plans. The pricing UI and the checkout route will both read from it. To build it, go to lib/ and create a file called plans.ts:

plans.ts
export type PlanKey = "subscription" | "lifetime";

export interface PlanDefinition {
  planKey: PlanKey;
  name: string;
  price: number;
  priceSuffix: string;
  description: string;
  features: readonly string[];
  envVar:
    | "NEXT_PUBLIC_WHOP_SUBSCRIPTION_PLAN_ID"
    | "NEXT_PUBLIC_WHOP_LIFETIME_PLAN_ID";
}

export const PRO_FEATURES = [
  "Unlimited projects",
  "Advanced analytics",
  "Priority support",
  "Team access (up to 5 seats)",
] as const;

export const PLANS = {
  subscription: {
    planKey: "subscription",
    name: "Pro Monthly",
    price: 29,
    priceSuffix: "/mo",
    description: "Month-to-month billing. Cancel anytime.",
    features: PRO_FEATURES,
    envVar: "NEXT_PUBLIC_WHOP_SUBSCRIPTION_PLAN_ID",
  },
  lifetime: {
    planKey: "lifetime",
    name: "Pro Lifetime",
    price: 199,
    priceSuffix: "one-time",
    description: "Pay once. Keep Pro forever.",
    features: PRO_FEATURES,
    envVar: "NEXT_PUBLIC_WHOP_LIFETIME_PLAN_ID",
  },
} as const satisfies Record<PlanKey, PlanDefinition>;

export function planIdFor(key: PlanKey): string {
  const plan = PLANS[key];
  const value = process.env[plan.envVar];
  if (!value) {
    throw new Error(`Missing env var: ${plan.envVar}`);
  }
  return value;
}

The checkout route

We're going to use the same route to handle both plans. It validates the plan field, asks Whop for a session using the user ID, and redirects /checkout with session ID to the URL. Go to app/api/checkout/ and create a file called route.ts:

route.ts
import { NextResponse, type NextRequest } from "next/server";
import { requireUser } from "@/lib/auth";
import { getWhop } from "@/lib/whop";
import { PLANS, planIdFor, type PlanKey } from "@/lib/plans";
import { env } from "@/lib/env";

function isPlanKey(value: FormDataEntryValue | null): value is PlanKey {
  return value === "subscription" || value === "lifetime";
}

export async function POST(request: NextRequest): Promise<NextResponse> {
  const user = await requireUser();
  const form = await request.formData();
  const plan = form.get("plan");

  if (!isPlanKey(plan)) {
    return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
  }

  const planId = planIdFor(plan);

  const whop = getWhop();
  const config = await whop.checkoutConfigurations.create({
    plan_id: planId,
    mode: "payment",
    redirect_url: `${env.NEXT_PUBLIC_APP_URL}/checkout/complete`,
    metadata: { userId: user.id },
  });

  const sessionId = (config as { id: string }).id;

  const url = new URL("/checkout", env.NEXT_PUBLIC_APP_URL);
  url.searchParams.set("session", sessionId);
  url.searchParams.set("plan", PLANS[plan].planKey);

  return NextResponse.redirect(url, { status: 303 });
}

Triggering the checkout

The pricing UI POSTs to /api/checkout with the plan key. The simplest version is a plain form with a hidden plan input:

HTML
<form action="/api/checkout" method="POST">
  <input type="hidden" name="plan" value="subscription" />
  <button type="submit">Upgrade to Pro Monthly</button>
</form>

Render the embedded checkout

Now, we're going to build two files: a server-rendered page that reads the query parameters and a client component that mounts the embed iframe.

The page shell

The page re-checks the authentication, validates the query parameters, and redirects to home if either is missing, and renders a plan alongside the embed. Go to app/checkout/ and create a file called page.tsx:

page.tsx
import { redirect } from "next/navigation";
import { PlanSummaryCard } from "@/components/plan-summary-card";
import { WhopCheckout } from "./WhopCheckout";
import { PLANS, type PlanKey } from "@/lib/plans";
import { requireUser } from "@/lib/auth";
import { env } from "@/lib/env";

interface SearchParams {
  session?: string;
  plan?: string;
}

function isPlanKey(value: string | undefined): value is PlanKey {
  return value === "subscription" || value === "lifetime";
}

export default async function CheckoutPage({
  searchParams,
}: {
  searchParams: Promise<SearchParams>;
}) {
  await requireUser();
  const { session, plan } = await searchParams;

  if (!session || !isPlanKey(plan)) redirect("/");

  const planDef = PLANS[plan];

  return (
    <div>
      <PlanSummaryCard
        name={planDef.name}
        price={planDef.price}
        priceSuffix={planDef.priceSuffix}
        features={[...planDef.features]}
      />
      <WhopCheckout
        sessionId={session}
        returnUrl={`${env.NEXT_PUBLIC_APP_URL}/checkout/complete`}
        sandbox={env.WHOP_SANDBOX}
      />
    </div>
  );
}

The client component

The client component mounts the Whop embed and passes through the session ID, return URL, and the sandbox flag we use to indicate that we're working with the sandbox environment for now.

We'll switch this to live environment later. Go to app/checkout/ and create a file called WhopCheckout.tsx:

WhopCheckout.tsx
"use client";

import { WhopCheckoutEmbed } from "@whop/checkout/react";

interface WhopCheckoutProps {
  sessionId: string;
  returnUrl: string;
  sandbox: boolean;
}

export function WhopCheckout({
  sessionId,
  returnUrl,
  sandbox,
}: WhopCheckoutProps) {
  return (
    <WhopCheckoutEmbed
      sessionId={sessionId}
      returnUrl={returnUrl}
      environment={sandbox ? "sandbox" : "production"}
      theme="light"
      themeOptions={{ accentColor: "pink" }}
      fallback={<CheckoutSkeleton />}
    />
  );
}

function CheckoutSkeleton() {
  return <div className="h-[560px] w-full animate-pulse rounded bg-neutral-100" />;
}

The user fills in the card details inside the iframe, so our app doesn't touch that data at all. The returnUrl is where Whop redirects the user. The sandbox flag mirrors env.WHOP_SANDBOX, so this file doesn't change when we ship to production.

To learn more about the fields of embedded checkouts and how you can customize checkouts, check out our embedded checkout documentation.

Handle the return URL

After the user pays, Whop redirects the browser to our returnUrl, which carries either a success or an error status, and the receipt ID. We can use the receipt ID to look up the payment later.

The complete page

The page reads ?status and ?receipt_id from the URL, fetches the payment from Whop's API for display, and renders the account card that will do the polling.

Go to app/checkout/complete/ and create a file called page.tsx:

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-typescript">import Link from &quot;next/link&quot;;
import { requireUser } from &quot;@/lib/auth&quot;;
import { getWhop } from &quot;@/lib/whop&quot;;
import { PRO_FEATURES } from &quot;@/lib/plans&quot;;
import { AccountCard } from &quot;@/components/account-card&quot;;

interface SearchParams {
  status?: string;
  receipt_id?: string;
}

interface ReceiptData {
  plan: string;
  amount: number;
  type: &quot;subscription&quot; | &quot;lifetime&quot; | &quot;unknown&quot;;
  date: Date;
  receiptId: string;
}

// Note: `payments.retrieve()` returns a richer payload than the webhook
// and DOES include `plan.plan_type`. The webhook handler (lib/webhooks.ts)
// derives plan type from the plan id instead because the webhook payload
// does not include plan_type. Different endpoints, different shapes.
async function loadReceipt(receiptId: string): Promise&lt;ReceiptData | null&gt; {
  try {
    const whop = getWhop();
    const payment = await whop.payments.retrieve(receiptId);
    const planType =
      (payment as { plan?: { plan_type?: string } })?.plan?.plan_type ??
      &quot;unknown&quot;;
    const type: ReceiptData[&quot;type&quot;] =
      planType === &quot;renewal&quot;
        ? &quot;subscription&quot;
        : planType === &quot;one_time&quot;
          ? &quot;lifetime&quot;
          : &quot;unknown&quot;;
    return {
      plan:
        type === &quot;subscription&quot;
          ? &quot;Pro Monthly&quot;
          : type === &quot;lifetime&quot;
            ? &quot;Pro Lifetime&quot;
            : &quot;Pro&quot;,
      amount: Number((payment as { subtotal?: number }).subtotal ?? 0),
      type,
      date: new Date(
        (payment as { created_at?: string | number }).created_at ?? Date.now(),
      ),
      receiptId,
    };
  } catch (err) {
    console.error(&quot;[complete] failed to load payment:&quot;, err);
    return null;
  }
}

export default async function CompletePage({
  searchParams,
}: {
  searchParams: Promise&lt;SearchParams&gt;;
}) {
  const user = await requireUser();
  const { status, receipt_id } = await searchParams;

  if (status === &quot;error&quot;) {
    return (
      &lt;div&gt;
        &lt;h1&gt;Payment didn&amp;rsquo;t go through&lt;/h1&gt;
        &lt;Link href=&quot;/&quot;&gt;Back to plans&lt;/Link&gt;
      &lt;/div&gt;
    );
  }

  const receipt = receipt_id ? await loadReceipt(receipt_id) : null;
  const waitingForWebhook = Boolean(receipt_id) &amp;&amp; user.plan !== &quot;pro&quot;;

  return (
    &lt;div&gt;
      &lt;h1&gt;You&amp;rsquo;re on Pro&lt;/h1&gt;
      {receipt &amp;&amp; (
        &lt;section&gt;
          &lt;p&gt;Plan: {receipt.plan}&lt;/p&gt;
          &lt;p&gt;Amount: ${receipt.amount.toFixed(2)}&lt;/p&gt;
          &lt;p&gt;Receipt: {receipt.receiptId}&lt;/p&gt;
        &lt;/section&gt;
      )}
      &lt;AccountCard
        plan={user.plan}
        planType={user.planType}
        updatedAt={user.planSince}
        waitingForWebhook={waitingForWebhook}
      /&gt;
      &lt;ul&gt;
        {PRO_FEATURES.map((f) =&gt; (
          &lt;li key={f}&gt;{f}&lt;/li&gt;
        ))}
      &lt;/ul&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

The polling card

The card displays the user’s plan. If the webhook has updated the database, the user sees “Pro” (or the name of their plan) immediately. If the database has not yet been updated, the user sees a “waiting” message and the card checks the database every 2 seconds.

Go to components/ and create a file called account-card.tsx:

account-card.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";

interface AccountCardProps {
  plan: string;
  planType: string | null;
  updatedAt: Date | null;
  waitingForWebhook: boolean;
}

const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 30000;

export function AccountCard({
  plan,
  planType,
  updatedAt,
  waitingForWebhook,
}: AccountCardProps) {
  const router = useRouter();
  const isPro = plan === "pro";
  const planLabel = isPro
    ? planType === "lifetime"
      ? "Pro (Lifetime)"
      : "Pro"
    : "Free";

  const [timedOut, setTimedOut] = useState(false);
  const startedAt = useRef<number | null>(null);

  useEffect(() => {
    if (!waitingForWebhook) return;
    if (startedAt.current === null) startedAt.current = Date.now();

    const poll = window.setInterval(() => {
      if (
        startedAt.current !== null &&
        Date.now() - startedAt.current > POLL_TIMEOUT_MS
      ) {
        setTimedOut(true);
        window.clearInterval(poll);
        return;
      }
      router.refresh();
    }, POLL_INTERVAL_MS);

    return () => window.clearInterval(poll);
  }, [waitingForWebhook, router]);

  return (
    <section aria-labelledby="account-heading">
      <h2 id="account-heading">Your account</h2>
      <p>Plan: {planLabel}</p>
      <p>
        Updated:{" "}
        {updatedAt
          ? updatedAt.toLocaleString(undefined, {
              dateStyle: "medium",
              timeStyle: "short",
            })
          : "\u2014"}
      </p>
      {waitingForWebhook && !timedOut && (
        <p role="status" aria-live="polite">
          Activating your Pro access — waiting for the webhook from Whop.
          This usually takes a second or two.
        </p>
      )}
      {waitingForWebhook && timedOut && (
        <div role="alert" aria-live="polite">
          <p>
            The webhook is taking longer than expected. Check your Whop
            dashboard &rarr; Webhooks &rarr; delivery log for a 200 response
            on a recent event.
          </p>
          <button
            type="button"
            onClick={() => {
              startedAt.current = Date.now();
              setTimedOut(false);
              router.refresh();
            }}
          >
            Check again
          </button>
        </div>
      )}
    </section>
  );
}

Handle the webhook

Whop notifies our app via webhooks when a user action is done. Like when a payment succeeds, a renewal fails, etc.

When one of these webhooks hits our endpoint, we verify it's from Whop, read the data, and find out which user of ours it's about. Then, we update their user.plan accordingly.

Event handlers

We need two handlers, one for new payments, and one for cancellation. Go to lib/ and create a file called webhooks.ts:

webhooks.ts
import { z } from "zod";
import { prisma } from "@/lib/db";

// Whop's payment webhook payload only guarantees a small set of fields —
// `data.plan.plan_type` is NOT returned on webhooks, only when you
// retrieve the plan separately. We parse defensively and derive the
// plan type from the plan id instead.
const paymentSchema = z.object({
  id: z.string(),
  metadata: z.record(z.string(), z.unknown()).nullish(),
  plan: z.object({ id: z.string() }).optional(),
  billing_reason: z.string().nullish(),
});

const membershipSchema = z.object({
  id: z.string(),
  metadata: z.record(z.string(), z.unknown()).nullish(),
  plan: z.object({ id: z.string() }).optional(),
});

function readUserId(metadata: unknown): string | null {
  if (!metadata || typeof metadata !== "object") return null;
  const value = (metadata as Record<string, unknown>).userId;
  return typeof value === "string" ? value : null;
}

function planTypeFromPlanId(
  planId: string | undefined,
): "subscription" | "lifetime" | null {
  if (!planId) return null;
  if (planId === process.env.NEXT_PUBLIC_WHOP_SUBSCRIPTION_PLAN_ID) {
    return "subscription";
  }
  if (planId === process.env.NEXT_PUBLIC_WHOP_LIFETIME_PLAN_ID) {
    return "lifetime";
  }
  return null;
}

async function alreadyProcessed(eventId: string): Promise<boolean> {
  const existing = await prisma.webhookEvent.findUnique({
    where: { id: eventId },
  });
  return existing !== null;
}

async function markProcessed(eventId: string, type: string): Promise<void> {
  try {
    await prisma.webhookEvent.create({ data: { id: eventId, type } });
  } catch {
  }
}

export async function handlePaymentSucceeded(eventId: string, data: unknown) {
  if (await alreadyProcessed(eventId)) return;

  let payment: z.infer<typeof paymentSchema>;
  try {
    payment = paymentSchema.parse(data);
  } catch (err) {
    console.error("[webhook] payment.succeeded parse failed:", err, "payload:", data);
    return;
  }

  const userId = readUserId(payment.metadata);
  if (!userId) {
    console.error(
      "[webhook] payment.succeeded with no userId metadata",
      payment.id,
    );
    return;
  }

  const planType = planTypeFromPlanId(payment.plan?.id);
  if (!planType) {
    console.error(
      "[webhook] payment.succeeded with unrecognized plan id:",
      payment.plan?.id,
    );
    return;
  }

  try {
    await prisma.user.update({
      where: { id: userId },
      data: { plan: "pro", planType, planSince: new Date() },
    });
  } catch (err) {
    console.error("[webhook] payment.succeeded DB update failed:", err);
    return;
  }

  await markProcessed(eventId, "payment.succeeded");
}

export async function handleMembershipDeactivated(eventId: string, data: unknown) {
  if (await alreadyProcessed(eventId)) return;

  let membership: z.infer<typeof membershipSchema>;
  try {
    membership = membershipSchema.parse(data);
  } catch (err) {
    console.error("[webhook] membership.deactivated parse failed:", err);
    return;
  }

  const userId = readUserId(membership.metadata);
  if (!userId) return;

  try {
    await prisma.user.update({
      where: { id: userId },
      data: { plan: "free", planType: null, planSince: null },
    });
  } catch (err) {
    console.error("[webhook] membership.deactivated DB update failed:", err);
    return;
  }

  await markProcessed(eventId, "membership.deactivated");
}

The route handler

The route receives Whop's POSTs and verifies the signature. If it sees a mistmatch, it replies with a 401. On a valid event, replies with 200. Go to app/api/webhooks/whop/ and create a file called route.ts:

route.ts
import { waitUntil } from "@vercel/functions";
import { NextResponse, type NextRequest } from "next/server";
import { getWhop } from "@/lib/whop";
import {
  handlePaymentSucceeded,
  handleMembershipDeactivated,
} from "@/lib/webhooks";

interface WhopEvent {
  type: string;
  id: string;
  data: Record<string, unknown>;
}

export async function POST(request: NextRequest): Promise<NextResponse> {
  const bodyText = await request.text();
  const headers = Object.fromEntries(request.headers);

  let event: WhopEvent;
  try {
    event = getWhop().webhooks.unwrap(bodyText, {
      headers,
    }) as unknown as WhopEvent;
  } catch (err) {
    console.error("[webhook] signature verification failed:", err);
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  switch (event.type) {
    case "payment.succeeded":
    case "membership.activated":
      waitUntil(handlePaymentSucceeded(event.id, event.data));
      break;
    case "membership.deactivated":
      waitUntil(handleMembershipDeactivated(event.id, event.data));
      break;
    case "payment.failed":
      console.error("[webhook] payment.failed:", event.id);
      break;
    default:
      break;
  }

  return NextResponse.json({ ok: true });
}

Gating paid features

With the webhook keeping user.plan current, gating a page is a three-line helper. Go to lib/ and create a file called access.ts:

access.ts
import { redirect } from "next/navigation";
import { requireUser } from "@/lib/auth";

export async function requirePro() {
  const user = await requireUser();
  if (user.plan !== "pro") redirect("/?upgrade=1");
  return user;
}

Use it in any server component that renders Pro-only content:

Component
import { requirePro } from "@/lib/access";

export default async function ProDashboard() {
  const user = await requirePro();
  return <div>Welcome back, {user.email}</div>;
}

Customization references

The embed accepts more props than we used:

  • Theming - theme="light" | "dark" | "system". themeOptions.accentColor accepts a curated palette (pink, jade, tomato, crimson, blue, etc.). Full list in the Whop docs.
  • Prefill - prefill={{ email: "..." }} or prefill={{ address: { name, country, line1, city, state, postalCode } }}. Useful when the email was already collected in an earlier form step.
  • Hide fields - hideEmail, disableEmail, hideAddressForm, hideTermsAndConditions, hidePrice. Combine with prefill to build one-click flows for logged-in users.
  • Programmatic controls - Pass a ref from useCheckoutEmbedControls to get submit(), getEmail(), setEmail(), setAddress(). Handy for wrapping the Whop pay button with a custom form.
  • Callbacks - onComplete(planId, receiptId), onStateChange(state), onPromoCodeChanged(promoCode).
  • Attribution - affiliateCode="..." and utm={{ utm_campaign: "..." }}. UTM keys must start with utm_.
For more information about customizing embedded checkout components, check out our embedded checkout documentation.

Switching from sandbox to production

As we mentioned, we've been using the sandbox environment of Whop (sandbox.whop.com) throughout the guide. It allows us to simulate payments without moving real money.

Now that the checkout integration is complete and you're ready to move real money, you should transform your project to the live environment (whop.com) let's see what you should do:

  1. Recreate the plans and products in the production environment - Go to the live environment at whop.com, create a whop if you don't already have one, and follow the steps we documented below to recreate your secret keys, products, and plans
  2. Create a production webhook - Using the production URL of your project, create a new webhook to get a live secret
  3. Update your environment variables - Using the new secret keys you got on the live environment, you should go back to your host and update your environment variables like WHOP_COMPANY_KEY and WHOP_WEBHOOK_SECRET
  4. Direct the API to the live environment - Go to your environment variables and set WHOP_SANDBOX to false

Getting paid

When a user completes a payment, the funds get transferred to your company's Whop balance. There are two easy ways to move that money to a bank account: using a Whop hosted dashboard to complete the payouts, or adding an embedded payout component to your project.

Hosted Embedded
Where the owner manages it Whop dashboard Inside our own admin UI
SetupNone Install @whop/embedded-components-react-js
KYC + bank linking Handled by Whop Handled by Whop (rendered in our UI)
Best for Single-owner project Teams keeping everything in their own app

Building a marketplace?

If you're building a marketplace-like project where other people can sign up as creators and get paid, you should use the Whop Payments Network, which offers hosted and embedded payout portals so each account can manage its own KYC and withdrawals.

If you want to learn more, check out the Whop Payments Network webpage and our payments documentation.

Step up your projects with Whop

You now know how to add a checkout to your Next.js project. But that's not the only thing Whop can help you step up your project with. If you have other creators that sign up to your platform and get paid (like a Gumroad or a Substack clone), you can use the Whop Payments Network to create a marketplace where creators publish content, users purchase, and you get a cut.

You can also use the Whop infrastructure to add live chats to your apps, easily handle user authentication with Whop OAuth, integrate private support chats, and much more. If you want to learn more about how Whop can help you, check out our developer documentation.