---
title: How to add express checkout to your app
slug: express-checkout
excerpt: You can add express checkout to your app by simply dropping in the Whop express checkout component.
customExcerpt: You can add express checkout to your app by simply dropping in the Whop express checkout component.
featureImage: "https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/07/blog-express-checkout.png"
status: published
publishedAt: "2026-07-28T17:54:14.000Z"
updatedAt: "2026-07-28T17:54:14.000Z"
createdAt: "2026-07-28T17:54:15.669Z"
tags:
  - { name: Tutorials, slug: tutorials }
  - { name: Developers, slug: developers }
authors:
  - { name: East, slug: east }
  - { name: Destinee Walston, slug: destinee }
---

# How to add express checkout to your app

<div class="ai-prompt-widget">
  <div class="ai-prompt-widget__header">
    <span class="ai-prompt-widget__icon">✨</span>
    <span class="ai-prompt-widget__title">Build this with AI</span>
  </div>
  <p class="ai-prompt-widget__description">Open the tutorial prompt in your favorite AI coding tool:</p>
  <div class="ai-prompt-widget__buttons" id="ai-prompt-buttons"></div>
</div>

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](https://docs.whop.com/payments/one-click-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.

Otherwise, they will see a pre-built Whop checkout popup. If you'd rather give checkout a page of its own, Whop's [embedded checkout](https://whop.com/blog/add-checkout-to-nextjs-app) 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](https://nextjs-whop-express-checkout-demo.vercel.app) and read all of its code in the [repository here](https://github.com/whopio/whop-tutorials/tree/main/express-checkout).

## 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](https://sandbox.whop.com/dashboard) 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: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 package that ships the button, a library for encrypted cookies, and one for checking that incoming data is the shape we expect.

Run the command below:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</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-bash">npm install @whop/sdk @whop/checkout iron-session zod</code></pre>
  </div>
</div>

### Environment variables

Add these to your `.env.local` file, and later to your host's environment settings when you deploy.

<table>
<tbody><tr><th>Variable</th><th>Example</th><th>How to get it</th></tr>
<tr><td><code>WHOP_COMPANY_API_KEY</code></td><td><code>apik_...</code></td><td>Whop dashboard &gt; Developer &gt; Company API keys.</td></tr>
<tr><td><code>WHOP_SANDBOX</code></td><td><code>true</code></td><td>Set manually. <code>true</code> while you build. Remove it or set it to <code>false</code> in production.</td></tr>
<tr><td><code>SESSION_SECRET</code></td><td><code>...</code></td><td>Generate with <code>openssl rand -base64 32</code>. 32+ chars. iron-session uses it to encrypt the cookie.</td></tr>
<tr><td><code>APP_URL</code></td><td><code>http://localhost:3000</code></td><td>Your app origin. The button's <code>returnUrl</code> is built from it.</td></tr>
</tbody></table>

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

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">env.ts</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 { z } from &quot;zod&quot;;

const envSchema = z.object({
  WHOP_COMPANY_API_KEY: z.string().startsWith(&quot;apik_&quot;),
  WHOP_SANDBOX: z
    .string()
    .optional()
    .transform((v) =&gt; v === &quot;true&quot;),
  SESSION_SECRET: z.string().min(32),
  APP_URL: z.string().url(),
});

type Env = z.infer&lt;typeof envSchema&gt;;

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;
}</code></pre>
  </div>
</div>

## Connect to Whop

Then, go to `lib/` and create a file called `whop.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">whop.ts</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 Whop from &quot;@whop/sdk&quot;;
import { getEnv } from &quot;@/lib/env&quot;;

let cached: Whop | null = null;

export function getWhop(): Whop {
  if (cached) return cached;
  const env = getEnv();
  cached = new Whop({
    apiKey: env.WHOP_COMPANY_API_KEY,
    baseURL: env.WHOP_SANDBOX
      ? &quot;https://sandbox-api.whop.com/api/v1&quot;
      : &quot;https://api.whop.com/api/v1&quot;,
  });
  return cached;
}</code></pre>
  </div>
</div>

> 

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

<table>
<thead>
<tr>
<th>Browser</th>
<th>Shows</th>
<th>Where they pay</th>
</tr>
</thead>
<tbody>
<tr>
<td>Safari on Mac or iPhone with Apple Pay set up</td>
<td>Apple Pay button</td>
<td>Right there, no window</td>
</tr>
<tr>
<td>Chrome or Android with Google Pay</td>
<td>Google Pay button</td>
<td>Right there, no window</td>
</tr>
<tr>
<td>Everything else</td>
<td>Whop Pay button</td>
<td>A checkout window over your page</td>
</tr>
</tbody>
</table>

There's one required setting that you shouldn't miss, the `returnUrl`. Some payment methods might redirect the buyer to another approval page, and those pages require a URL to return the user to. We will set that page up in the coming parts.

> 

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

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</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-bash">npm install -g @whop/cli</code></pre>
  </div>
</div>

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:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</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-bash"># macOS and Linux
export WHOP_API_BASE_URL=&quot;https://sandbox-api.whop.com/api/v1&quot;

# Windows PowerShell
$env:WHOP_API_BASE_URL = &quot;https://sandbox-api.whop.com/api/v1&quot;

whop auth login --method api-key --apiKey apik_your_key_here</code></pre>
  </div>
</div>

> 

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:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</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-bash">whop products create --title &quot;Pro pass&quot; --description &quot;Unlocked with the express checkout button.&quot; --visibility visible

whop plans create --product_id prod_XXXXXXXXX --plan_type one_time --initial_price 19.99 --visibility visible</code></pre>
  </div>
</div>

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:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">whop-ids.ts</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">export const WHOP_IDS = {
  productId: &quot;prod_XXXXXXXXX&quot;,
  planId: &quot;plan_XXXXXXXXX&quot;,
} as const;</code></pre>
  </div>
</div>

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:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</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-bash">whop skills add</code></pre>
  </div>
</div>

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:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">PricingCard.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-tsx">&quot;use client&quot;;

import { useCallback, useState } from &quot;react&quot;;
import { WhopExpressCheckoutButton } from &quot;@whop/checkout/react&quot;;

interface PricingCardProps {
  planId: string;
  environment: &quot;production&quot; | &quot;sandbox&quot;;
  returnUrl: string;
}

type Phase =
  | { name: &quot;idle&quot; }
  | { name: &quot;verifying&quot; }
  | { name: &quot;unlocked&quot;; receiptId: string }
  | { name: &quot;error&quot;; message: string };

const POLL_MS = 2000;
const MAX_ATTEMPTS = 10;

export function PricingCard({ planId, environment, returnUrl }: PricingCardProps) {
  const [phase, setPhase] = useState&lt;Phase&gt;({ name: &quot;idle&quot; });
  const [hidden, setHidden] = useState(false);

  const verify = useCallback(async (receiptId: string) =&gt; {
    setPhase({ name: &quot;verifying&quot; });
    for (let attempt = 0; attempt &lt; MAX_ATTEMPTS; attempt++) {
      const res = await fetch(&quot;/api/verify&quot;, {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify({ receiptId, path: &quot;callback&quot; }),
      });
      if (res.status === 202 || res.status === 404) {
        await new Promise((resolve) =&gt; setTimeout(resolve, POLL_MS));
        continue;
      }
      if (res.ok) {
        setPhase({ name: &quot;unlocked&quot;, receiptId });
        return;
      }
      setPhase({
        name: &quot;error&quot;,
        message: `We couldn&#039;t verify the payment. Keep your receipt id (${receiptId}) and contact support.`,
      });
      return;
    }
    setPhase({
      name: &quot;error&quot;,
      message: &quot;Verification is taking longer than expected. Refresh and try again.&quot;,
    });
  }, []);

  if (hidden) return null;
  if (phase.name === &quot;unlocked&quot;) return &lt;p&gt;Payment confirmed. You&#039;re in.&lt;/p&gt;;
  if (phase.name === &quot;verifying&quot;) return &lt;p&gt;Confirming your payment...&lt;/p&gt;;

  return (
    &lt;div&gt;
      {phase.name === &quot;error&quot; &amp;&amp; &lt;p&gt;{phase.message}&lt;/p&gt;}
      &lt;WhopExpressCheckoutButton
        planId={planId}
        returnUrl={returnUrl}
        environment={environment}
        theme=&quot;light&quot;
        themeOptions={{ accentColor: &quot;orange&quot; }}
        fallback={&lt;div&gt;Resolving payment method...&lt;/div&gt;}
        onComplete={(_planId, receiptId) =&gt; {
          if (receiptId) void verify(receiptId);
        }}
        onPaymentError={(error) =&gt; setPhase({ name: &quot;error&quot;, message: error.message })}
        onExpressMethodResolved={({ rendered }) =&gt; {
          if (rendered === &quot;none&quot;) setHidden(true);
        }}
      /&gt;
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

> 

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:

<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-tsx">import { PricingCard } from &quot;@/components/PricingCard&quot;;
import { WHOP_IDS } from &quot;@/constants/whop-ids&quot;;
import { getEnv } from &quot;@/lib/env&quot;;

export default function PricingPage() {
  const env = getEnv();
  return (
    &lt;main&gt;
      &lt;h1&gt;Pro pass&lt;/h1&gt;
      &lt;p&gt;Everything we ship, one payment, $19.99.&lt;/p&gt;
      &lt;PricingCard
        planId={WHOP_IDS.planId}
        environment={env.WHOP_SANDBOX ? &quot;sandbox&quot; : &quot;production&quot;}
        returnUrl={`${env.APP_URL}/checkout/complete`}
      /&gt;
    &lt;/main&gt;
  );
}</code></pre>
  </div>
</div>

Once that's done, start the dev server and open `/pricing`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</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-bash">npm run dev</code></pre>
  </div>
</div>

> 

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

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">session.ts</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 { getIronSession, type IronSession, type SessionOptions } from &quot;iron-session&quot;;
import { cookies } from &quot;next/headers&quot;;
import { getEnv } from &quot;@/lib/env&quot;;

export interface SessionData {
  callbackPaymentId?: string;
  redirectPaymentId?: string;
  redirectStateId?: string;
}

export function sessionOptions(): SessionOptions {
  return {
    password: getEnv().SESSION_SECRET,
    cookieName: &quot;whop_session&quot;,
    cookieOptions: {
      httpOnly: true,
      secure: process.env.NODE_ENV === &quot;production&quot;,
      sameSite: &quot;lax&quot;,
      path: &quot;/&quot;,
    },
  };
}

export async function getSession(): Promise&lt;IronSession&lt;SessionData&gt;&gt; {
  const store = await cookies();
  return getIronSession&lt;SessionData&gt;(store, sessionOptions());
}</code></pre>
  </div>
</div>

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.

Go to `app/api/verify/` and create a file called `route.ts` with the content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</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 { z } from &quot;zod&quot;;
import { NotFoundError } from &quot;@whop/sdk&quot;;
import { WHOP_IDS } from &quot;@/constants/whop-ids&quot;;
import { getSession } from &quot;@/lib/session&quot;;
import { getWhop } from &quot;@/lib/whop&quot;;

const bodySchema = z.object({
  receiptId: z.string().regex(/^pay_[A-Za-z0-9]{4,60}$/),
  path: z.enum([&quot;callback&quot;, &quot;redirect&quot;]),
  stateId: z.string().max(80).optional(),
});

export async function POST(request: Request) {
  const body: unknown = await request.json().catch(() =&gt; null);
  const parsed = bodySchema.safeParse(body);
  if (!parsed.success) {
    return Response.json({ error: &quot;invalid_receipt&quot; }, { status: 400 });
  }

  let payment;
  try {
    payment = await getWhop().payments.retrieve(parsed.data.receiptId);
  } catch (error: unknown) {
    if (error instanceof NotFoundError) {
      return Response.json({ error: &quot;not_found&quot; }, { status: 404 });
    }
    throw error;
  }

  if (payment.product?.id !== WHOP_IDS.productId) {
    return Response.json({ error: &quot;wrong_product&quot; }, { status: 403 });
  }
  if (payment.status === &quot;pending&quot; || payment.status === &quot;open&quot;) {
    return Response.json({ status: &quot;pending&quot; }, { status: 202 });
  }
  if (payment.status !== &quot;paid&quot;) {
    return Response.json({ error: &quot;not_paid&quot; }, { status: 403 });
  }

  const session = await getSession();
  if (parsed.data.path === &quot;callback&quot;) {
    session.callbackPaymentId = payment.id;
  } else {
    session.redirectPaymentId = payment.id;
    session.redirectStateId = parsed.data.stateId;
  }
  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_method?.card?.brand ?? null,
      cardLast4: payment.payment_method?.card?.last4 ?? null,
    },
  });
}</code></pre>
  </div>
