You can add promo codes to your app or website using Next.js and Whop, without building a discount system or counting uses yourself. Learn how in this guide.
Key takeaways
- You can add promo codes to your app or website without building a discount system, because Whop stores the code, its rules, and its usage count, and the checkout works out the new price.
- The number you send is not the number you get back, so a 20% code goes in as 20 and reads back as 0.2, including in the checkout callback.
- A code that has expired or been turned off fails silently, charging full price with no error to the buyer and none to your app.
You can add a promo code system to your app without having to build your own discount system. Whop handles the code, its details, and statistics itself and automatically calculates the prices inside its own checkout component.
This means you don't have to keep your promo codes in a database or manually update anything.
If you sell on Whop and only need codes made from the dashboard, our promo codes guide for creators covers that.

In this tutorial, we're going to create a promo code using an API route and make our customers input it into a checkout. Then, we'll put the code inside a marketing link so the customers can see the lower price at first sight.
You can try the whole flow in our companion demo and its repository.
Prerequisites
This guide assumes your app runs on Next.js and already talks to Whop through a server-side SDK client. That client is the getWhop() helper we build in the paywall article, and we show the full file at the end of this section.
We're going to add files under lib/, components/, app/api/, and app/. Everything we do will initially work on the Whop sandbox, which helps us simulate the real system without moving real money. We'll move to production in the last section.
Create something to discount
Promo codes lower the price of plans, so before we ever create a promo code, we need a plan. There are two easy ways to create one.
First, you can go to sandbox.whop.com and create a whop. Inside it, create a product from its dashboard with a one-time price. Ours costs $40, which is high enough that a percentage discount is easy to see on the receipt.
Now open the three-dot menu next to the product on the Products page of your business dashboard, go to the Details option, and copy its product ID. It starts with prod_. Then open the Checkout links section and copy the plan ID, which starts with plan_.
If you'd rather do it with a command in the terminal or using an AI agent, the Whop CLI creates the same product and plan. One thing to watch out for: the CLI cannot switch to the sandbox on its own. You have to set WHOP_API_BASE_URL on every command, including the login command that saves your key.
How to point the terminal at the sandbox
You can point the CLI at the sandbox with one environment variable. Set WHOP_API_BASE_URL to https://sandbox-api.whop.com/api/v1. On macOS or Linux you set it for the whole terminal session with export, and on Windows PowerShell you use $env:WHOP_API_BASE_URL = "..." instead.
Set it before you log in, not after. The login command saves your key against whichever environment the variable points at, so logging in without it saves a sandbox key against production, and every later command fails with a 401 that never mentions the real cause.
It only lasts as long as the terminal window. Open a new one and you have to set it again.
npm install -g @whop/cli
export WHOP_API_BASE_URL=https://sandbox-api.whop.com/api/v1
whop auth login --method api-key --api-key apik_XXXXXXXX --profile sandbox
whop products create --title "Promo demo pass"
whop plans create --product_id prod_XXXXXXXXXXXXX --plan_type one_time --initial_price 40
Get a Company API key
Now, let's create a company API key. While you're in the dashboard, open the Developer page and create one under the Company API keys section with these permissions: promo_code:create, promo_code:basic:read, promo_code:delete, access_pass:basic:read, payment:basic:read, and plan:basic:read.
Install the packages
We're going to need three packages for our promo code system to work: the Whop server SDK, the checkout embed that draws the payment form, and Zod, which checks that incoming data has the shape we expect.
npm install @whop/sdk @whop/checkout zod
Environment variables
Add these to .env.local. Add them to your hosting provider too, when you deploy.
| Variable | Example | How to get it |
|---|---|---|
WHOP_COMPANY_API_KEY | apik_... | Sandbox dashboard > Developer > Company API keys. |
WHOP_COMPANY_ID | biz_... | From the sandbox dashboard URL. |
WHOP_PRODUCT_ID | prod_... | The product the codes discount. |
WHOP_PLAN_ID | plan_... | The plan buyers check out with. |
WHOP_PLAN_PRICE | 40 | The plan's list price, used to preview the discounted price before checkout. |
WHOP_SANDBOX | true | Set manually. Remove it in production. |
ADMIN_API_KEY | a long random string | Set manually. Your admin tools send it to create and list codes. |
APP_URL | http://localhost:3000 | Your app origin, used for the embed's return URL. |
Create the Whop client
The Whop SDK exports a WhopClient class and an environment setting that switches it between the sandbox and production environments.
Go to lib/ and create a file called whop.ts with the following content:
import { WhopClient, WhopEnvironment } from "@whop/sdk";
let cached: WhopClient | null = null;
export function getWhop(): WhopClient {
if (!cached) {
cached = new WhopClient({
token: process.env.WHOP_COMPANY_API_KEY as string,
environment:
process.env.WHOP_SANDBOX === "true"
? WhopEnvironment.Sandbox
: WhopEnvironment.Production,
});
}
return cached;
}
The number Whop gives back
Before we create a promo code, let's understand the system first.
When you want to create a promo code with a 25% discount, you send 25, but when you ask Whop about the code later, you'll get 0.25.
If you create the promo code with a fixed dollar amount discount, a $10 discount goes in as 10 and comes back as 10.

