---
title: How to add a paywall to your app or website
slug: add-paywall
excerpt: You can add a paywall to your app or website using Next.js and Whop. Learn how to paywall content behind one-time payments or recurring subscription with Whop.
customExcerpt: You can add a paywall to your app or website using Next.js and Whop. Learn how to paywall content behind one-time payments or recurring subscription with Whop.
featureImage: "https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/06/HowToAddPaywall.webp"
status: published
publishedAt: "2026-06-22T19:28:01.000Z"
updatedAt: "2026-06-22T23:01:00.000Z"
createdAt: "2026-06-17T06:00:58.643Z"
tags:
  - { name: Tutorials, slug: tutorials }
  - { name: Developers, slug: developers }
authors:
  - { name: East, slug: east }
  - { name: Destinee Walston, slug: destinee }
---

# How to add a paywall to your app or website

## Key takeaways

- You can gate app content behind one-time or recurring purchases using Next.js and Whop's infrastructure.
- This paywall stays database-free and webhook-free by checking entitlements live on Whop in real time.
- The receipt ID is the only data crossing from browser to server, keeping the unlock flow secure.

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

You can add a paywall to your app with Next.js and Whop to gate content behind one-time or recurring purchases. The Whop infrastructure handles this with a checkout that collects payments, entitlement records you can query, and user IDs kept in cookies.

In this tutorial, we're going to walk you through locking content behind embedded checkouts, which will unlock and render the content in the page after the checkout, a subscription that unlocks all paywalled content, and one-time purchases that target individual posts.