</div>

> 

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

The window closes, the page says it's confirming your payment, and then it confirms. You never left `/pricing`.

## Handle the return trip

There's another way a payment can end. 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.

If the checkout didn't process any charges, like if the user saved a card for later, you'll get `setup_intent_id` in place of `payment_id`.

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:

<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-tsx">import { NotFoundError } from &quot;@whop/sdk&quot;;
import { WHOP_IDS } from &quot;@/constants/whop-ids&quot;;
import { getWhop } from &quot;@/lib/whop&quot;;

function first(value: string | string[] | undefined): string | undefined {
  if (typeof value === &quot;string&quot;) return value;
  return Array.isArray(value) ? value[0] : undefined;
}

export default async function CheckoutCompletePage({
  searchParams,
}: {
  searchParams: Promise&lt;Record&lt;string, string | string[] | undefined&gt;&gt;;
}) {
  const params = await searchParams;
  const status = first(params.status);
  const paymentId = first(params.payment_id);
  const setupIntentId = first(params.setup_intent_id);

  if (status !== &quot;success&quot;) {
    return (
      &lt;main&gt;
        &lt;h1&gt;Payment not completed&lt;/h1&gt;
        &lt;p&gt;The payment failed or was canceled, and nothing was charged.&lt;/p&gt;
      &lt;/main&gt;
    );
  }

  if (!paymentId) {
    return (
      &lt;main&gt;
        &lt;h1&gt;All set&lt;/h1&gt;
        &lt;p&gt;Checkout finished without a charge{setupIntentId ? &quot;, and your payment method was saved&quot; : &quot;&quot;}.&lt;/p&gt;
      &lt;/main&gt;
    );
  }

  let payment = null;
  try {
    payment = await getWhop().payments.retrieve(paymentId);
  } catch (error: unknown) {
    if (!(error instanceof NotFoundError)) throw error;
  }

  if (!payment || payment.status === &quot;pending&quot; || payment.status === &quot;open&quot;) {
    return (
      &lt;main&gt;
        &lt;h1&gt;Almost there&lt;/h1&gt;
        &lt;p&gt;Your payment is still settling. Refresh this page in a few seconds.&lt;/p&gt;
      &lt;/main&gt;
    );
  }

  if (payment.product?.id !== WHOP_IDS.productId || payment.status !== &quot;paid&quot;) {
    return (
      &lt;main&gt;
        &lt;h1&gt;We couldn&#039;t confirm this payment&lt;/h1&gt;
        &lt;p&gt;Keep your receipt id ({paymentId}) and contact support.&lt;/p&gt;
      &lt;/main&gt;
    );
  }

  return (
    &lt;main&gt;
      &lt;h1&gt;Payment confirmed&lt;/h1&gt;
      &lt;p&gt;${payment.total} {payment.currency?.toUpperCase() ?? &quot;USD&quot;} paid. Receipt {payment.id}.&lt;/p&gt;
    &lt;/main&gt;
  );
}</code></pre>
  </div>