So, let's fix the number in one place, and let the rest of the app work with normal percentages. Go to lib/ and create a file called promo.ts with the following content:
export type PromoType = "percentage" | "flat_amount";
export type PromoStatus = "active" | "inactive" | "archived";
export interface PromoSummary {
id: string;
code: string;
promoType: PromoType;
displayAmount: number;
rawAmountOff: number;
label: string;
status: PromoStatus;
uses: number;
stock: number;
unlimitedStock: boolean;
expiresAt: string | null;
}
export interface RawPromoCode {
id: string;
code: string | null;
promo_type: string;
amount_off: number;
status: string;
uses: number;
stock: number;
unlimited_stock: boolean;
expires_at: string | null;
}
export function toDisplayAmount(promoType: PromoType, rawAmountOff: number) {
if (promoType !== "percentage") return rawAmountOff;
// Math.round because 0.15 * 100 is 15.000000000000002 in binary floating point.
return Math.round(rawAmountOff * 100);
}
export function describeDiscount(promoType: PromoType, displayAmount: number) {
return promoType === "percentage"
? `${displayAmount}% off`
: `$${displayAmount.toFixed(2)} off`;
}
export function previewPrice(
price: number,
promoType: PromoType,
displayAmount: number,
) {
const discounted =
promoType === "percentage"
? price * (1 - displayAmount / 100)
: price - displayAmount;
return Math.max(0, Math.round(discounted * 100) / 100);
}
export function toSummary(raw: RawPromoCode): PromoSummary {
const promoType: PromoType =
raw.promo_type === "flat_amount" ? "flat_amount" : "percentage";
const displayAmount = toDisplayAmount(promoType, raw.amount_off);
return {
id: raw.id,
code: raw.code ?? "",
promoType,
displayAmount,
rawAmountOff: raw.amount_off,
label: describeDiscount(promoType, displayAmount),
status: raw.status as PromoStatus,
uses: raw.uses,
stock: raw.stock,
unlimitedStock: raw.unlimited_stock,
expiresAt: raw.expires_at,
};
}
Create a promo code
Now, let's create the route that our app calls to create promo codes. Whop calls the business that owns a code an account, so our company ID goes in as account_id. promoCodes.list resolves to a page of results, so we await the call first and then walk the page with for await.
One field you should keep in mind is promo_duration_months, which sets how many months the discount stays on a subscription. Send 0 to keep it forever, 1 to apply it to the first payment only, or a bigger number for that many payments.
The route also takes an optional expiresAt timestamp, so you can create a code that runs out and test the expiry feature we're going to take a look at later down the guide.
Those are the fields we use here. The create promo code reference lists the rest, and none of them can change after the code exists, so choose them carefully.
This route creates real discounts and lists every code you have, so it only answers requests that carry your admin key. Whatever calls it, such as your dashboard or a campaign script, sends that key in the Authorization header.
Go to app/api/promo-codes/ and create a file called route.ts with the following content:
import { timingSafeEqual } from "node:crypto";
import { z } from "zod";
import { WhopError } from "@whop/sdk";
import { getWhop } from "@/lib/whop";
import { toSummary, type PromoSummary, type RawPromoCode } from "@/lib/promo";
const createSchema = z.object({
code: z
.string()
.trim()
.min(3)
.max(40)
.regex(/^[A-Za-z0-9_-]+$/, "Letters, numbers, hyphen and underscore only"),
promoType: z.enum(["percentage", "flat_amount"]),
amount: z.number().positive(),
durationMonths: z.number().int().min(0).max(60),
stock: z.number().int().positive().nullable(),
onePerCustomer: z.boolean(),
newUsersOnly: z.boolean(),
expiresAt: z.iso.datetime().optional(),
});
function isAdmin(request: Request) {
const key = process.env.ADMIN_API_KEY;
if (!key) return false;
const expected = Buffer.from(`Bearer ${key}`);
const given = Buffer.from(request.headers.get("authorization") ?? "");
return given.length === expected.length && timingSafeEqual(given, expected);
}
export async function GET(request: Request) {
if (!isAdmin(request)) {
return Response.json({ error: "unauthorized" }, { status: 401 });
}
const codes: PromoSummary[] = [];
for await (const raw of await getWhop().promoCodes.list({
account_id: process.env.WHOP_COMPANY_ID as string,
})) {
codes.push(toSummary(raw as unknown as RawPromoCode));
}
return Response.json({ codes });
}
export async function POST(request: Request) {
if (!isAdmin(request)) {
return Response.json({ error: "unauthorized" }, { status: 401 });
}
const body: unknown = await request.json().catch(() => null);
const parsed = createSchema.safeParse(body);
if (!parsed.success) {
return Response.json({ error: "invalid_input" }, { status: 400 });
}
const input = parsed.data;
if (input.promoType === "percentage" && (input.amount < 1 || input.amount > 100)) {
return Response.json(
{ error: "A percentage must be between 1 and 100." },
{ status: 400 },
);
}
try {
const created = await getWhop().promoCodes.create({
account_id: process.env.WHOP_COMPANY_ID as string,
code: input.code,
promo_type: input.promoType,
amount_off: input.amount,
base_currency: "usd",
new_users_only: input.newUsersOnly,
promo_duration_months: input.durationMonths,
...(input.stock === null
? { unlimited_stock: true }
: { stock: input.stock, unlimited_stock: false }),
one_per_customer: input.onePerCustomer,
product_id: process.env.WHOP_PRODUCT_ID as string,
...(input.expiresAt ? { expires_at: input.expiresAt } : {}),
});
return Response.json({ code: toSummary(created as unknown as RawPromoCode) });
} catch (error: unknown) {
return Response.json({ error: whopMessage(error) }, { status: 400 });
}
}
function whopMessage(error: unknown) {
if (error instanceof WhopError) {
const body = error.body as { error?: { message?: string } } | undefined;
if (body?.error?.message) return body.error.message;
}
return "Whop turned this promo code down.";
}
To try it from the terminal, send a request like the one below with your admin key. It answers with the new code's summary, so LAUNCH25 comes back as launch25 with displayAmount: 25 and label: "25% off".
curl -X POST http://localhost:3000/api/promo-codes \
-H "Authorization: Bearer $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"code":"LAUNCH25","promoType":"percentage","amount":25,"durationMonths":1,"stock":100,"onePerCustomer":true,"newUsersOnly":false}'
LAUNCH25 is saved as launch25. Two working codes also cannot share the same name.If someone picks a name that is taken, Whop sends back a clear message, and we pass that message straight to them.
Let a buyer use a code
The Whop checkout has a built-in field where buyers can input promo codes, and now it's our turn to find out which promo code the buyer entered. That is what onPromoCodeChanged does. It is a function you hand to the checkout, and the checkout runs it whenever the promo code changes.
It runs with the code when someone adds one, and with null when they remove it. So treat it as "the code right now".
Go to components/ and create a file called PromoCheckout.tsx with the following content:
"use client";
import { useState } from "react";
import { WhopCheckoutEmbed } from "@whop/checkout/react";
import type { WhopCheckoutPromoCode } from "@whop/checkout/react";
import { toDisplayAmount } from "@/lib/promo";
export function PromoCheckout({
planId,
environment,
returnUrl,
promoCode,
}: {
planId: string;
environment: "sandbox" | "production";
returnUrl: string;
promoCode?: string;
}) {
const [applied, setApplied] = useState<WhopCheckoutPromoCode | null>(null);
return (
<div>
<WhopCheckoutEmbed
key={`${planId}:${promoCode ?? ""}`}
planId={planId}
environment={environment}
returnUrl={returnUrl}
promoCode={promoCode}
onPromoCodeChanged={setApplied}
onComplete={async (_planId, receiptId) => {
if (!receiptId) return;
await fetch("/api/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ receiptId }),
});
}}
/>
{applied && (
<p>
{applied.code} is on, worth{" "}
{applied.type === "percentage"
? `${toDisplayAmount("percentage", applied.amount)}%`
: `$${applied.amount}`}
</p>
)}
</div>
);
}
Put a code inside a link
Now let's talk about one of the most important parts of a promo code system. We don't want typing the promo code into the checkout to be the only option our users have.

