Topic Details

https://whop.com/blog/rss/

Last successful fetch
Wed, 14 Oct 2026 19:02:49 +0000
Last ping
Wed, 14 Oct 2026 18:06:39 +0000
Last fetch error
Tue, 28 Apr 2026 09:51:37 +0000 (HTTP 504)
Aggregate statistics
0 fetch request(s) per second to whop.com, 0% errors, based on latest 300 seconds

Last item retrieved

Content received
Wed, 14 Oct 2026 19:02:49 +0000
<item><title><![CDATA[How to build a subscription billing app with Next.js and Whop]]></title><description><![CDATA[You can build a subscription billing app with Next.js and Whop, with monthly plans, embedded checkouts, and products gated on membership. Learn how in this guide.]]></description><link>https://whop.com/blog/build-subscription-app/</link><guid isPermaLink="false">6accdda7054a160001f4440b</guid><category><![CDATA[Tutorials]]></category><category><![CDATA[Developers]]></category><dc:creator><![CDATA[East]]></dc:creator><pubDate>Wed, 14 Oct 2026 11:03:51 GMT</pubDate><media:content url="https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/10/blog-sub.png" medium="image"/><content:encoded><![CDATA[
<!--kg-card-begin: html-->
<div class="kg-card kg-cta-card kg-cta-minimal whop-key-takeaways" data-whop-key-takeaways="2026-10-07T07:31:25.671Z" data-layout="minimal">
  <div class="kg-cta-content">
    <div class="kg-cta-content-inner">
      <div class="kg-cta-text">
        <img src="https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/10/blog-sub.png" alt="How to build a subscription billing app with Next.js and Whop"><p><strong>Key takeaways</strong></p>
        <ul><li>To build a subscription billing app with Whop, create a monthly plan, put Whop&apos;s checkout in your app, and Whop bills your subscribers every month after that.</li><li>Your app doesn&apos;t need a database for subscriptions. It reads each user&apos;s membership from Whop and opens the paid area while it&apos;s active.</li><li>Subscribers can cancel, change their mind, or pause without contacting you. A paused subscriber keeps access until the month they paid for ends, and every charge shows what Whop took and what you keep.</li></ul>
      </div>
    </div>
  </div>
</div>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div class="ai-prompt-widget">
  <div class="ai-prompt-widget__header">
    <span class="ai-prompt-widget__icon">&#x2728;</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>
<!--kg-card-end: html-->
<p>You can build a <strong>subscription billing app</strong> with Whop without having to manually charge your subscribers every month, retry cards that fail, handle receipts, and more.</p><p>All you need to do is create a monthly plan, put the Whop checkout in your app, and go live.</p><p>In this tutorial, we&apos;re going to build a Next.js app where people sign in, subscribe to a Pro membership, get into the Pro area while their subscription is active, and manage the subscription themselves.</p><p>You can try the whole flow in our <a href="https://nextjs-whop-subscriptions-demo.vercel.app">companion demo</a> and its <a href="https://github.com/whopio/whop-tutorials/tree/main/subscription-billing">repository</a>.</p><h2 id="how-subscription-billing-works-on-whop">How subscription billing works on Whop</h2><p>The first step of getting subscription billing to work on Whop is creating a plan with the price and how often it renews.</p><p>Then your subscribers pay using the Whop checkout that&apos;s embedded in your app (you can also use a <a href="https://whop.com/blog/recurring-billing-for-saas/">Whop-hosted checkout link</a>).</p><p>Once the checkout is done, Whop&apos;s going to bill the customer every month, retry if the charges fail, and send a receipt to the customer directly without you having to get involved.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/10/subscription-billing-monthly-pulse.webp" class="kg-image" alt="How to build a subscription billing app with Next.js and Whop" loading="lazy" width="2000" height="1125" srcset="https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w600/2026/10/subscription-billing-monthly-pulse.webp 600w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w1000/2026/10/subscription-billing-monthly-pulse.webp 1000w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w1600/2026/10/subscription-billing-monthly-pulse.webp 1600w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w2400/2026/10/subscription-billing-monthly-pulse.webp 2400w" sizes="(min-width: 720px) 720px"></figure><p>And then your app checks the subscription by reading the user&apos;s membership from Whop and grants the paid product to the customer.</p><h2 id="prerequisites">Prerequisites</h2><p>For the sake of the tutorial and being able to do tests, we&apos;re going to start from a new Next.js app that runs on the <a href="https://whop.com/blog/whop-sandbox/">Whop sandbox</a>, so we can do tests without moving real money until the Moving to production section.</p><h3 id="set-up-a-sandbox-account">Set up a sandbox account</h3><p>Create a business on sandbox.whop.com. This will be your app&apos;s account, and your subscribers&apos; payments land in it.</p><p>Then go to your business&apos;s dashboard and open the Developer page. There, find the API key section and use the &quot;Create API key&quot; button to create a new one. Then grant it these permissions:</p><ul><li><code>access_pass:create</code></li><li><code>plan:create</code></li><li><code>checkout_configuration:create</code></li><li><code>checkout_configuration:basic:read</code></li><li><code>member:basic:read</code></li><li><code>member:email:read</code></li><li><code>member:manage</code></li><li><code>membership:cancel</code></li><li><code>payment:basic:read</code></li><li><code>access_pass:update</code></li></ul><h3 id="create-the-plan">Create the plan</h3><p>Before we move on to code, let&apos;s create a plan for our subscribers to pay for. You only have to create the plan once, and there are a few ways to do it.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/10/subscription-billing-one-plan.webp" class="kg-image" alt="How to build a subscription billing app with Next.js and Whop" loading="lazy" width="2000" height="1125" srcset="https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w600/2026/10/subscription-billing-one-plan.webp 600w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w1000/2026/10/subscription-billing-one-plan.webp 1000w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w1600/2026/10/subscription-billing-one-plan.webp 1600w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w2400/2026/10/subscription-billing-one-plan.webp 2400w" sizes="(min-width: 720px) 720px"></figure><p>The easiest way is by using the dashboard. In your sandbox dashboard, open the Product page and use the &quot;Create product&quot; button to create one with a recurring plan at the price you want. Then copy its plan ID, which starts with <code>plan_</code>. You can find it in the URL of the plan you just created.</p><p>You can also create it in code with <code>variants.create</code>, but for something you make once, the dashboard or the CLI is quicker.</p><p>Keep in mind that if you set an initial price, it&apos;s going to be charged on top of the first month&apos;s price. A &#x24;29 initial price on a &#x24;29 plan charges a new subscriber &#x24;58 in the first checkout.</p><p>Once the plan is done and you have its ID, copy it to your environment as <code>WHOP_PLAN_ID</code>. The app is going to create checkouts from it.</p><p>If you&apos;d rather use the terminal or an AI agent, the <a href="https://whop.com/blog/cli-guide/">Whop CLI</a> does the same with a few commands. Point <code>WHOP_API_BASE_URL</code> at the sandbox and export your sandbox API key first, because the CLI can&apos;t switch to the sandbox on its own.</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npm install -g @whop/cli
export WHOP_API_BASE_URL=https://sandbox-api.whop.com/api/v1
export WHOP_API_KEY=apik_XXXXXXXX