</div>

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_id` to `/api/verify` with `path: "redirect"` from the browser instead.

To test this, take `onComplete` out of the pricing card and buy again. The browser should leave your page and come back to `/checkout/complete?status=success&payment_id=pay_...&state_id=...`, where the page checks it and confirms. Put `onComplete` back when you're done, unless the redirect is the flow you want.

## If your site isn't React

The express checkout button works as a plain HTML tag so you can sell from Framer, Webflow, WordPress, or any hand-written HTML page. You only need to load the script, drop the tag in, and listen for the information you need.

The `complete` listener 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.

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">HTML</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-html">&lt;script async defer src=&quot;https://js.whop.com/static/checkout/loader.js&quot;&gt;&lt;/script&gt;

&lt;whop-express-checkout-button
  id=&quot;buy-button&quot;
  plan-id=&quot;plan_XXXXXXXXX&quot;
  return-url=&quot;https://yoursite.com/checkout/complete&quot;
  environment=&quot;sandbox&quot;
  skip-redirect=&quot;true&quot;
  theme=&quot;light&quot;
  theme-accent-color=&quot;orange&quot;
&gt;&lt;/whop-express-checkout-button&gt;

&lt;script&gt;
  const button = document.getElementById(&quot;buy-button&quot;);

  button.addEventListener(&quot;express-method-resolved&quot;, (event) =&gt; {
    if (event.detail.rendered === &quot;none&quot;) button.style.display = &quot;none&quot;;
  });

  button.addEventListener(&quot;complete&quot;, (event) =&gt; {
    fetch(&quot;/api/verify&quot;, {
      method: &quot;POST&quot;,
      headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
      body: JSON.stringify({ receiptId: event.detail.receiptOrSetupIntentId, path: &quot;callback&quot; }),
    });
  });

  button.addEventListener(&quot;payment-error&quot;, (event) =&gt; {
    console.log(&quot;payment failed&quot;, event.detail.message, event.detail.code);
  });
&lt;/script&gt;</code></pre>
  </div>
</div>

`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](https://whop.com/dashboard), 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.
- Keep `https://js.whop.com` and `https://t.whop.tw` in your CSP.
- 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`](https://docs.whop.com/api-reference/payments/payment-succeeded) webhook on your Whop dashboard, so a buyer who closes the tab before your page confirms still gets what they paid for. The [checkout article](https://whop.com/blog/add-checkout-to-nextjs-app) 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](https://whop.com/blog/add-checkout-to-nextjs-app/) to your existing apps, put your premium content [behind a paywall](https://whop.com/blog/add-paywall/), or integrate [embedded chats](https://whop.com/blog/add-embedded-chat/) 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](https://whop.com/blog/run-business-with-cli/) to get everything done agentically. If you want to learn more about what Whop offers, check out our other [tutorials](https://whop.com/blog/t/tutorials/) and the Whop developer documentation.

**[Go to Whop developer docs](https://docs.whop.com/)**