Instead, we want the checkout to carry the promo code as the user loads it. The checkout accepts a setting called promoCode, and it applies the discount before the buyer ever sees the full price.
All that is left is to read the code out of the web address. Go to app/ and create a file called page.tsx with the following content:
import { PromoCheckout } from "@/components/PromoCheckout";
export default async function Home({
searchParams,
}: {
searchParams: Promise<{ promo?: string }>;
}) {
const { promo } = await searchParams;
return (
<main>
<h1>Promo demo pass</h1>
<PromoCheckout
planId={process.env.WHOP_PLAN_ID as string}
environment={process.env.WHOP_SANDBOX === "true" ? "sandbox" : "production"}
returnUrl={`${process.env.APP_URL}/`}
promoCode={promo}
/>
</main>
);
}
Check the code before the checkout
When a promo code has expired, been archived, or never existed, you or your customers won't see an error. The customer gets a regular checkout at the full price with an empty promo box, and your app won't see an error either.

That is why we want to check the code ourselves first. You can ask for a code by its promo_ ID, and you can filter a list by company, product, plan, or status, but not by the name itself.
One other thing you should keep in mind is that expired codes still come back as active. So checking the status on its own isn't enough, and you have to compare the expiry date with the current time yourself.
So we ask for the list and match the name in our own code. That is fine for tens of codes. With thousands, keep your own index of code names and their promo_ IDs instead.
We put the check in one function, so the API route and the page can share it. Go to lib/ and create a file called promo-lookup.ts with the following content:
import { getWhop } from "@/lib/whop";
import { toSummary, previewPrice, type PromoSummary, type RawPromoCode } from "@/lib/promo";
export type CodeCheck =
| { usable: true; promo: PromoSummary; preview: number }
| { usable: false; reason: string };
export async function checkPromoCode(code: string): Promise<CodeCheck> {
const wanted = code.trim().toLowerCase();
if (!wanted) {
return { usable: false, reason: "No code given." };
}
for await (const raw of await getWhop().promoCodes.list({
account_id: process.env.WHOP_COMPANY_ID as string,
})) {
const promo = toSummary(raw as unknown as RawPromoCode);
if (promo.code !== wanted) continue;
if (promo.status === "archived") {
return { usable: false, reason: "That code was retired." };
}
if (promo.status !== "active") {
return { usable: false, reason: "That code is paused." };
}
if (!promo.unlimitedStock && promo.uses >= promo.stock) {
return { usable: false, reason: "That code is used up." };
}
if (promo.expiresAt && new Date(promo.expiresAt).getTime() < Date.now()) {
return { usable: false, reason: "That code has expired." };
}
return {
usable: true,
promo,
preview: previewPrice(
Number(process.env.WHOP_PLAN_PRICE),
promo.promoType,
promo.displayAmount,
),
};
}
return { usable: false, reason: "No code by that name." };
}
Go to app/api/promo-codes/lookup/ and create a file called route.ts with the following content:
import { checkPromoCode } from "@/lib/promo-lookup";
export async function GET(request: Request) {
const code = new URL(request.url).searchParams.get("code") ?? "";
return Response.json(await checkPromoCode(code));
}
Now the page can run the same check before it opens the checkout. A dead code gets its reason on screen and a normal checkout, and a working code shows the price the buyer is about to pay. Update app/page.tsx with the following content:
import { PromoCheckout } from "@/components/PromoCheckout";
import { checkPromoCode } from "@/lib/promo-lookup";
export default async function Home({
searchParams,
}: {
searchParams: Promise<{ promo?: string }>;
}) {
const { promo } = await searchParams;
const check = promo ? await checkPromoCode(promo) : null;
return (
<main>
<h1>Promo demo pass</h1>
{check && !check.usable && <p>{check.reason} The checkout opens at the full price.</p>}
{check?.usable && (
<p>
{check.promo.label} with {check.promo.code}, so you pay ${check.preview.toFixed(2)}
</p>
)}
<PromoCheckout
planId={process.env.WHOP_PLAN_ID as string}
environment={process.env.WHOP_SANDBOX === "true" ? "sandbox" : "production"}
returnUrl={`${process.env.APP_URL}/`}
promoCode={check?.usable ? check.promo.code : undefined}
/>
</main>
);
}
Check the discount on the receipt
Everything up to this point is what the checkout said about the price. The number that settles it sits on the payment itself, and only your server can read it.
After a payment, the checkout gives your page a receipt ID. The page sends that ID to us, and we ask Whop for that payment.
The payment gives us all the details. subtotal is the price before the discount, total is what the buyer actually paid, and promo_code_id names the code that did it. Both prices arrive as money objects whose amount is an exact string like "40.00", so we turn them into numbers first.
The payment only carries the code's ID, so we fetch the code itself to show its name and discount. Its amount_off is the same fraction we talked about before, so a 25% code reads 0.25 here too, and it goes through toDisplayAmount like everywhere else.