whop products create --title &quot;Pro membership&quot; --visibility visible
whop plans create --product_id prod_XXXXXXXX --plan_type renewal --initial_price 0 --renewal_price 29 --billing_period 30</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="install-the-packages">Install the packages</h3><p>Now, let&apos;s create the app and install the packages:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npx create-next-app@latest subscription-billing --typescript --tailwind --app
cd subscription-billing
npm install @whop/sdk @whop/elements @whop/elements-react iron-session zod</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="environment-variables">Environment variables</h3><p>Add these to <code>.env.local</code>, and to your host when you deploy.</p>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th>Variable</th>
<th>Example</th>
<th>How to get it</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>WHOP_COMPANY_API_KEY</code></td>
<td><code>apik_...</code></td>
<td>Sandbox dashboard &gt; Developer &gt; API keys.</td>
</tr>
<tr>
<td><code>WHOP_PLAN_ID</code></td>
<td><code>plan_...</code></td>
<td>The plan you created in Create the plan.</td>
</tr>
<tr>
<td><code>WHOP_CLIENT_ID</code></td>
<td><code>app_...</code></td>
<td>Your OAuth app&apos;s client ID, from Let users sign in with Whop.</td>
</tr>
<tr>
<td><code>WHOP_CLIENT_SECRET</code></td>
<td><code>apik_...</code></td>
<td>Your OAuth app&apos;s API key, from Let users sign in with Whop.</td>
</tr>
<tr>
<td><code>WHOP_WEBHOOK_SECRET</code></td>
<td><code>ws_...</code></td>
<td>Shown when you create the webhook in Get notified about every renewal.</td>
</tr>
<tr>
<td><code>WHOP_SANDBOX</code></td>
<td><code>true</code></td>
<td>Set manually. Remove it in production.</td>
</tr>
<tr>
<td><code>SESSION_SECRET</code></td>
<td><code>32+ random characters</code></td>
<td>Run <code>openssl rand -base64 32</code>.</td>
</tr>
<tr>
<td><code>APP_URL</code></td>
<td><code>http://localhost:3000</code></td>
<td>Your app&apos;s origin.</td>
</tr>
</tbody>
</table>
<!--kg-card-end: html-->
<h3 id="check-your-env-vars-on-startup">Check your env vars on startup</h3><p>Let&apos;s create a schema so Zod checks our environment variables at startup. This will make missing keys fail with clear errors.</p><p>Go to <code>lib/</code> and create a file called <code>env.ts</code> with the following content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">lib/env.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; 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().min(1),
  WHOP_PLAN_ID: z.string().startsWith(&quot;plan_&quot;),
  WHOP_CLIENT_ID: z.string().startsWith(&quot;app_&quot;),
  WHOP_CLIENT_SECRET: z.string().min(1),
  WHOP_WEBHOOK_SECRET: z.string().startsWith(&quot;ws_&quot;).optional(),
  WHOP_SANDBOX: z
    .string()
    .optional()
    .transform((value) =&gt; value === &quot;true&quot;),
  SESSION_SECRET: z.string().min(32),
  APP_URL: z.url(),
});

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

let cached: Env | null = null;

