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.
✨ Build this with AI

Open the tutorial prompt in your favorite AI coding tool:

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:

  1. Go to Sandbox.Whop.com and create an account
  2. After creating an account, click the Start a business button on the left navigation bar
  3. 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:read
  • access_pass:basic:read
  • plan:basic:read
  • access_pass:create
  • plan: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:

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

VariableExampleHow to get it
WHOP_COMPANY_API_KEYapik_...Whop dashboard > Developer > Company API keys.
WHOP_SANDBOXtrueSet 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_URLhttp://localhost:3000Your 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:

env.ts
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:

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

Wallet buttons only show on an https domain you've verified with Whop, so on localhost the wallet row stays empty and you'll pay through the Pay with card button while you build.

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:

Terminal
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:

Terminal
# 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
This setting only lives in the terminal window you typed it in, so closing that window undoes it.

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:

Terminal
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:

whop-ids.ts
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:

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

The same CLI that creates products can delete them, and it can move money as well. Get comfortable on the sandbox before you point an AI at your live account.

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:

PricingCard.tsx
"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>
  );
}
If your app sends a Content-Security-Policy header, the button won't load until you add the hosts Whop Elements needs. The 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:

page.tsx
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:

Terminal
npm run dev
On localhost you'll only see the Pay with card button, and clicking it opens Whop's checkout form in its place.

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:

session.ts
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:

route.ts
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,
    },
  });
}
Anyone can send this route any string they like, so it's the server's check that makes a receipt count, not the fact that it arrived.

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:

page.tsx
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.

HTML
<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_URL unset. Put the new ids in constants/whop-ids.ts.
  • Create a production API key with the same permissions and set it as WHOP_COMPANY_API_KEY.
  • Remove WHOP_SANDBOX or set it to false. The SDK address and the button's environment both come from it, so they switch together.
  • Generate a fresh SESSION_SECRET and point APP_URL at your real domain, which moves returnUrl to your real return page.
  • If you send a CSP, call contentSecurityPolicy without 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.succeeded webhook 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.