Go to app/api/verify/ and create a file called route.ts with the following content:
import { z } from "zod";
import { Whop } from "@whop/sdk";
import { getWhop } from "@/lib/whop";
import { toDisplayAmount } from "@/lib/promo";
const bodySchema = z.object({
receiptId: z.string().regex(/^pay_[A-Za-z0-9]{4,60}$/),
});
export async function POST(request: Request) {
const body: unknown = await request.json().catch(() => null);
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return Response.json({ error: "invalid_receipt" }, { status: 400 });
}
let payment;
try {
payment = await getWhop().payments.retrieve({ id: parsed.data.receiptId });
} catch (error: unknown) {
if (error instanceof Whop.NotFoundError) {
return Response.json({ error: "not_found" }, { status: 404 });
}
throw error;
}
if (payment.product_id !== process.env.WHOP_PRODUCT_ID) {
return Response.json({ error: "wrong_product" }, { status: 403 });
}
if (payment.status === "pending" || payment.status === "open") {
return Response.json({ status: "pending" }, { status: 202 });
}
if (payment.status !== "paid") {
return Response.json({ error: "not_paid" }, { status: 403 });
}
const before = Number(payment.subtotal?.amount ?? 0);
const paid = Number(payment.total?.amount ?? 0);
const promo = payment.promo_code_id
? await getWhop().promoCodes.retrieve({ id: payment.promo_code_id })
: null;
return Response.json({
ok: true,
before,
paid,
saved: Math.round((before - paid) * 100) / 100,
code: promo?.code ?? null,
discount:
promo && promo.promo_type === "percentage"
? `${toDisplayAmount("percentage", promo.amount_off)}%`
: promo
? `$${promo.amount_off.toFixed(2)}`
: null,
});
}
Payments take a moment to become readable after the checkout is complete, so one that's still going through gets a 202. A missing receipt, on the other hand, gets a 404.
In both cases you should ask again in a moment since they don't mean "failed." So the checkout component should keep asking for a few seconds instead of giving up. Update components/PromoCheckout.tsx with the following content:
"use client";
import { useState } from "react";
import { WhopCheckoutEmbed } from "@whop/checkout/react";
import type { WhopCheckoutPromoCode } from "@whop/checkout/react";
import { toDisplayAmount } from "@/lib/promo";
interface Receipt {
paid: number;
saved: number;
code: string | null;
}
async function verifyReceipt(receiptId: string) {
for (let attempt = 1; attempt <= 5; attempt++) {
const response = await fetch("/api/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ receiptId }),
});
if (response.status === 202 || response.status === 404) {
await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
continue;
}
return response.ok ? ((await response.json()) as Receipt) : null;
}
return null;
}
export function PromoCheckout({
planId,
environment,
returnUrl,
promoCode,
}: {
planId: string;
environment: "sandbox" | "production";
returnUrl: string;
promoCode?: string;
}) {
const [applied, setApplied] = useState<WhopCheckoutPromoCode | null>(null);
const [receipt, setReceipt] = useState<Receipt | null>(null);
return (
<div>
<WhopCheckoutEmbed
key={`${planId}:${promoCode ?? ""}`}
planId={planId}
environment={environment}
returnUrl={returnUrl}
promoCode={promoCode}
onPromoCodeChanged={setApplied}
onComplete={async (_planId, receiptId) => {
if (receiptId) setReceipt(await verifyReceipt(receiptId));
}}
/>
{applied && (
<p>
{applied.code} is on, worth{" "}
{applied.type === "percentage"
? `${toDisplayAmount("percentage", applied.amount)}%`
: `$${applied.amount}`}
</p>
)}
{receipt && (
<p>
Paid ${receipt.paid.toFixed(2)} and saved ${receipt.saved.toFixed(2)}
{receipt.code ? ` with ${receipt.code}` : ""}
</p>
)}
</div>
);
}
Pause, restart, and archive a code
Promo codes can be in three states: active means it's working, paused (inactive in the API) means checkout won't accept the code until you resume it, and archived means the code is finished.