export function getEnv(): Env {
  cached ??= envSchema.parse(process.env);
  return cached;
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h3 id="create-the-whop-client">Create the Whop client</h3><p>You are going to use your company API key in this step. One thing you should keep in mind is that the client switches to the sandbox when <code>WHOP_SANDBOX</code> is true in your environment.</p><p>Go to <code>lib/</code> and create a file called <code>whop.ts</code> with the following content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">lib/whop.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { WhopClient, WhopEnvironment } from &quot;@whop/sdk&quot;;
import { getEnv } from &quot;@/lib/env&quot;;

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;
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h2 id="let-users-sign-in-with-whop">Let users sign in with Whop</h2><p>The plan is ready. Your app needs a way to pinpoint who the user is, so the user signs in with <a href="https://whop.com/blog/add-user-authentication/">Whop OAuth</a>. This method gives us the user&apos;s Whop ID, which is how we&apos;ll find their subscription later, and their email, which we&apos;ll put in the checkout.</p><p>Now, on the Developer page of your business dashboard, let&apos;s create an app and, under OAuth, add <code>http://localhost:3000/api/auth/callback</code> as a redirect URL.</p><p>Then, copy the app&apos;s client ID into <code>WHOP_CLIENT_ID</code> and its API key into <code>WHOP_CLIENT_SECRET</code>. Then add the <code>oauth:token_exchange</code> permission to the app from the Permissions tab.</p><p>The session is an encrypted cookie that holds the user&apos;s Whop ID and email, which also means we don&apos;t have to build and manage a users table.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/10/subscription-billing-session-cookie.webp" class="kg-image" alt="How to build a subscription billing app with Next.js and Whop" loading="lazy" width="2000" height="1125" srcset="https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w600/2026/10/subscription-billing-session-cookie.webp 600w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w1000/2026/10/subscription-billing-session-cookie.webp 1000w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w1600/2026/10/subscription-billing-session-cookie.webp 1600w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w2400/2026/10/subscription-billing-session-cookie.webp 2400w" sizes="(min-width: 720px) 720px"></figure><p>Now, create <code>lib/session.ts</code> with the following content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">lib/session.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { getIronSession, type IronSession } from &quot;iron-session&quot;;
import { cookies } from &quot;next/headers&quot;;
import { getEnv } from &quot;@/lib/env&quot;;

export interface SessionData {
  whopUserId?: string;
  email?: string;
}

export async function getSession(): Promise&lt;IronSession&lt;SessionData&gt;&gt; {
  return getIronSession&lt;SessionData&gt;(await cookies(), {
    password: getEnv().SESSION_SECRET,
    cookieName: &quot;billing_session&quot;,
    cookieOptions: {
      httpOnly: true,
      secure: process.env.NODE_ENV === &quot;production&quot;,
      sameSite: &quot;lax&quot;,
      path: &quot;/&quot;,
    },
  });
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>The OAuth file has two main parts:</p><ol><li>It creates a random PKCE verifier, keeps it in a short-lived cookie, and builds the URL that sends users to Whop.</li><li>When users come back, it checks the values, swaps the code for tokens with the client secret included, and asks Whop who just signed in.</li></ol><p>Go to <code>lib/</code> and create a file called <code>whop-oauth.ts</code> with the following content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">lib/whop-oauth.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { cookies } from &quot;next/headers&quot;;
import { z } from &quot;zod&quot;;
import { getEnv } from &quot;@/lib/env&quot;;

const PKCE_COOKIE = &quot;whop_pkce&quot;;

const pkceSchema = z.object({
  codeVerifier: z.string(),
  state: z.string(),
  nonce: z.string(),
});

const tokenSchema = z.object({
  access_token: z.string(),
  id_token: z.string().optional(),
});

const userInfoSchema = z.object({
  sub: z.string(),
  email: z.string().nullish(),
});

export type WhopUser = z.infer&lt;typeof userInfoSchema&gt;;

function oauthHost(): string {
  return getEnv().WHOP_SANDBOX ? &quot;https://sandbox-api.whop.com&quot; : &quot;https://api.whop.com&quot;;
}

function redirectUri(): string {
  return `&#x24;{getEnv().APP_URL}/api/auth/callback`;
}

function randomString(bytes: number): string {
  return Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString(&quot;base64url&quot;);
}

function nonceFromIdToken(idToken: string): unknown {
  const payload: unknown = JSON.parse(Buffer.from(idToken.split(&quot;.&quot;)[1] ?? &quot;&quot;, &quot;base64url&quot;).toString());
  return payload &amp;&amp; typeof payload === &quot;object&quot; &amp;&amp; &quot;nonce&quot; in payload ? payload.nonce : undefined;
}

export async function startSignIn(): Promise&lt;string&gt; {
  const pkce = { codeVerifier: randomString(32), state: randomString(16), nonce: randomString(16) };
  const store = await cookies();
  store.set(PKCE_COOKIE, JSON.stringify(pkce), {
    httpOnly: true,
    secure: process.env.NODE_ENV === &quot;production&quot;,
    sameSite: &quot;lax&quot;,
    path: &quot;/&quot;,
    maxAge: 600,
  });

  const challenge = await crypto.subtle.digest(&quot;SHA-256&quot;, new TextEncoder().encode(pkce.codeVerifier));
  const params = new URLSearchParams({
    response_type: &quot;code&quot;,
    client_id: getEnv().WHOP_CLIENT_ID,
    redirect_uri: redirectUri(),
    scope: &quot;openid profile email&quot;,
    state: pkce.state,
    nonce: pkce.nonce,
    code_challenge: Buffer.from(challenge).toString(&quot;base64url&quot;),
    code_challenge_method: &quot;S256&quot;,
  });
  return `&#x24;{oauthHost()}/oauth/authorize?&#x24;{params.toString()}`;
}

export async function finishSignIn(code: string, state: string): Promise&lt;WhopUser&gt; {
  const store = await cookies();
  const saved = store.get(PKCE_COOKIE)?.value;
  store.delete(PKCE_COOKIE);
  const pkce = pkceSchema.parse(JSON.parse(saved ?? &quot;null&quot;));
  if (pkce.state !== state) throw new Error(&quot;OAuth state mismatch&quot;);

  const env = getEnv();
  const tokenResponse = await fetch(`&#x24;{oauthHost()}/oauth/token`, {
    method: &quot;POST&quot;,
    headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
    body: JSON.stringify({
      grant_type: &quot;authorization_code&quot;,
      code,
      redirect_uri: redirectUri(),
      client_id: env.WHOP_CLIENT_ID,
      client_secret: env.WHOP_CLIENT_SECRET,
      code_verifier: pkce.codeVerifier,
    }),
  });
  if (!tokenResponse.ok) throw new Error(`Token exchange failed with &#x24;{tokenResponse.status}`);
  const tokens = tokenSchema.parse(await tokenResponse.json());
  if (tokens.id_token &amp;&amp; nonceFromIdToken(tokens.id_token) !== pkce.nonce) {
    throw new Error(&quot;OAuth nonce mismatch&quot;);
  }

  const userResponse = await fetch(`&#x24;{oauthHost()}/oauth/userinfo`, {
    headers: { Authorization: `Bearer &#x24;{tokens.access_token}` },
  });
  if (!userResponse.ok) throw new Error(`Userinfo failed with &#x24;{userResponse.status}`);
  return userInfoSchema.parse(await userResponse.json());
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>Now, the login route. It starts the sign-in process and sends the user to Whop.</p><p>Create <code>app/api/auth/login/route.ts</code> with the following content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">app/api/auth/login/route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse } from &quot;next/server&quot;;
import { startSignIn } from &quot;@/lib/whop-oauth&quot;;

export async function GET(): Promise&lt;NextResponse&gt; {
  return NextResponse.redirect(await startSignIn());
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>The callback route is where Whop sends users back. It finishes the sign-in, saves the user&apos;s Whop ID and email in the session, and opens the home page.</p><p>Create <code>app/api/auth/callback/route.ts</code> with the following content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">app/api/auth/callback/route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { NextResponse, type NextRequest } from &quot;next/server&quot;;
import { finishSignIn } from &quot;@/lib/whop-oauth&quot;;
import { getSession } from &quot;@/lib/session&quot;;
import { getEnv } from &quot;@/lib/env&quot;;

export async function GET(request: NextRequest): Promise&lt;NextResponse&gt; {
  const appUrl = getEnv().APP_URL;
  const code = request.nextUrl.searchParams.get(&quot;code&quot;);
  const state = request.nextUrl.searchParams.get(&quot;state&quot;);
  if (!code || !state) return NextResponse.redirect(`&#x24;{appUrl}/?error=signin_cancelled`);

  try {
    const user = await finishSignIn(code, state);
    if (!user.email) return NextResponse.redirect(`&#x24;{appUrl}/?error=email_required`);

    const session = await getSession();
    session.whopUserId = user.sub;
    session.email = user.email;
    await session.save();

    return NextResponse.redirect(`&#x24;{appUrl}/`);
  } catch (error: unknown) {
    console.error(&quot;Sign in failed&quot;, error);
    return NextResponse.redirect(`&#x24;{appUrl}/?error=signin_failed`);
  }
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h2 id="find-a-users-subscription">Find a user&apos;s subscription</h2><p>Subscriptions are considered memberships on Whop, and the <code>subscription.ts</code> file we&apos;ll create finds a user&apos;s subscription by their Whop user ID. It reads the memberships directly. Users have access while their membership is active, trialing, or past due while Whop retries the charge.</p><p>That includes a subscriber who has scheduled a cancellation for the end of the period, and a paused subscriber until the period they paid for ends.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/10/subscription-billing-who-gets-in.webp" class="kg-image" alt="How to build a subscription billing app with Next.js and Whop" loading="lazy" width="2000" height="1125" srcset="https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w600/2026/10/subscription-billing-who-gets-in.webp 600w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w1000/2026/10/subscription-billing-who-gets-in.webp 1000w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w1600/2026/10/subscription-billing-who-gets-in.webp 1600w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w2400/2026/10/subscription-billing-who-gets-in.webp 2400w" sizes="(min-width: 720px) 720px"></figure><p>It can also change subscriptions, like canceling at the end of a period, taking that cancellation back, pausing it, or resuming it.</p><p>Pausing leaves a membership&apos;s status active, so we find paused memberships with a second list filtered by <code>status: &quot;paused&quot;</code>. The list can trail a change by a few seconds, so the file then reads the membership by its ID, which is always current.</p><p>When canceling, pass <code>cancel_at_period_end: true</code> to <code>memberships.cancel</code>, or the membership ends right away instead of at the end of the period.</p><p>The file also builds the checkout from your plan. It looks for a checkout configuration for <code>WHOP_PLAN_ID</code> that sends subscribers back to your Pro page, and creates one the first time if there isn&apos;t one.</p><p>Go to <code>lib/</code> and create a file called <code>subscription.ts</code> with the following content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">lib/subscription.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { getWhop } from &quot;@/lib/whop&quot;;
import { getEnv } from &quot;@/lib/env&quot;;

export interface Subscription {
  id: string;
  status: string;
  paused: boolean;
  cancelAtPeriodEnd: boolean;
  renewsAt: string | null;
  manageUrl: string | null;
}

export const ACTIONS = [&quot;cancel_at_period_end&quot;, &quot;keep&quot;, &quot;pause&quot;, &quot;resume&quot;] as const;
export type SubscriptionAction = (typeof ACTIONS)[number];

const ACCESS_STATUSES = new Set([&quot;active&quot;, &quot;trialing&quot;, &quot;past_due&quot;]);
const ENDED_STATUSES = new Set([&quot;completed&quot;, &quot;canceled&quot;, &quot;expired&quot;]);

export async function getMonthlyPrice(): Promise&lt;number&gt; {
  const plan = await getWhop().variants.retrieve({ id: getEnv().WHOP_PLAN_ID });
  return plan.renewal_price;
}

let checkoutId: Promise&lt;string&gt; | null = null;

export function getCheckoutId(): Promise&lt;string&gt; {
  checkoutId ??= findOrCreateCheckout().catch((error: unknown) =&gt; {
    checkoutId = null;
    throw error;
  });
  return checkoutId;
}

async function findOrCreateCheckout(): Promise&lt;string&gt; {
  const { WHOP_PLAN_ID, APP_URL } = getEnv();
  const whop = getWhop();
  const redirectUrl = APP_URL.startsWith(&quot;https://&quot;) ? `&#x24;{APP_URL}/pro` : null;

  const plan = await whop.variants.retrieve({ id: WHOP_PLAN_ID });
  if (!plan.account) throw new Error(&quot;The plan has no account&quot;);
  for await (const checkout of await whop.checkoutConfigurations.list({ account_id: plan.account.id, plan_id: WHOP_PLAN_ID })) {
    if (checkout.redirect_url === redirectUrl) return checkout.id;
  }
  const created = await whop.checkoutConfigurations.create({ plan_id: WHOP_PLAN_ID, redirect_url: redirectUrl });
  return created.id;
}

export async function findSubscription(whopUserId: string): Promise&lt;Subscription | null&gt; {
  const whop = getWhop();
  const filter = { user_id: whopUserId, plan_id: getEnv().WHOP_PLAN_ID };

  const paused = new Set&lt;string&gt;();
  for await (const membership of await whop.memberships.list({ ...filter, status: &quot;paused&quot; })) {
    paused.add(membership.id);
  }

  for await (const listed of await whop.memberships.list({ ...filter, order: &quot;created_at&quot;, direction: &quot;desc&quot;, first: 1 })) {
    const membership = await whop.memberships.retrieve({ id: listed.id });
    return {
      id: membership.id,
      status: membership.status,
      paused: paused.has(membership.id),
      cancelAtPeriodEnd: membership.cancel_at_period_end,
      renewsAt: membership.current_period_end,
      manageUrl: membership.manage_url,
    };
  }
  return null;
}

export function isOngoing(subscription: Subscription | null): boolean {
  return Boolean(subscription &amp;&amp; !ENDED_STATUSES.has(subscription.status));
}

export function hasAccess(subscription: Subscription | null): boolean {
  if (!subscription || !ACCESS_STATUSES.has(subscription.status)) return false;
  if (!subscription.paused) return true;
  return subscription.renewsAt !== null &amp;&amp; new Date(subscription.renewsAt) &gt; new Date();
}

export async function changeSubscription(whopUserId: string, action: SubscriptionAction): Promise&lt;void&gt; {
  const subscription = await findSubscription(whopUserId);
  if (!subscription) throw new Error(&quot;No subscription to change&quot;);

  const whop = getWhop();
  const id = subscription.id;
  switch (action) {
    case &quot;cancel_at_period_end&quot;:
      await whop.memberships.cancel({ id, cancel_at_period_end: true });
      return;
    case &quot;keep&quot;:
      await whop.memberships.update({ id, cancel_at_period_end: false });
      return;
    case &quot;pause&quot;:
      await whop.memberships.pause({ id });
      return;
    case &quot;resume&quot;:
      await whop.memberships.resume({ id });
      return;
  }
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h2 id="put-the-checkout-in-your-app">Put the checkout in your app</h2><p>We embed a Whop checkout into your pages using <a href="https://whop.com/blog/add-checkout-to-nextjs-app/" rel="noreferrer">Whop Elements</a>, so customers can make the payment directly in your app. It fills in the signed-in user&apos;s email and locks it, so the membership lands on the same Whop user the app knows from sign-in.</p><p>After the payment, Whop sends the subscriber to your Pro page and adds <code>status=success</code> to the URL. Whop only redirects to HTTPS addresses, so when you do tests locally, the subscriber stays on Whop&apos;s own confirmation page instead.</p><p>If Whop hasn&apos;t listed the new membership by the time they land, the Pro page shows a short &quot;payment received&quot; message instead of sending them away.</p><p>Go to <code>components/</code> and create a file called <code>SubscribeCheckout.tsx</code> with the following content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">components/SubscribeCheckout.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import { WhopElements, Checkout, CheckoutElement } from &quot;@whop/elements-react&quot;;
import { loadWhop, type Appearance } from &quot;@whop/elements&quot;;

const appearance: Appearance = { theme: { appearance: &quot;light&quot;, accentColor: &quot;orange&quot; } };

export function SubscribeCheckout({
  checkoutId,
  email,
  sandbox,
}: {
  checkoutId: string;
  email: string;
  sandbox: boolean;
}) {
  return (
    &lt;WhopElements elements={loadWhop()} environment={sandbox ? &quot;sandbox&quot; : &quot;production&quot;} appearance={appearance}&gt;
      &lt;Checkout checkoutConfiguration={checkoutId}&gt;
        &lt;CheckoutElement buyerEmail={email} lockBuyerEmail /&gt;
      &lt;/Checkout&gt;
    &lt;/WhopElements&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<p>On the homepage, signed-out visitors get the sign-in button. Subscribers get links to the Pro area and their account. A subscriber who is paused past their paid period, or whose payment is unresolved, gets a link to their account instead of a second checkout. Everyone else gets the checkout.</p><p>Since we are on the sandbox, we can do tests without spending real money, so in the checkout, use the card number <code>4242 4242 4242 4242</code>, any future date, and any CVC.</p><p><code>4000 0000 0000 0002</code> declines, and <code>4000 0000 0000 0341</code> subscribes but fails later charges, so you can test a failed renewal.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/10/subscription-billing-test-cards.webp" class="kg-image" alt="How to build a subscription billing app with Next.js and Whop" loading="lazy" width="2000" height="1125" srcset="https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w600/2026/10/subscription-billing-test-cards.webp 600w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w1000/2026/10/subscription-billing-test-cards.webp 1000w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w1600/2026/10/subscription-billing-test-cards.webp 1600w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w2400/2026/10/subscription-billing-test-cards.webp 2400w" sizes="(min-width: 720px) 720px"></figure><p>Update <code>app/page.tsx</code> with the following content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">app/page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import Link from &quot;next/link&quot;;
import { getEnv } from &quot;@/lib/env&quot;;
import { getSession } from &quot;@/lib/session&quot;;
import { findSubscription, getCheckoutId, getMonthlyPrice, hasAccess, isOngoing } from &quot;@/lib/subscription&quot;;
import { SubscribeCheckout } from &quot;@/components/SubscribeCheckout&quot;;

export default async function Home() {
  const env = getEnv();
  const session = await getSession();
  const price = await getMonthlyPrice();
  const subscription = session.whopUserId ? await findSubscription(session.whopUserId) : null;

  return (
    &lt;main className=&quot;mx-auto flex max-w-xl flex-col gap-6 px-4 py-12&quot;&gt;
      &lt;div&gt;
        &lt;h1 className=&quot;text-3xl font-semibold&quot;&gt;Pro membership&lt;/h1&gt;
        &lt;p className=&quot;text-gray-600&quot;&gt;&#x24;{price.toFixed(2)} a month. Cancel or pause any time.&lt;/p&gt;
      &lt;/div&gt;

      {!session.whopUserId || !session.email ? (
        &lt;a className=&quot;w-fit rounded bg-black px-4 py-2 text-white&quot; href=&quot;/api/auth/login&quot;&gt;
          Sign in with Whop to subscribe
        &lt;/a&gt;
      ) : isOngoing(subscription) ? (
        &lt;p&gt;
          {hasAccess(subscription) ? (
            &lt;&gt;
              You are subscribed. Go to &lt;Link className=&quot;underline&quot; href=&quot;/pro&quot;&gt;Pro&lt;/Link&gt;, or manage it on your{&quot; &quot;}
            &lt;/&gt;
          ) : subscription?.paused ? (
            &quot;Your subscription is paused. Resume it on your &quot;
          ) : (
            &quot;Your subscription needs attention. Check it on your &quot;
          )}
          &lt;Link className=&quot;underline&quot; href=&quot;/account&quot;&gt;account&lt;/Link&gt; page.
        &lt;/p&gt;
      ) : (
        &lt;SubscribeCheckout checkoutId={await getCheckoutId()} email={session.email} sandbox={env.WHOP_SANDBOX} /&gt;
      )}
    &lt;/main&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h2 id="unlock-the-pro-area">Unlock the Pro area</h2><p>The Pro page is the actual product that people paid for. It&apos;s going to read the user&apos;s subscription on every visit and only show the page when they have access.</p><p>If the user&apos;s membership is canceled, or paused past the period they paid for, the page is going to be locked.</p><p>Go to <code>app/pro/</code> and create a file called <code>page.tsx</code> with the following content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">app/pro/page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import { redirect } from &quot;next/navigation&quot;;
import { getSession } from &quot;@/lib/session&quot;;
import { findSubscription, hasAccess } from &quot;@/lib/subscription&quot;;

export default async function ProPage({ searchParams }: { searchParams: Promise&lt;{ status?: string }&gt; }) {
  const session = await getSession();
  if (!session.whopUserId) redirect(&quot;/api/auth/login&quot;);

  const subscription = await findSubscription(session.whopUserId);
  if (!hasAccess(subscription)) {
    const { status } = await searchParams;
    if (status === &quot;success&quot;) {
      return &lt;p className=&quot;p-12&quot;&gt;Payment received. Your membership is being activated. Refresh in a moment.&lt;/p&gt;;
    }
    redirect(&quot;/&quot;);
  }

  return (
    &lt;main className=&quot;mx-auto max-w-xl px-4 py-12&quot;&gt;
      &lt;h1 className=&quot;text-3xl font-semibold&quot;&gt;Welcome to Pro&lt;/h1&gt;
      &lt;p className=&quot;text-gray-600&quot;&gt;Everything your subscribers pay for lives here.&lt;/p&gt;
    &lt;/main&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h2 id="let-subscribers-manage-it">Let subscribers manage it</h2><p>While Whop gives every membership a manage page, building your own page keeps subscribers in your app.</p><p>The account page shows when the subscription renews or ends, and a button for each change. Each button calls a server action, which checks the session, validates the action with Zod, and changes the signed-in user&apos;s own subscription.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/10/subscription-billing-self-serve.webp" class="kg-image" alt="How to build a subscription billing app with Next.js and Whop" loading="lazy" width="2000" height="1125" srcset="https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w600/2026/10/subscription-billing-self-serve.webp 600w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w1000/2026/10/subscription-billing-self-serve.webp 1000w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w1600/2026/10/subscription-billing-self-serve.webp 1600w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w2400/2026/10/subscription-billing-self-serve.webp 2400w" sizes="(min-width: 720px) 720px"></figure><p>Go to <code>app/account/</code> and create a file called <code>page.tsx</code> with the following content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">app/account/page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import { redirect } from &quot;next/navigation&quot;;
import { revalidatePath } from &quot;next/cache&quot;;
import { z } from &quot;zod&quot;;
import { getSession } from &quot;@/lib/session&quot;;
import { ACTIONS, changeSubscription, findSubscription, type SubscriptionAction } from &quot;@/lib/subscription&quot;;

const actionSchema = z.object({ action: z.enum(ACTIONS) });

async function requireUserId(): Promise&lt;string&gt; {
  const session = await getSession();
  if (!session.whopUserId) redirect(&quot;/api/auth/login&quot;);
  return session.whopUserId;
}

async function updateSubscription(formData: FormData): Promise&lt;void&gt; {
  &quot;use server&quot;;
  const userId = await requireUserId();
  const { action } = actionSchema.parse(Object.fromEntries(formData));
  await changeSubscription(userId, action);
  revalidatePath(&quot;/account&quot;);
}

function ActionButton({ action, label }: { action: SubscriptionAction; label: string }) {
  return (
    &lt;form action={updateSubscription}&gt;
      &lt;input type=&quot;hidden&quot; name=&quot;action&quot; value={action} /&gt;
      &lt;button className=&quot;rounded border px-3 py-1&quot;&gt;{label}&lt;/button&gt;
    &lt;/form&gt;
  );
}

export default async function AccountPage() {
  const userId = await requireUserId();
  const subscription = await findSubscription(userId);
  if (!subscription) redirect(&quot;/&quot;);

  const renews = subscription.renewsAt ? new Date(subscription.renewsAt).toLocaleDateString() : &quot;-&quot;;

  return (
    &lt;main className=&quot;mx-auto flex max-w-xl flex-col gap-4 px-4 py-12&quot;&gt;
      &lt;h1 className=&quot;text-3xl font-semibold&quot;&gt;Your subscription&lt;/h1&gt;
      &lt;p className=&quot;text-gray-600&quot;&gt;
        {subscription.paused ? `Paused. Access until &#x24;{renews}.` : subscription.cancelAtPeriodEnd ? `Ends on &#x24;{renews}.` : `Renews on &#x24;{renews}.`} Status: {subscription.status}.
      &lt;/p&gt;

      &lt;div className=&quot;flex gap-2&quot;&gt;
        {subscription.paused ? (
          &lt;ActionButton action=&quot;resume&quot; label=&quot;Resume&quot; /&gt;
        ) : (
          &lt;ActionButton action=&quot;pause&quot; label=&quot;Pause&quot; /&gt;
        )}
        {subscription.cancelAtPeriodEnd ? (
          &lt;ActionButton action=&quot;keep&quot; label=&quot;Keep my subscription&quot; /&gt;
        ) : (
          &lt;ActionButton action=&quot;cancel_at_period_end&quot; label=&quot;Cancel at period end&quot; /&gt;
        )}
      &lt;/div&gt;

      {subscription.manageUrl &amp;&amp; (
        &lt;a className=&quot;underline&quot; href={subscription.manageUrl}&gt;
          Manage it on Whop instead
        &lt;/a&gt;
      )}
    &lt;/main&gt;
  );
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h2 id="see-what-you-keep">See what you keep</h2><p>Let&apos;s build the <code>payments.ts</code> file, which reads one payment and returns what the subscriber paid, Whop&apos;s fees, and what you keep.</p><p>Go to <code>lib/</code> and create a file called <code>payments.ts</code> with the following content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">lib/payments.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { getWhop } from &quot;@/lib/whop&quot;;

export interface Receipt {
  paymentId: string;
  billingReason: string;
  total: number;
  whopFees: number;
  youKeep: number;
}

export async function readReceipt(paymentId: string): Promise&lt;Receipt&gt; {
  const whop = getWhop();
  const payment = await whop.payments.retrieve({ id: paymentId });
  const { data: fees } = await whop.payments.listFees({ id: paymentId });

  return {
    paymentId,
    billingReason: payment.billing_reason ?? &quot;unknown&quot;,
    total: Number(payment.total?.amount ?? &quot;0&quot;),
    whopFees: fees.reduce((sum, fee) =&gt; sum + Number(fee.settlement_amount.amount), 0),
    youKeep: Number(payment.amount_after_fees.amount),
  };
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h2 id="get-notified-about-every-renewal">Get notified about every renewal</h2><p>Whop handles the <a href="https://whop.com/blog/recurring-payments-guide/">charges on renewals on its own</a>, and we need a webhook to let our app know about each charge as it happens.</p><figure class="kg-card kg-image-card"><img src="https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/10/subscription-billing-renewal-pings.webp" class="kg-image" alt="How to build a subscription billing app with Next.js and Whop" loading="lazy" width="2000" height="1125" srcset="https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w600/2026/10/subscription-billing-renewal-pings.webp 600w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w1000/2026/10/subscription-billing-renewal-pings.webp 1000w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w1600/2026/10/subscription-billing-renewal-pings.webp 1600w, https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/size/w2400/2026/10/subscription-billing-renewal-pings.webp 2400w" sizes="(min-width: 720px) 720px"></figure><p>First, we need to deploy our app so Whop can reach our URL, since it can&apos;t reach localhost URLs.</p><p>In your business&apos;s dashboard, go to the Developer page and, under the webhook section, create a new webhook for the URL <code>https://your-app.vercel.app/api/webhooks/whop</code> with the <code>payment.succeeded</code> and <code>payment.failed</code> events, and copy its secret into <code>WHOP_WEBHOOK_SECRET</code>.</p><p>Go to <code>app/api/webhooks/whop/</code> and create a file called <code>route.ts</code> with the following content:</p>
<!--kg-card-begin: html-->
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">app/api/webhooks/whop/route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest(&apos;.ucb-box&apos;).querySelector(&apos;code&apos;).innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = &apos;Copied!&apos;;
      setTimeout(() =&gt; this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { after, type NextRequest } from &quot;next/server&quot;;
import { unwrapWebhook } from &quot;@whop/sdk/helpers&quot;;
import { z } from &quot;zod&quot;;
import { getEnv } from &quot;@/lib/env&quot;;
import { readReceipt } from &quot;@/lib/payments&quot;;

const eventSchema = z.object({
  type: z.string(),
  data: z.looseObject({
    id: z.string(),
    billing_reason: z.string().nullish(),
  }),
});

export async function POST(request: NextRequest): Promise&lt;Response&gt; {
  const body = await request.text();

  let event: unknown;
  try {
    event = unwrapWebhook(body, {
      headers: Object.fromEntries(request.headers),
      key: getEnv().WHOP_WEBHOOK_SECRET,
    });
  } catch {
    return new Response(&quot;Invalid signature&quot;, { status: 401 });
  }

  const parsed = eventSchema.safeParse(event);
  if (!parsed.success) return new Response(&quot;Ignored&quot;, { status: 200 });
  const { type, data } = parsed.data;
  const deliveryId = request.headers.get(&quot;webhook-id&quot;);

  if (type === &quot;payment.succeeded&quot;) {
    after(async () =&gt; {
      const receipt = await readReceipt(data.id);
      console.log(JSON.stringify({ deliveryId, type, receipt }));
    });
  }

  if (type === &quot;payment.failed&quot; &amp;&amp; data.billing_reason === &quot;subscription_cycle&quot;) {
    console.log(JSON.stringify({ deliveryId, type, paymentId: data.id, note: &quot;renewal failed, Whop will retry&quot; }));
  }

  return new Response(&quot;OK&quot;, { status: 200 });
}</code></pre>
  </div>
</div>
<!--kg-card-end: html-->
<h2 id="moving-to-production">Moving to production</h2><p>To switch to production:</p><ul><li>Create your business on <a href="https://whop.com/dashboard">Whop.com</a> and a company API key with the same permissions. Set <code>WHOP_COMPANY_API_KEY</code>.</li><li>Create the plan again on Whop.com, the same way you did in the sandbox, and set <code>WHOP_PLAN_ID</code> to its ID.</li><li>Create a production OAuth app with <code>oauth:token_exchange</code> and your real callback URL. Set <code>WHOP_CLIENT_ID</code> and <code>WHOP_CLIENT_SECRET</code>.</li><li>Create the webhook again for your production URL and set <code>WHOP_WEBHOOK_SECRET</code>.</li><li>Delete <code>WHOP_SANDBOX</code>.</li><li>Point <code>APP_URL</code> at your domain and generate a new <code>SESSION_SECRET</code>.</li></ul><div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-text">Sandbox plans and subscriptions don&apos;t carry over. Your subscribers subscribe again on the live app.</div></div><h2 id="use-whop-in-your-projects">Use Whop in your projects</h2><p>Whop runs subscriptions and one-time payments in one place, with cards and other payment methods built into the checkout. The <a href="https://whop.com/blog/cli-guide/">Whop CLI</a> manages the same plans and checkouts from the terminal, or from an AI agent.</p><p>You can add <a href="https://whop.com/blog/add-free-trials/">free trials</a>, <a href="https://whop.com/blog/add-promo-codes/">promo codes</a>, or a <a href="https://whop.com/blog/add-paywall/">paywall</a> to the same app. For a bigger build on the same pieces, see how to build an <a href="https://whop.com/blog/build-ai-chatbot-saas/">AI chatbot SaaS</a>. See our other <a href="https://whop.com/blog/t/tutorials/">tutorials</a> and the <a href="https://docs.whop.com/">Whop developer docs</a> for more.</p><div class="kg-card kg-button-card kg-align-left"><a href="https://docs.whop.com/" class="kg-btn kg-btn-accent">Go to Whop developer docs</a></div>]]></content:encoded></item>

These legal disclaimers are here because this hub is run by Google as a service. If you don't want to agree to these terms you can use a different hub or even run your own. The PubSubHubbub protocol is decentralized and free.

©2022 Google - Terms of Service - Privacy Policy