You can check out the entire flow in our [companion demo here](https://nextjs-whop-paywall-demo.vercel.app/) and its [repository here](https://github.com/whopio/whop-tutorials/tree/main/paywall).

## Prerequisites

This guide assumed your app is running on Next.js and has a `lib/` folder. For the integration, we're going to add files under `lib/`, `constants/`, `components/`, `app/posts/`, and `app/api/unlock/`. Our example gates blog-style posts, but you can swap "post" for whatever your paid content is.

This paywall is deliberately database-free and webhook-free: entitlements live on Whop and are checked in real time, so there is nothing to store and nothing to keep in sync.

If you would rather gate with webhooks and your own database, follow our [How to add checkout to a Next.js app](https://whop.com/blog/add-checkout-to-nextjs-app/) article instead.

### Create the products and plans

First we need to create products and plans for the paywalls, and we're going to do it in the [Whop sandbox dashboard](https://sandbox.whop.com/dashboard), the sandbox environment of Whop where we can simulate payments without moving real money. We'll switch to production in the last section.

After signing in, create a whop and go to its dashboard, then create:

- A product for the subscription tier with a recurring plan, for example $10 a month. This product will unlock every premium post.
- A product for each post that can be bought on its own, with a one-time plan, for example $5. We create one for our pricing teardown post.

Copy the two product ids (`prod_...`) and the two plan ids (`plan_...`) as you go. The plan ids are listed on each product's pricing section.

### Get a Company API key

Still in the dashboard of your whop, go to the Developer section and under the API keys part, and create an API key. Enable the permissions the unlock flow needs: `payment:basic:read`, `plan:basic:read`, and `access_pass:basic:read` (the unlock route retrieves the payment along with the plan and product it bought), plus `member:basic:read` (the buyer's identity on that payment).

### Install packages

Then, install the Whop SDK, the checkout embed, an encrypted session cookie, and runtime validation.

<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 `.env.local` (and Vercel's environment variables when we 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 company &gt; Dashboard &gt; Developer &gt; API keys.</td></tr>
<tr><td><code>WHOP_PRO_PRODUCT_ID</code></td><td><code>prod_...</code></td><td>The subscription product's ID.</td></tr>
<tr><td><code>WHOP_PRO_PLAN_ID</code></td><td><code>plan_...</code></td><td>The recurring plan on that product.</td></tr>
<tr><td><code>WHOP_SANDBOX</code></td><td><code>true</code></td><td>Set manually. <code>true</code> in development. Remove or set 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>Our app origin, used for the embed's return URL.</td></tr>
</tbody></table>

### Validate env vars at startup

Go to `lib/` and create a file called `env.ts`:

<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_PRO_PRODUCT_ID: z.string().startsWith(&quot;prod_&quot;),
  WHOP_PRO_PLAN_ID: z.string().startsWith(&quot;plan_&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_PRO_PRODUCT_ID: process.env.WHOP_PRO_PRODUCT_ID?.trim(),
    WHOP_PRO_PLAN_ID: process.env.WHOP_PRO_PLAN_ID?.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>

## The paywall flow

Every paywall we create has three jobs:

- **Enforcement** is a gate that decides to render the content or not.
- **Entitlement** checks if the user has the product, and it lives on Whop, queried with one API call.
- **Identity** is a `whopUserId` in an encrypted cookie. The checkout produces identity and entitlement in one step, and the receipt ID is the proof the browser hands back to our server.

The checkout doubles as the signup form, so users reach payment as soon as possible.

The whole flow, end to end:

- A visitor opens a premium post. When the server component can't find a `whopUserId` cookie, it renders the paywall with an embedded checkout.
- The visitor pays inside the embed. The email field in Whop's checkout is the first place we capture identity.
- The embed's `onComplete` callback fires with a receipt id (`pay_...`), which then the client posts to `/api/unlock`.
- The unlock route asks Whop for that payment, confirms it is actually paid, and reads the buyer's `user.id`.
- The route writes `{ whopUserId }` into an encrypted iron-session cookie, and the client refreshes the route.
- Then, the page re-renders. The gate calls `users.checkAccess` for each product with the cookie's user ID, and any grant renders the content. The same live check runs on every later visit. Revoking the membership locks the page again.

Security-wise, the receipt ID is the only thing that ever crosses from browser to server, and only valuable when Whop confirms what it paid for.

## The SDK client

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>

## The session cookie

Go to `lib/` and create a file called `session.ts`:

<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 {
  whopUserId?: string;
  username?: string;
  unlockedAt?: number;
}

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>

> 

## The posts map

Go to `constants/` and create a file called `posts.ts`. This map stands in for your database or CMS, and the structure is what matters.

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">posts.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 interface Post {
  slug: string;
  title: string;
  teaser: string;
  premium: boolean;
  productId: string | null;
  planId: string | null;
  body: string[];
}

export const posts: Post[] = [
  {
    slug: &quot;getting-started&quot;,
    title: &quot;Getting started with Pulse&quot;,
    teaser: &quot;What this product does and who it is for.&quot;,
    premium: false,
    productId: null,
    planId: null,
    body: [&quot;Free content renders for everyone and never touches the gate.&quot;],
  },
  {
    slug: &quot;saas-pricing-teardown&quot;,
    title: &quot;Pricing teardown: what 50 top SaaS apps actually charge&quot;,
    teaser: &quot;We normalized the public pricing of 50 category leaders.&quot;,
    premium: true,
    productId: &quot;prod_REPLACE_WITH_POST_PRODUCT&quot;,
    planId: &quot;plan_REPLACE_WITH_ONE_TIME_PLAN&quot;,
    body: [&quot;The premium analysis lives here.&quot;],
  },
  {
    slug: &quot;the-churn-report&quot;,
    title: &quot;The churn report: why customers actually leave&quot;,
    teaser: &quot;Twelve months of cancellation reasons, categorized.&quot;,
    premium: true,
    productId: null,
    planId: null,
    body: [&quot;This post is for subscribers only.&quot;],
  },
];

export function getPost(slug: string): Post | undefined {
  return posts.find((post) =&gt; post.slug === slug);
}</code></pre>
  </div>
</div>

A post that can be bought on its own carries its own Whop product and plan IDs, and a post with neither is unlocked only by the subscription.

Paste your one-time product and plan IDs into the post that sells on its own. Our demo fills the `body` arrays with real content. Yours will come from wherever your content lives today.

## The gate

Go to `lib/` and create a file called `paywall.ts`:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">paywall.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-tsx">import { cache } from &quot;react&quot;;
import { getSession } from &quot;@/lib/session&quot;;
import { getWhop } from &quot;@/lib/whop&quot;;

export const checkProductAccess = cache(
  async (productId: string, whopUserId: string): Promise&lt;boolean&gt; =&gt; {
    try {
      const result = await getWhop().users.checkAccess(productId, {
        id: whopUserId,
      });
      return result.has_access;
    } catch {
      return false;
    }
  },
);

export async function hasAccess(
  productIds: Array&lt;string | null | undefined&gt;,
): Promise&lt;boolean&gt; {
  const session = await getSession();
  const whopUserId = session.whopUserId;
  if (!whopUserId) return false;

  const ids = productIds.filter((id): id is string =&gt; Boolean(id));
  const results = await Promise.all(
    ids.map((id) =&gt; checkProductAccess(id, whopUserId)),
  );
  return results.some(Boolean);
}</code></pre>
  </div>
</div>

This is the entire authorization layer. `hasAccess` retrieves a list of products the user can view (for a shoppable post, this includes both the post itself and its subscription product) and returns `true` if the user owns any of them.

Anonymous visitors cost nothing: with no user id in the cookie, `hasAccess` returns early before any API call. For logged-in users, one Whop round-trip runs per product on every render, so the data is always up to date.

If that round-trip ever fails, `checkProductAccess` catches the error and returns `false` instead of throwing, so a Whop outage shows the paywall rather than crashing the page with a 500.

The webhook-and-database approach in the Checkout article makes the opposite trade-off: no API round-trip per render, but entitlements are only eventually consistent and you have a database to run.

You can choose each feature separately. They all work together in a single application.

## The checkout drop-in

Go to `components/` and create a file called `PaywallCard.tsx`. This is a minimal version; our demo improves on the same concept with a modal and step-by-step instructions.

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">PaywallCard.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, useEffect, useRef, useState } from &quot;react&quot;;
import { useRouter } from &quot;next/navigation&quot;;
import { WhopCheckoutEmbed } from &quot;@whop/checkout/react&quot;;

export interface TierOption {
  key: string;
  label: string;
  description: string;
  planId: string;
}

interface PaywallCardProps {
  options: TierOption[];
  environment: &quot;production&quot; | &quot;sandbox&quot;;
  returnUrl: string;
}

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

const POLL_MS = 2000;
const MAX_ATTEMPTS = 10;

export function PaywallCard({
  options,
  environment,
  returnUrl,
}: PaywallCardProps) {
  const router = useRouter();
  const [selected, setSelected] = useState(options[0]?.key ?? &quot;&quot;);
  const [phase, setPhase] = useState&lt;Phase&gt;({ name: &quot;checkout&quot; });
  const cancelled = useRef(false);

  useEffect(() =&gt; {
    cancelled.current = false;
    return () =&gt; {
      cancelled.current = true;
    };
  }, []);

  const verify = useCallback(
    async (receiptId: string) =&gt; {
      setPhase({ name: &quot;verifying&quot; });
      for (let attempt = 0; attempt &lt; MAX_ATTEMPTS; attempt++) {
        let res: Response;
        try {
          res = await fetch(&quot;/api/unlock&quot;, {
            method: &quot;POST&quot;,
            headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
            body: JSON.stringify({ receiptId }),
          });
        } catch {
          setPhase({
            name: &quot;error&quot;,
            message: &quot;We couldn&#039;t reach the server. Try again.&quot;,
            receiptId,
          });
          return;
        }
        if (cancelled.current) return;

        if (res.ok) {
          router.refresh();
          return;
        }

        if (res.status === 202 || res.status === 404) {
          await new Promise((resolve) =&gt; setTimeout(resolve, POLL_MS));
          continue;
        }

        setPhase({
          name: &quot;error&quot;,
          message:
            &quot;We couldn&#039;t verify the payment. If you were charged, keep your receipt id and contact support.&quot;,
          receiptId,
        });
        return;
      }
      setPhase({
        name: &quot;error&quot;,
        message: &quot;The payment is taking longer than expected. Try again.&quot;,
        receiptId,
      });
    },
    [router],
  );

  const active =
    options.find((option) =&gt; option.key === selected) ?? options[0];

  if (phase.name === &quot;verifying&quot;) {
    return &lt;p&gt;Verifying your purchase...&lt;/p&gt;;
  }

  if (phase.name === &quot;error&quot;) {
    return (
      &lt;div&gt;
        &lt;p&gt;{phase.message}&lt;/p&gt;
        {phase.receiptId &amp;&amp; (
          &lt;button
            type=&quot;button&quot;
            onClick={() =&gt; void verify(phase.receiptId as string)}
          &gt;
            Retry verification
          &lt;/button&gt;
        )}
      &lt;/div&gt;
    );
  }

  return (
    &lt;div&gt;
      {options.length &gt; 1 &amp;&amp; (
        &lt;div&gt;
          {options.map((option) =&gt; (
            &lt;button
              key={option.key}
              type=&quot;button&quot;
              onClick={() =&gt; setSelected(option.key)}
            &gt;
              {option.label}: {option.description}
            &lt;/button&gt;
          ))}
        &lt;/div&gt;
      )}
      {active &amp;&amp; (
        &lt;WhopCheckoutEmbed
          key={active.planId}
          planId={active.planId}
          environment={environment}
          returnUrl={returnUrl}
          onComplete={(_planId, receiptId) =&gt; {
            if (!receiptId) {
              setPhase({
                name: &quot;error&quot;,
                message:
                  &quot;The checkout didn&#039;t hand back a receipt. Check your email for it.&quot;,
              });
              return;
            }
            void verify(receiptId);
          }}
        /&gt;
      )}
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</div>

The checkout-session route isn't included in this article since embed takes the `planId` directly. This is the simplification provided compared to a metadata-driven checkout.

The second argument of `onComplete` is the receipt ID, and it's the exact Whop payment ID (`pay_...`) that the server receives in the unlock route.

It remains in the component's state and never passes into a URL. You can learn more about checkout sessions, metadata, and the full embed customization reference in your [Whop developer docs](https://docs.whop.com/).

> 

## The locked page

Go to `app/posts/[slug]/` and create a file called `page.tsx`. A simple gated page looks like:

<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 { notFound } from &quot;next/navigation&quot;;
import { PaywallCard, type TierOption } from &quot;@/components/PaywallCard&quot;;
import { getPost } from &quot;@/constants/posts&quot;;
import { getEnv } from &quot;@/lib/env&quot;;
import { hasAccess } from &quot;@/lib/paywall&quot;;

export default async function PostPage({
  params,
}: {
  params: Promise&lt;{ slug: string }&gt;;
}) {
  const { slug } = await params;
  const post = getPost(slug);
  if (!post) notFound();

  const env = getEnv();
  const unlocked =
    !post.premium ||
    (await hasAccess([env.WHOP_PRO_PRODUCT_ID, post.productId]));

  return (
    &lt;main&gt;
      &lt;h1&gt;{post.title}&lt;/h1&gt;
      {unlocked ? (
        &lt;article&gt;
          {post.body.map((paragraph, i) =&gt; (
            &lt;p key={i}&gt;{paragraph}&lt;/p&gt;
          ))}
        &lt;/article&gt;
      ) : (
        &lt;div&gt;
          &lt;p&gt;{post.teaser}&lt;/p&gt;
          &lt;PaywallCard
            options={
              [
                post.planId &amp;&amp; {
                  key: &quot;post&quot;,
                  label: &quot;Unlock this post&quot;,
                  description: &quot;$5 one time. Yours forever.&quot;,
                  planId: post.planId,
                },
                {
                  key: &quot;pro&quot;,
                  label: &quot;Pulse Pro&quot;,
                  description: &quot;$10 a month. Every premium post.&quot;,
                  planId: env.WHOP_PRO_PLAN_ID,
                },
              ].filter(Boolean) as TierOption[]
            }
            environment={env.WHOP_SANDBOX ? &quot;sandbox&quot; : &quot;production&quot;}
            returnUrl={`${env.APP_URL}/posts/${post.slug}`}
          /&gt;
        &lt;/div&gt;
      )}
    &lt;/main&gt;
  );
}</code></pre>
  </div>
</div>

Branching is preferred over redirection, and this choice enables an in-place unlocking experience. After payment, the client reloads the same URL, and the same page renders the other branch.

The locked branch is rendered on the server only after `hasAccess` grants permission. This makes sure that the locked visitors never see the premium content, not even when it's hidden with CSS.

Now run the development server and open a premium post. The teaser and embedded checkout should come back rendered, but the content body shouldn't.

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

## The unlock route

Go to `app/api/unlock/` and create a file called `route.ts`:

<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 { posts } from &quot;@/constants/posts&quot;;
import { getEnv } from &quot;@/lib/env&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}$/),
});

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

  const env = getEnv();
  const knownProductIds = new Set(
    [env.WHOP_PRO_PRODUCT_ID, ...posts.map((post) =&gt; post.productId)].filter(
      (id): id is string =&gt; Boolean(id),
    ),
  );
  if (!payment.product?.id || !knownProductIds.has(payment.product.id)) {
    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; || payment.substatus?.includes(&quot;refund&quot;)) {
    return Response.json({ error: &quot;not_paid&quot; }, { status: 403 });
  }

  if (!payment.user?.id) {
    return Response.json({ error: &quot;no_user&quot; }, { status: 502 });
  }

  const session = await getSession();
  session.whopUserId = payment.user.id;
  session.username = payment.user.username;
  session.unlockedAt = Date.now();
  await session.save();

  return Response.json({ ok: true, username: payment.user.username });
}</code></pre>
  </div>
</div>

This route does exactly one thing, exchange a verified receipt for a session. It never grants authorization itself, because the gateway already re-checks Whop on the next render, maintaining this separation keeps both components small.

It also intentionally returns JSON instead of a redirect because writing an iron-session cookie and returning `NextResponse.redirect` from the same handler makes us lose the `Set-Cookie` header. The client-side `router.refresh()` then takes over the redirection task following the 200 response.

Now let's run the loop:

- Create a premium post and make a payment within the embed using a sandbox test card (`4242 4242 4242 4242`, any future expiration date, any CVC, and any name). Check that the page loads the full content without a redirect.
- Instead, make a payment with `4000 0000 0000 0002`. The decline process remains within the embed, `onComplete` is never triggered, and nothing reaches your server.
- Open your sandbox panel, locate the recipient's subscription, and cancel it immediately, then refresh the page. The page should lock again.

> 

## Returning users

On the same browser, there is nothing to do: the cookie persists and the gate keeps passing. For a new device or cleared cookies, add "Sign in with Whop" from our [user authentication article](https://whop.com/blog/add-user-authentication).

It composes with this paywall in one line: the OAuth `sub` claim is the same `user_...` id the payment carried, so when the callback sets `session.whopUserId`, the gate unlocks with zero changes here. `checkAccess` never cares how the id reached the cookie.

Buyers sign in with the email they paid with, since the checkout created their Whop account under it. The companion demo wires this exact button as its restore path.

## Sandbox to production

In the development phase, we used the sandbox environment of Whop to simulate payments ([sandbox.whop.com](https://sandbox.whop.com/dashboard)). Now, it's time to switch to production ([whop.com](https://whop.com/dashboard))

- Recreate the products and plans in production. Sandbox ids do not carry over. Swap `WHOP_PRO_PRODUCT_ID` and `WHOP_PRO_PLAN_ID` in the env, and the per-post ids in `constants/posts.ts`.
- Create a production Company API key with the same permissions and set it as `WHOP_COMPANY_API_KEY`.
- Remove `WHOP_SANDBOX` or set it to `false`. The SDK base URL and the embed's `environment` prop both derive from it, so they flip together.
- Generate a fresh `SESSION_SECRET` for production and set `APP_URL` to your real origin.
- Pay with a real card once and confirm the unlock. Then refund yourself from the dashboard AND cancel the test membership, which both stops a real renewal and re-verifies the relock in production. Remember that the refund alone leaves access granted.

There is no webhook to migrate, which is the payoff of keeping entitlements on Whop: the paywall's production switch is just keys and ids.

## What's next

Adding a paywall to your app is just one way Whop infrastructure can help you add functionality to your project. With Whop, you can build entire app clones like a Patreon or a Ko-fi clone, or simply add user authentication to your existing projects.

To learn more about the Whop infrastructure, check out our other tutorials and the [Whop developer documentation](https://docs.whop.com/).

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