You can add express checkout to your app by simply dropping in the Whop express checkout component.
Key takeaways
- Drop in Whop's express checkout button to take real payments without building a checkout page or touching card data.
- The button adapts per visitor, showing Apple Pay, Google Pay, or PayPal automatically, with a card form for everyone else.
- Always verify payments server-side from the receipt ID, and build in the sandbox before going live.
In this tutorial, we're going to walk you through adding an express checkout button to your store pages. You won't have to build a checkout page or touch any card number for the express checkout button to process real money.
We'll use Whop's express checkout button to achieve this. All you have to do is drop the button component into your store, and it will show each visitor the fastest way to pay, like Apple Pay if they're on an iPhone or Google Pay if they're on an Android.
Everyone else gets a Pay with card button that opens Whop's checkout form right on your page. If you'd rather give checkout a page of its own, Whop's embedded checkout is the other option.
In this tutorial, we'll add the button, take a test payment, check whether the payment went through, handle the case where the browser gets sent away and comes back, and finish with a version for sites that don't run React.
You can click through the finished version in our companion demo here and read all of its code in the repository here.
Prerequisites
We're assuming your app runs on Next.js with the App Router. We'll add files under lib/, components/, constants/, app/pricing/, app/checkout/, and app/api/.
The express checkout button sells one thing at a time, and that thing is a Whop plan. A plan is a price attached to a product you've created before (we'll look at creating products soon).
Set up a sandbox account
The first thing you should keep in mind is that we'll be building on Whop sandbox first. This will allow us to see if our payments work without moving real money. Then, we'll move to live Whop.com.
Now, you should:
- Go to Sandbox.Whop.com and create an account
- After creating an account, click the Start a business button on the left navigation bar
- After creating a company, go to its dashboard using the company navigation bar
Get an API key
API keys are secrets that your app uses when it talks to other services, like Whop. While viewing the dashboard of your company, go to the Developer page.
There, you'll find a section called Company API keys. Click the Create button next to it and create an API key with the permissions:
payment:basic:readaccess_pass:basic:readplan:basic:readaccess_pass:createplan:create
After creating the API key, take note of it. We'll use it soon.
Install the packages
Before setting up the environment variables, let's install the packages we'll use: Whop's server SDK, the two Whop Elements packages that ship the button, a library for encrypted cookies, and one for checking that incoming data is the shape we expect.
Run the command below:
npm install @whop/sdk@2.6.0 @whop/elements@1.9.0 @whop/elements-react@1.9.0 iron-session@8 zod@^4
Environment variables
Add these to your .env.local file, and later to your host's environment settings when you deploy.
| Variable | Example | How to get it |
|---|---|---|
WHOP_COMPANY_API_KEY | apik_... | Whop dashboard > Developer > Company API keys. |
WHOP_SANDBOX | true | Set manually. true while you build. Remove it or set it to false in production. |
SESSION_SECRET | ... | Generate with openssl rand -base64 32. 32+ chars. iron-session uses it to encrypt the cookie. |
APP_URL | http://localhost:3000 | Your app origin. The button's returnUrl is built from it. |
Check your env vars on startup
On startup, you should validate the environment variables to avoid silent errors in the future. Go to lib/ and create a file called env.ts with the content:
import { z } from "zod";
const envSchema = z.object({
WHOP_COMPANY_API_KEY: z.string().startsWith("apik_"),
WHOP_SANDBOX: z
.string()
.optional()
.transform((v) => v === "true"),
SESSION_SECRET: z.string().min(32),
APP_URL: z.url(),
});
type Env = z.infer<typeof envSchema>;
let cached: Env | null = null;
export function getEnv(): Env {
if (cached) return cached;
cached = envSchema.parse({
WHOP_COMPANY_API_KEY: process.env.WHOP_COMPANY_API_KEY?.trim(),
WHOP_SANDBOX: process.env.WHOP_SANDBOX?.trim(),
SESSION_SECRET: process.env.SESSION_SECRET,
APP_URL: process.env.APP_URL?.trim(),
});
return cached;
}
Connect to Whop
Then, go to lib/ and create a file called whop.ts:
import { WhopClient, WhopEnvironment } from "@whop/sdk";
import { getEnv } from "@/lib/env";
let cached: WhopClient | null = null;
export function getWhop(): WhopClient {
if (cached) return cached;
const env = getEnv();
cached = new WhopClient({
token: env.WHOP_COMPANY_API_KEY,
environment: env.WHOP_SANDBOX
? WhopEnvironment.Sandbox
: WhopEnvironment.Production,
});
return cached;
}
WHOP_SANDBOX set to true while you build. Without it the client talks to the live site, where your sandbox key fails.How the button decides what to show
As we mentioned, the express checkout button doesn't look the same for everyone. It checks what the visitor's browser supports and shows the quickest option available:
| Browser | Shows | Where they pay |
|---|---|---|
| Safari on Mac or iPhone with Apple Pay set up | Apple Pay button | Right there, no window |
| Chrome or Android with Google Pay | Google Pay button | Right there, no window |
| Any browser, if you accept PayPal | PayPal button | A PayPal window |
| Everything else | No wallet button | Whop's checkout form, after they click Pay with card |
There's one required setting that you shouldn't miss, the returnUrl. Whop sends the buyer there once a payment goes through, and it's also where payment methods with an extra approval page bring them back. We will set that page up in the coming parts.
The same code shows the real wallet buttons once your live domain is verified, which is why the go-live list at the end asks you to verify it and test in both browsers.
Create something to sell
The express checkout button needs a plan ID to work, so let's get one. The fastest way is Whop's command line tool. Install it by running the command:
npm install -g @whop/cli
Now, let's sign in to use the CLI. One thing you'll realize is that there are two ways to sign in to the Whop CLI: with a browser login, and by providing an API key of a business.
We're going to use the API key method since browser logins will automatically target live Whop.com accounts and businesses.
Point it at the sandbox and sign in with the API key you just created (you won't have to do this if you're not working on Whop sandbox).
If you work with AI agents, you can instruct them to do this for you (we'll dive deeper into this in the next section), although it's always better to keep your API keys a secret:
# macOS and Linux
export WHOP_API_BASE_URL="https://sandbox-api.whop.com/api/v1"
# Windows PowerShell
$env:WHOP_API_BASE_URL = "https://sandbox-api.whop.com/api/v1"
whop auth login --method api-key --apiKey apik_your_key_here
To point the CLI back at your live account in the same window, run unset WHOP_API_BASE_URL (on Windows PowerShell: Remove-Item Env:WHOP_API_BASE_URL) and sign in again with a production key, because a sandbox key against the live site just returns "Authentication failed".
It's time to create the product, then the $19.99 one-time price that goes on it. If you're using AI agents, you can instruct them to create a product and give you the plan ID.
If you prefer doing it yourself, the first command prints a product ID starting with prod_, and that id goes into the second command:
whop products create --title "Pro pass" --description "Unlocked with the express checkout button." --visibility visible
whop variants create --product_id prod_XXXXXXXXX --billing_type one_time --initial_price 19.99 --visibility visible
The second command prints a plan id starting with plan_. Both ids go in a file the rest of the app imports, so go to constants/ and create a file called whop-ids.ts with the content:
export const WHOP_IDS = {
productId: "prod_XXXXXXXXX",
planId: "plan_XXXXXXXXX",
} as const;
If you prefer doing things manually on Sandbox.Whop.com, you can go to your sandbox company's dashboard, create a product under the Products section, and copy its ID by using its context menu button.
You need the plan ID too, which sits under the product's Checkout links section.
Let your AI run it
As we mentioned, you can use the Whop CLI with your AI agents. If you want your agents to get comfortable using the Whop CLI and save you some time, you can get the Whop CLI skills by running the command:
whop skills add
This will write a short reference file for every group of Whop CLI commands, so your preferred agent can learn how to use the CLI, even if you switch agents.
From then on, you can ask your agent in plain language to manage your business, like creating plans, getting statistics, or creating checkout links.
The Whop CLI will stick to the account you signed in with, and won't prompt you to select a business again.
The sandbox key and WHOP_API_BASE_URL from above keep it in the sandbox account. Signing in with a production key, or selecting the browser login option with whop login, puts it on your real business instead.
Drop in the button
This component shows the button, hides itself if the button has nothing to show, and tells our server when a payment goes through.
The onComplete function hands us a receipt id that starts with pay_, and that id is the only thing that crosses to the server. What it's actually worth gets decided there.
Go to components/ and create a file called PricingCard.tsx with the content:
"use client";
import { useCallback, useState } from "react";
import { loadWhop } from "@whop/elements";
import {
Checkout,
CheckoutElement,
ExpressCheckoutElement,
WhopElements,
} from "@whop/elements-react";
interface PricingCardProps {
planId: string;
environment: "production" | "sandbox";
returnUrl: string;
}
type Phase =
| { name: "idle" }
| { name: "verifying" }
| { name: "unlocked"; receiptId: string }
| { name: "error"; message: string };
const POLL_MS = 2000;
const MAX_ATTEMPTS = 10;
export function PricingCard({ planId, environment, returnUrl }: PricingCardProps) {
const [phase, setPhase] = useState<Phase>({ name: "idle" });
const [payWithCard, setPayWithCard] = useState(false);
const verify = useCallback(async (receiptId: string) => {
setPhase({ name: "verifying" });
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const res = await fetch("/api/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ receiptId, path: "callback" }),
});
if (res.status === 202 || res.status === 404) {
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
continue;
}
if (res.ok) {
setPhase({ name: "unlocked", receiptId });
return;
}
setPhase({
name: "error",
message: `We couldn't verify the payment. Keep your receipt id (${receiptId}) and contact support.`,
});
return;
}
setPhase({
name: "error",
message: "Verification is taking longer than expected. Refresh and try again.",
});
}, []);
if (phase.name === "unlocked") return <p>Payment confirmed. You're in.</p>;
if (phase.name === "verifying") return <p>Confirming your payment...</p>;
return (
<div>
{phase.name === "error" && <p>{phase.message}</p>}
<WhopElements
elements={loadWhop()}
environment={environment}
appearance={{ theme: { appearance: "light", accentColor: "orange" } }}
>
<Checkout
key={payWithCard ? "card" : "express"}
plan={planId}
returnUrl={returnUrl}
fallback={<div>Loading checkout...</div>}
onComplete={(completion) => {
if (completion.result === "payment") void verify(completion.paymentId);
}}
>
{payWithCard ? <CheckoutElement /> : <ExpressCheckoutElement />}
</Checkout>
</WhopElements>
{!payWithCard && (
<button type="button" onClick={() => setPayWithCard(true)}>
Pay with card
</button>
)}
</div>
);
}
contentSecurityPolicy helper from @whop/elements lists them for the sandbox or production, and its merge method adds them to the policy you already have.The list already covers Apple Pay, Google Pay, PayPal, and bank transfers, so you don't have to track those hosts yourself.
Since we passed onComplete, the buyer will stay on the page instead of getting redirected. Removing it will redirect the user to your return URL instead. We'll take a more detailed look at this later.
Now render the card from a page, which is where the env values get passed in. Go to app/pricing/ and create a file called page.tsx with the content:
import { PricingCard } from "@/components/PricingCard";
import { WHOP_IDS } from "@/constants/whop-ids";
import { getEnv } from "@/lib/env";
export default function PricingPage() {
const env = getEnv();
return (
<main>
<h1>Pro pass</h1>
<p>Everything we ship, one payment, $19.99.</p>
<PricingCard
planId={WHOP_IDS.planId}
environment={env.WHOP_SANDBOX ? "sandbox" : "production"}
returnUrl={`${env.APP_URL}/checkout/complete`}
/>
</main>
);
}
Once that's done, start the dev server and open /pricing:
npm run dev
Don't pay yet, because the card reports to
/api/verify, which doesn't exist until the next section.Check the payment on your server
In this part we're going to work on the system that decides whether you actually get paid. We can't rely on what the browser tells us, so we want the server to ask Whop directly whether the receipt is real and paid.
We'll keep the confirmed payment in an encrypted cookie so the rest of the app can read it, and you'd swap that for whatever your app really does, like setting a flag on a user.
Go to lib/ and create a file called session.ts with the content:
import { getIronSession, type IronSession, type SessionOptions } from "iron-session";
import { cookies } from "next/headers";
import { getEnv } from "@/lib/env";
export interface SessionData {
callbackPaymentId?: string;
redirectPaymentId?: string;
}
export function sessionOptions(): SessionOptions {
return {
password: getEnv().SESSION_SECRET,
cookieName: "whop_session",
cookieOptions: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
},
};
}
export async function getSession(): Promise<IronSession<SessionData>> {
const store = await cookies();
return getIronSession<SessionData>(store, sessionOptions());
}
One route covers both ways a payment can reach us: the button's callback, and the return page we'll build in the next step. The receipt has to look like a payment ID, exist at Whop, be attached to our product, and be paid without a refund.
Go to app/api/verify/ and create a file called route.ts with the content:
import { z } from "zod";
import { Whop } from "@whop/sdk";
import { WHOP_IDS } from "@/constants/whop-ids";
import { getSession } from "@/lib/session";
import { getWhop } from "@/lib/whop";
const bodySchema = z.object({
receiptId: z.string().regex(/^pay_[A-Za-z0-9]{4,60}$/),
path: z.enum(["callback", "redirect"]),
});
export async function POST(request: Request) {
const body: unknown = await request.json().catch(() => null);
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return Response.json({ error: "invalid_receipt" }, { status: 400 });
}
let payment;
try {
payment = await getWhop().payments.retrieve({ id: parsed.data.receiptId });
} catch (error: unknown) {
if (error instanceof Whop.NotFoundError) {
return Response.json({ error: "not_found" }, { status: 404 });
}
throw error;
}
if (payment.product_id !== WHOP_IDS.productId) {
return Response.json({ error: "wrong_product" }, { status: 403 });
}
if (payment.status === "pending" || payment.status === "open") {
return Response.json({ status: "pending" }, { status: 202 });
}
if (payment.status !== "paid" || payment.substatus?.includes("refund")) {
return Response.json({ error: "not_paid" }, { status: 403 });
}
const session = await getSession();
if (parsed.data.path === "callback") {
session.callbackPaymentId = payment.id;
} else {
session.redirectPaymentId = payment.id;
}
await session.save();
return Response.json({
ok: true,
receipt: {
id: payment.id,
status: payment.status,
substatus: payment.substatus,
total: payment.total,
currency: payment.currency,
paidAt: payment.paid_at,
cardBrand: payment.payment_instrument?.card?.brand ?? null,
cardLast4: payment.payment_instrument?.card?.last4 ?? null,
},
});
}
Now let's see if everything works. On /pricing, click the express checkout button and pay with the Whop sandbox test card (4242 4242 4242 4242, any future expiry, any CVC).
Handle the return trip
When a payment goes through, Whop sends the buyer to the returnUrl with the result in the address bar.
If we don't pass onComplete, the payment sends the user to the returnUrl with the result in the address bar. Even with onComplete in place, a bank approval step can send the user to that same address.
The URL receives three things: status is either success or error. payment_id is the receipt to check, and state_id identifies the checkout.
The return page reads the address bar, checks the payment with the same call we used before, and shows confirmation only for payments that pass. Go to app/checkout/complete/ and create a file called page.tsx with the content:
import { Whop } from "@whop/sdk";
import { WHOP_IDS } from "@/constants/whop-ids";
import { getWhop } from "@/lib/whop";
function first(value: string | string[] | undefined): string | undefined {
if (typeof value === "string") return value;
return Array.isArray(value) ? value[0] : undefined;
}
export default async function CheckoutCompletePage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const status = first(params.status);
const paymentId = first(params.payment);
if (!paymentId || status === "failed" || status === "canceled") {
return (
<main>
<h1>Payment not completed</h1>
<p>The payment failed or was canceled, and nothing was charged.</p>
</main>
);
}
let payment = null;
try {
payment = await getWhop().payments.retrieve({ id: paymentId });
} catch (error: unknown) {
if (!(error instanceof Whop.NotFoundError)) throw error;
}
if (!payment || payment.status === "pending" || payment.status === "open") {
return (
<main>
<h1>Almost there</h1>
<p>Your payment is still settling. Refresh this page in a few seconds.</p>
</main>
);
}
if (
payment.product_id !== WHOP_IDS.productId ||
payment.status !== "paid" ||
payment.substatus?.includes("refund")
) {
return (
<main>
<h1>We couldn't confirm this payment</h1>
<p>Keep your receipt id ({paymentId}) and contact support.</p>
</main>
);
}
return (
<main>
<h1>Payment confirmed</h1>
<p>${payment.total?.amount} {payment.currency?.toUpperCase() ?? "USD"} paid. Receipt {payment.id}.</p>
</main>
);
}
This page can check a payment and display the result, but it can't set cookies because Next.js doesn't allow that while a page is rendering.
If your return needs to log someone in or save their progress, you should post the payment value to /api/verify with path: "redirect" from the browser instead.
Now let's see if everything works. On /pricing, click Pay with card and pay with the Whop sandbox test card (4242 4242 4242 4242, any future expiry, any CVC, and any name and address).
The card starts confirming your payment, and then Whop sends you to /checkout/complete?payment=pay_...&status=succeeded, where the page checks it and confirms.
If your site isn't React
The express checkout button also works without React, so you can sell from Framer, Webflow, WordPress, or any hand-written HTML page. You only need to load Whop's script, mount the button into an empty element, and listen for the payment.
The onComplete function below posts to /api/verify, the route we built earlier, so it only works when this page and your app sit on the same domain.
If your page lives somewhere else, like Framer or Webflow, leave the fetch out and let the payment.succeeded webhook confirm the payment instead.
<script src="https://cdn.whop.com/elements/amber/elements.js" data-whop-elements></script>
<div id="buy"></div>
<button id="pay-with-card" type="button">Pay with card</button>
<script type="module">
const whop = window.WhopElements({
environment: "sandbox",
appearance: { theme: { appearance: "light", accentColor: "orange" } },
});
const checkout = whop.checkout.create({
plan: "plan_XXXXXXXXX",
returnUrl: "https://yoursite.com/checkout/complete",
onComplete: (completion) => {
if (completion.result !== "payment") return;
fetch("/api/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ receiptId: completion.paymentId, path: "callback" }),
});
},
});
let element = checkout.create("expressCheckout");
element.mount("#buy");
document.getElementById("pay-with-card").addEventListener("click", (event) => {
element.destroy();
element = checkout.create("checkout");
element.mount("#buy");
event.currentTarget.remove();
});
</script>
skip-redirect="true" is what keeps the buyer on your page, the way onComplete does in React. Take it out if you'd rather the browser go to your return URL.
Take environment: "sandbox" out when you switch to a live plan id, because there's no WHOP_SANDBOX on a plain HTML page.
Going live
Everything we built so far runs on the sandbox. To go live, you should direct your process from Sandbox.Whop.com to Whop.com. Here's a small checklist to help:
- Make the product and plan again for real on whop.com, because sandbox ids don't carry over. Same two CLI commands, except you sign in with a production API key and leave
WHOP_API_BASE_URLunset. Put the new ids inconstants/whop-ids.ts. - Create a production API key with the same permissions and set it as
WHOP_COMPANY_API_KEY. - Remove
WHOP_SANDBOXor set it tofalse. The SDK address and the button'senvironmentboth come from it, so they switch together. - Generate a fresh
SESSION_SECRETand pointAPP_URLat your real domain, which movesreturnUrlto your real return page. - If you send a CSP, call
contentSecurityPolicywithout the sandbox environment, so it lists the production hosts. - Turn on Apple Pay for your domain, which clears both Apple Pay and Google Pay. Learn more at our Enable Apple Pay and Google Pay page.
- Test on Safari with an Apple Pay card and on Chrome with Google Pay. This is the first time you'll see the real wallet buttons.
- Register the
payment.succeededwebhook on your Whop dashboard, so a buyer who closes the tab, or who Whop sends to its own account page, still gets what they paid for. The checkout article sets one up.
Use Whop to improve your business
You now have the quickest possible way for people to make payments on your app, but that's not the only thing Whop can help you improve your business with.
With Whop, you can add embedded checkouts to your existing apps, put your premium content behind a paywall, or integrate embedded chats for your community to engage with each other.
All of this can be done with the Whop API, and you can use the Whop CLI with your agents to get everything done agentically. If you want to learn more about what Whop offers, check out our other tutorials and the Whop developer documentation.