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

0:00
/1:35

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 using the Whop infrastructure by adding a few lines into your web page.

In this guide, we're going to walk you through building a working checkout using a single React component. Then, we'll do a deep dive on building a webhook that tells our app who paid, and a flag on the user row we can gate the features with for SaaS-like apps that need to know which user paid.

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.

Quick start

Adding an embedded checkout to a Next.js app can be done with a single component. Whop handles the checkout form, payments service, receipts, and order management.

All payments you receive can be managed in the Whop dashboard at Whop.com

Step 1: Creating a plan

The embedded Whop checkout is a customizable component that requires a plan ID. To get it, you need to have a whop and create a plan in it. Follow the steps below to crate one:

  1. Go to your whop dashboard (create a whop if you don't have one already)
  2. Click on the Checkout links page and the Create checkout link at the top right
  3. Customize your checkout link in the editor and hit Create checkout link
  4. Back in the Checkout links page of your dashboard, click on the context menu button of your checkout link, hover over the Details option, and click on the plan ID to copy it
For the development phase, we're going to use the Whop sandbox (via sandbox.whop.com) to simulate payments without moving real money. Once your checkout is fully integrated, you should use the live Whop environment (at Whop.com) to create your checkout link and get its plan ID.

Step 2: Install the embed package

To make your code render the embedded checkout, install the checkout package using the command below:

Terminal
npm install @whop/checkout

Step 3: Add the embed into a page

Now, go to the checkout page of your project (the page.tsx file) and add the checkout component:

page.tsx
import { WhopCheckoutEmbed } from "@whop/checkout/react";
export default function Page() {
  return (
    <WhopCheckoutEmbed
      planId="plan_XXXXXXXXX"
      returnUrl="https://yoursite.com/checkout/complete"
    />
  );
}

As you can see in the snippet above, the planId and returnUrl parameters are placeholders. You should replace the plan_XXXXXXXXX and https://yoursite.com/checkout/complete arguments with your actual plan ID you copied in the first step and your website's checkout completion URL.

You can test the checkout you just embedded by using a test card with the card number 4242 4242 4242 4242 (any CVC and future expiry). Once complete, you should be able to see the payment landing on your whop dashboard.

This simple approach is enough for ecommerce, paid-access products, donations, and similar projects. If your project needs to know which of your users paid to unlock a feature or a membership, you should follow the steps below.

Setting up the integration

The quick setup we looked at above is enough for a working checkout. But if you want to know which user paid so you can change database flags, gate features, or log the purchases in your own system, you need an API key, a webhook, some environment variables, a database client, and a small authentication helper.

First, let's get the sandbox secret. 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 later in the guide.

Getting the API key

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. You'll add it to your environment variables later
The code samples below use Prisma to update a user record and track webhook IDs. If you're on Drizzle, raw SQL, MongoDB, or any other stack, swap the queries (prisma.user.update(...), prisma.webhookEvent.findUnique(...), etc.) for the equivalent in your ORM.

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 interval (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 scripts/ 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, you'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 (First option: dashboard - Second option: 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.

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;
}

User helper

In the rest of this article, we'll use a requireUser() helper that returns the current user. You should use whatever your existing authentication library exposes, and swap the requireUser() calls in the snippets below for its equivalent.

Create a checkout session

When a user clicks the upgrade button, you create a Whop checkout session, tag it with the user's ID, and redirect to a page that render the embed. Later when the webhook fires, Whop gives us the same ID back. This is how you know which one of your users have 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 });
}
While we use fixed-price plans in this guide, you can pass an in-line plan: { initial_price, plan_type, currency } to checkoutConfigurations.create instead of a plan_id if you charge variable amounts.

To learn more about programmatically charging customers, see the checkout configuration documentation page for more details.

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.

For more information about customizing embedded checkout components, check out our embedded checkout documentation.

Handle the return URL

After the user pays, Whop redirects the browser to your returnUrl with a ?status=success or ?status=error in the URL. The webhook handles updating your database in the background. The page just needs to render a success or failure message.

The complete page

The page reads ?status from the URL and renders either the success message or an error link.

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

page.tsx
import Link from "next/link";
import { requireUser } from "@/lib/auth";
import { PRO_FEATURES } from "@/lib/plans";

interface SearchParams {
  status?: string;
}

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

  if (status === "error") {
    return (
      <div>
        <h1>Payment didn&rsquo;t go through</h1>
        <Link href="/">Back to plans</Link>
      </div>
    );
  }

  return (
    <div>
      <h1>Thanks, payment received</h1>
      <p>Your access will be active shortly. Check your email for a receipt.</p>
      <ul>
        {PRO_FEATURES.map((f) => (
          <li key={f}>{f}</li>
        ))}
      </ul>
    </div>
  );
}

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 mismatch, 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>;
}

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.

To go live, 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_API_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 with connected accounts, 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 for platforms webpage and our payments documentation.

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

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 Whop 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.