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.
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:
- Go to the Products section of your whop and click Create product at the top right
- In the product editor, give your product a name and a headline
- 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:
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:
WHOP_COMPANY_API_KEY="apik_..." WHOP_SANDBOX="true" npx tsx scripts/setup-whop.ts
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_succeededpayment_failedmembership_activatedmembership_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:
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:
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:
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:
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:
datasource db {
provider = "postgresql"
}
The URL moves to a new file at the project root called 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:
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:
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:
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:
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:
<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:
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:
"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:
<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 "next/link";
import { requireUser } from "@/lib/auth";
import { getWhop } from "@/lib/whop";
import { PRO_FEATURES } from "@/lib/plans";
import { AccountCard } from "@/components/account-card";
interface SearchParams {
status?: string;
receipt_id?: string;
}
interface ReceiptData {
plan: string;
amount: number;
type: "subscription" | "lifetime" | "unknown";
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<ReceiptData | null> {
try {
const whop = getWhop();
const payment = await whop.payments.retrieve(receiptId);
const planType =
(payment as { plan?: { plan_type?: string } })?.plan?.plan_type ??
"unknown";
const type: ReceiptData["type"] =
planType === "renewal"
? "subscription"
: planType === "one_time"
? "lifetime"
: "unknown";
return {
plan:
type === "subscription"
? "Pro Monthly"
: type === "lifetime"
? "Pro Lifetime"
: "Pro",
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("[complete] failed to load payment:", err);
return null;
}
}
export default async function CompletePage({
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
const user = await requireUser();
const { status, receipt_id } = await searchParams;
if (status === "error") {
return (
<div>
<h1>Payment didn&rsquo;t go through</h1>
<Link href="/">Back to plans</Link>
</div>
);
}
const receipt = receipt_id ? await loadReceipt(receipt_id) : null;
const waitingForWebhook = Boolean(receipt_id) && user.plan !== "pro";
return (
<div>
<h1>You&rsquo;re on Pro</h1>
{receipt && (
<section>
<p>Plan: {receipt.plan}</p>
<p>Amount: ${receipt.amount.toFixed(2)}</p>
<p>Receipt: {receipt.receiptId}</p>
</section>
)}
<AccountCard
plan={user.plan}
planType={user.planType}
updatedAt={user.planSince}
waitingForWebhook={waitingForWebhook}
/>
<ul>
{PRO_FEATURES.map((f) => (
<li key={f}>{f}</li>
))}
</ul>
</div>
);
}</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:
"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 → Webhooks → 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:
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:
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:
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:
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.accentColoraccepts a curated palette (pink,jade,tomato,crimson,blue, etc.). Full list in the Whop docs. - Prefill -
prefill={{ email: "..." }}orprefill={{ 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 withprefillto build one-click flows for logged-in users. - Programmatic controls - Pass a
reffromuseCheckoutEmbedControlsto getsubmit(),getEmail(),setEmail(),setAddress(). Handy for wrapping the Whop pay button with a custom form. - Callbacks -
onComplete(planId, receiptId),onStateChange(state),onPromoCodeChanged(promoCode). - Attribution -
affiliateCode="..."andutm={{ utm_campaign: "..." }}. UTM keys must start withutm_.
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:
- 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
- Create a production webhook - Using the production URL of your project, create a new webhook to get a live secret
- 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_KEYandWHOP_WEBHOOK_SECRET - Direct the API to the live environment - Go to your environment variables and set
WHOP_SANDBOXtofalse
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 |
| Setup | None | 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.