While archiving prevents you from bringing the code back online again, it allows you to reuse its name.
The Whop SDK covers all three states. deactivate pauses a code, activate turns it back on, and delete archives it for good, which is the one step you cannot undo. You can also pause and resume a code from the terminal with whop promo-codes deactivate and whop promo-codes activate using the Whop CLI.
Go to lib/ and create a file called promo-lifecycle.ts with the following content:
import { getWhop } from "@/lib/whop";
import { toSummary, type RawPromoCode } from "@/lib/promo";
export async function pausePromoCode(id: string) {
const paused = await getWhop().promoCodes.deactivate({ id });
return toSummary(paused as unknown as RawPromoCode);
}
export async function resumePromoCode(id: string) {
const resumed = await getWhop().promoCodes.activate({ id });
return toSummary(resumed as unknown as RawPromoCode);
}
export async function archivePromoCode(id: string) {
const { deleted } = await getWhop().promoCodes.delete({ id });
return deleted;
}
Moving to production
Everything we've done so far runs on the Whop sandbox, which helps us test the promo code system we added and the checkout without moving real money. Here is how to switch to production:
- Create a whop at Whop.com, build the product and plan again there, then update
WHOP_PRODUCT_ID,WHOP_PLAN_ID, andWHOP_PLAN_PRICEin your environment variables. - Create a new company API key with the same permissions and set it as
WHOP_COMPANY_API_KEY. - Set
WHOP_COMPANY_IDto your new company. - Delete
WHOP_SANDBOX, or set it tofalse. - Set
ADMIN_API_KEYto a new long random string. - Point
APP_URLat your real domain.
Use Whop in your projects
Promo codes are just one addition Whop can make to your app. You can add a checkout API, express checkout, free trials, paywalls, and many more.
If you want to learn more about how you can use Whop in your app, take a look at our other tutorials and the Whop developer docs.