Topic Details
https://whop.com/blog/rss/
Last item retrieved
<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's checkout in your app, and Whop bills your subscribers every month after that.</li><li>Your app doesn't need a database for subscriptions. It reads each user's membership from Whop and opens the paid area while it'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">✨</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'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'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'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'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'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's account, and your subscribers' payments land in it.</p><p>Then go to your business's dashboard and open the Developer page. There, find the API key section and use the "Create API key" 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'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 "Create product" 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's going to be charged on top of the first month's price. A $29 initial price on a $29 plan charges a new subscriber $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'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'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('.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
export WHOP_API_BASE_URL=https://sandbox-api.whop.com/api/v1
export WHOP_API_KEY=apik_XXXXXXXX
whop products create --title "Pro membership" --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'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('.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">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 > Developer > 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'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'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'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'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('.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 "zod";
const envSchema = z.object({
WHOP_COMPANY_API_KEY: z.string().min(1),
WHOP_PLAN_ID: z.string().startsWith("plan_"),
WHOP_CLIENT_ID: z.string().startsWith("app_"),
WHOP_CLIENT_SECRET: z.string().min(1),
WHOP_WEBHOOK_SECRET: z.string().startsWith("ws_").optional(),
WHOP_SANDBOX: z
.string()
.optional()
.transform((value) => value === "true"),
SESSION_SECRET: z.string().min(32),
APP_URL: z.url(),
});
export type Env = z.infer<typeof envSchema>;
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('.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 { WhopClient, WhopEnvironment } from "@whop/sdk";
import { getEnv } from "@/lib/env";
let cached: WhopClient | null = null;
export function getWhop(): WhopClient {
if (cached) return cached;
const env = getEnv();
cached = new WhopClient({
token: env.WHOP_COMPANY_API_KEY,
environment: env.WHOP_SANDBOX ? WhopEnvironment.Sandbox : WhopEnvironment.Production,
});
return cached;
}</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's Whop ID, which is how we'll find their subscription later, and their email, which we'll put in the checkout.</p><p>Now, on the Developer page of your business dashboard, let'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'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's Whop ID and email, which also means we don'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('.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 } from "iron-session";
import { cookies } from "next/headers";
import { getEnv } from "@/lib/env";
export interface SessionData {
whopUserId?: string;
email?: string;
}
export async function getSession(): Promise<IronSession<SessionData>> {
return getIronSession<SessionData>(await cookies(), {
password: getEnv().SESSION_SECRET,
cookieName: "billing_session",
cookieOptions: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
},
});
}</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('.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 { cookies } from "next/headers";
import { z } from "zod";
import { getEnv } from "@/lib/env";
const PKCE_COOKIE = "whop_pkce";
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<typeof userInfoSchema>;
function oauthHost(): string {
return getEnv().WHOP_SANDBOX ? "https://sandbox-api.whop.com" : "https://api.whop.com";
}
function redirectUri(): string {
return `${getEnv().APP_URL}/api/auth/callback`;
}
function randomString(bytes: number): string {
return Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString("base64url");
}
function nonceFromIdToken(idToken: string): unknown {
const payload: unknown = JSON.parse(Buffer.from(idToken.split(".")[1] ?? "", "base64url").toString());
return payload && typeof payload === "object" && "nonce" in payload ? payload.nonce : undefined;
}
export async function startSignIn(): Promise<string> {
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 === "production",
sameSite: "lax",
path: "/",
maxAge: 600,
});
const challenge = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(pkce.codeVerifier));
const params = new URLSearchParams({
response_type: "code",
client_id: getEnv().WHOP_CLIENT_ID,
redirect_uri: redirectUri(),
scope: "openid profile email",
state: pkce.state,
nonce: pkce.nonce,
code_challenge: Buffer.from(challenge).toString("base64url"),
code_challenge_method: "S256",
});
return `${oauthHost()}/oauth/authorize?${params.toString()}`;
}
export async function finishSignIn(code: string, state: string): Promise<WhopUser> {
const store = await cookies();
const saved = store.get(PKCE_COOKIE)?.value;
store.delete(PKCE_COOKIE);
const pkce = pkceSchema.parse(JSON.parse(saved ?? "null"));
if (pkce.state !== state) throw new Error("OAuth state mismatch");
const env = getEnv();
const tokenResponse = await fetch(`${oauthHost()}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
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 ${tokenResponse.status}`);
const tokens = tokenSchema.parse(await tokenResponse.json());
if (tokens.id_token && nonceFromIdToken(tokens.id_token) !== pkce.nonce) {
throw new Error("OAuth nonce mismatch");
}
const userResponse = await fetch(`${oauthHost()}/oauth/userinfo`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
if (!userResponse.ok) throw new Error(`Userinfo failed with ${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('.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 { NextResponse } from "next/server";
import { startSignIn } from "@/lib/whop-oauth";
export async function GET(): Promise<NextResponse> {
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'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('.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 { NextResponse, type NextRequest } from "next/server";
import { finishSignIn } from "@/lib/whop-oauth";
import { getSession } from "@/lib/session";
import { getEnv } from "@/lib/env";
export async function GET(request: NextRequest): Promise<NextResponse> {
const appUrl = getEnv().APP_URL;
const code = request.nextUrl.searchParams.get("code");
const state = request.nextUrl.searchParams.get("state");
if (!code || !state) return NextResponse.redirect(`${appUrl}/?error=signin_cancelled`);
try {
const user = await finishSignIn(code, state);
if (!user.email) return NextResponse.redirect(`${appUrl}/?error=email_required`);
const session = await getSession();
session.whopUserId = user.sub;
session.email = user.email;
await session.save();
return NextResponse.redirect(`${appUrl}/`);
} catch (error: unknown) {
console.error("Sign in failed", error);
return NextResponse.redirect(`${appUrl}/?error=signin_failed`);
}
}</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<h2 id="find-a-users-subscription">Find a user's subscription</h2><p>Subscriptions are considered memberships on Whop, and the <code>subscription.ts</code> file we'll create finds a user'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's status active, so we find paused memberships with a second list filtered by <code>status: "paused"</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'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('.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 { getWhop } from "@/lib/whop";
import { getEnv } from "@/lib/env";
export interface Subscription {
id: string;
status: string;
paused: boolean;
cancelAtPeriodEnd: boolean;
renewsAt: string | null;
manageUrl: string | null;
}
export const ACTIONS = ["cancel_at_period_end", "keep", "pause", "resume"] as const;
export type SubscriptionAction = (typeof ACTIONS)[number];
const ACCESS_STATUSES = new Set(["active", "trialing", "past_due"]);
const ENDED_STATUSES = new Set(["completed", "canceled", "expired"]);
export async function getMonthlyPrice(): Promise<number> {
const plan = await getWhop().variants.retrieve({ id: getEnv().WHOP_PLAN_ID });
return plan.renewal_price;
}
let checkoutId: Promise<string> | null = null;
export function getCheckoutId(): Promise<string> {
checkoutId ??= findOrCreateCheckout().catch((error: unknown) => {
checkoutId = null;
throw error;
});
return checkoutId;
}
async function findOrCreateCheckout(): Promise<string> {
const { WHOP_PLAN_ID, APP_URL } = getEnv();
const whop = getWhop();
const redirectUrl = APP_URL.startsWith("https://") ? `${APP_URL}/pro` : null;
const plan = await whop.variants.retrieve({ id: WHOP_PLAN_ID });
if (!plan.account) throw new Error("The plan has no account");
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<Subscription | null> {
const whop = getWhop();
const filter = { user_id: whopUserId, plan_id: getEnv().WHOP_PLAN_ID };
const paused = new Set<string>();
for await (const membership of await whop.memberships.list({ ...filter, status: "paused" })) {
paused.add(membership.id);
}
for await (const listed of await whop.memberships.list({ ...filter, order: "created_at", direction: "desc", 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 && !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 && new Date(subscription.renewsAt) > new Date();
}
export async function changeSubscription(whopUserId: string, action: SubscriptionAction): Promise<void> {
const subscription = await findSubscription(whopUserId);
if (!subscription) throw new Error("No subscription to change");
const whop = getWhop();
const id = subscription.id;
switch (action) {
case "cancel_at_period_end":
await whop.memberships.cancel({ id, cancel_at_period_end: true });
return;
case "keep":
await whop.memberships.update({ id, cancel_at_period_end: false });
return;
case "pause":
await whop.memberships.pause({ id });
return;
case "resume":
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'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's own confirmation page instead.</p><p>If Whop hasn't listed the new membership by the time they land, the Pro page shows a short "payment received" 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('.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">"use client";
import { WhopElements, Checkout, CheckoutElement } from "@whop/elements-react";
import { loadWhop, type Appearance } from "@whop/elements";
const appearance: Appearance = { theme: { appearance: "light", accentColor: "orange" } };
export function SubscribeCheckout({
checkoutId,
email,
sandbox,
}: {
checkoutId: string;
email: string;
sandbox: boolean;
}) {
return (
<WhopElements elements={loadWhop()} environment={sandbox ? "sandbox" : "production"} appearance={appearance}>
<Checkout checkoutConfiguration={checkoutId}>
<CheckoutElement buyerEmail={email} lockBuyerEmail />
</Checkout>
</WhopElements>
);
}</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('.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 Link from "next/link";
import { getEnv } from "@/lib/env";
import { getSession } from "@/lib/session";
import { findSubscription, getCheckoutId, getMonthlyPrice, hasAccess, isOngoing } from "@/lib/subscription";
import { SubscribeCheckout } from "@/components/SubscribeCheckout";
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 (
<main className="mx-auto flex max-w-xl flex-col gap-6 px-4 py-12">
<div>
<h1 className="text-3xl font-semibold">Pro membership</h1>
<p className="text-gray-600">${price.toFixed(2)} a month. Cancel or pause any time.</p>
</div>
{!session.whopUserId || !session.email ? (
<a className="w-fit rounded bg-black px-4 py-2 text-white" href="/api/auth/login">
Sign in with Whop to subscribe
</a>
) : isOngoing(subscription) ? (
<p>
{hasAccess(subscription) ? (
<>
You are subscribed. Go to <Link className="underline" href="/pro">Pro</Link>, or manage it on your{" "}
</>
) : subscription?.paused ? (
"Your subscription is paused. Resume it on your "
) : (
"Your subscription needs attention. Check it on your "
)}
<Link className="underline" href="/account">account</Link> page.
</p>
) : (
<SubscribeCheckout checkoutId={await getCheckoutId()} email={session.email} sandbox={env.WHOP_SANDBOX} />
)}
</main>
);
}</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's going to read the user's subscription on every visit and only show the page when they have access.</p><p>If the user'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('.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 { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
import { findSubscription, hasAccess } from "@/lib/subscription";
export default async function ProPage({ searchParams }: { searchParams: Promise<{ status?: string }> }) {
const session = await getSession();
if (!session.whopUserId) redirect("/api/auth/login");
const subscription = await findSubscription(session.whopUserId);
if (!hasAccess(subscription)) {
const { status } = await searchParams;
if (status === "success") {
return <p className="p-12">Payment received. Your membership is being activated. Refresh in a moment.</p>;
}
redirect("/");
}
return (
<main className="mx-auto max-w-xl px-4 py-12">
<h1 className="text-3xl font-semibold">Welcome to Pro</h1>
<p className="text-gray-600">Everything your subscribers pay for lives here.</p>
</main>
);
}</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'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('.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 { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getSession } from "@/lib/session";
import { ACTIONS, changeSubscription, findSubscription, type SubscriptionAction } from "@/lib/subscription";
const actionSchema = z.object({ action: z.enum(ACTIONS) });
async function requireUserId(): Promise<string> {
const session = await getSession();
if (!session.whopUserId) redirect("/api/auth/login");
return session.whopUserId;
}
async function updateSubscription(formData: FormData): Promise<void> {
"use server";
const userId = await requireUserId();
const { action } = actionSchema.parse(Object.fromEntries(formData));
await changeSubscription(userId, action);
revalidatePath("/account");
}
function ActionButton({ action, label }: { action: SubscriptionAction; label: string }) {
return (
<form action={updateSubscription}>
<input type="hidden" name="action" value={action} />
<button className="rounded border px-3 py-1">{label}</button>
</form>
);
}
export default async function AccountPage() {
const userId = await requireUserId();
const subscription = await findSubscription(userId);
if (!subscription) redirect("/");
const renews = subscription.renewsAt ? new Date(subscription.renewsAt).toLocaleDateString() : "-";
return (
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-12">
<h1 className="text-3xl font-semibold">Your subscription</h1>
<p className="text-gray-600">
{subscription.paused ? `Paused. Access until ${renews}.` : subscription.cancelAtPeriodEnd ? `Ends on ${renews}.` : `Renews on ${renews}.`} Status: {subscription.status}.
</p>
<div className="flex gap-2">
{subscription.paused ? (
<ActionButton action="resume" label="Resume" />
) : (
<ActionButton action="pause" label="Pause" />
)}
{subscription.cancelAtPeriodEnd ? (
<ActionButton action="keep" label="Keep my subscription" />
) : (
<ActionButton action="cancel_at_period_end" label="Cancel at period end" />
)}
</div>
{subscription.manageUrl && (
<a className="underline" href={subscription.manageUrl}>
Manage it on Whop instead
</a>
)}
</main>
);
}</code></pre>
</div>
</div>
<!--kg-card-end: html-->
<h2 id="see-what-you-keep">See what you keep</h2><p>Let's build the <code>payments.ts</code> file, which reads one payment and returns what the subscriber paid, Whop'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('.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 { getWhop } from "@/lib/whop";
export interface Receipt {
paymentId: string;
billingReason: string;
total: number;
whopFees: number;
youKeep: number;
}
export async function readReceipt(paymentId: string): Promise<Receipt> {
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 ?? "unknown",
total: Number(payment.total?.amount ?? "0"),
whopFees: fees.reduce((sum, fee) => 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't reach localhost URLs.</p><p>In your business'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('.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 { after, type NextRequest } from "next/server";
import { unwrapWebhook } from "@whop/sdk/helpers";
import { z } from "zod";
import { getEnv } from "@/lib/env";
import { readReceipt } from "@/lib/payments";
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<Response> {
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("Invalid signature", { status: 401 });
}
const parsed = eventSchema.safeParse(event);
if (!parsed.success) return new Response("Ignored", { status: 200 });
const { type, data } = parsed.data;
const deliveryId = request.headers.get("webhook-id");
if (type === "payment.succeeded") {
after(async () => {
const receipt = await readReceipt(data.id);
console.log(JSON.stringify({ deliveryId, type, receipt }));
});
}
if (type === "payment.failed" && data.billing_reason === "subscription_cycle") {
console.log(JSON.stringify({ deliveryId, type, paymentId: data.id, note: "renewal failed, Whop will retry" }));
}
return new Response("OK", { 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'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