---
title: How to add promo codes to your app or website
slug: add-promo-codes
excerpt: "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."
customExcerpt: "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."
featureImage: "https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/08/blog-promocode.png"
status: published
publishedAt: "2026-10-06T15:33:55.000Z"
updatedAt: "2026-10-06T15:33:55.000Z"
createdAt: "2026-08-29T06:01:13.445Z"
tags:
  - { name: Tutorials, slug: tutorials }
  - { name: Developers, slug: developers }
authors:
  - { name: East, slug: east }
  - { name: Destinee Walston, slug: destinee }
---

# How to add promo codes to your app or website

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

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

You can add a 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](https://whop.com/blog/whop-promo-codes/) covers that.

![Whop promo codes with no database](https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/08/promo-no-database.webp)

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](https://nextjs-whop-promo-codes-demo.vercel.app) and its [repository](https://github.com/whopio/whop-tutorials/tree/main/promo-codes).

## 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](https://whop.com/blog/add-paywall/), 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](https://whop.com/blog/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](https://sandbox.whop.com/dashboard) 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](https://whop.com/blog/run-business-with-cli/), the [Whop CLI](https://whop.com/blog/cli-guide/) 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.

<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

whop auth login --method api-key --api-key apik_XXXXXXXX --profile sandbox
whop products create --title &quot;Promo demo pass&quot;
whop plans create --product_id prod_XXXXXXXXXXXXX --plan_type one_time --initial_price 40</code></pre>
  </div>
</div>

### 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](https://docs.whop.com/payments/checkout-embed) that draws the payment form, and Zod, which checks that incoming data has the shape we expect.

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">Terminal</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-bash">npm install @whop/sdk @whop/checkout zod</code></pre>
  </div>
</div>

### Environment variables

Add these to `.env.local`. Add them to your hosting provider too, when you deploy.

<table>
<tbody><tr><th>Variable</th><th>Example</th><th>How to get it</th></tr>
<tr><td><code>WHOP_COMPANY_API_KEY</code></td><td><code>apik_...</code></td><td>Sandbox dashboard &gt; Developer &gt; Company API keys.</td></tr>
<tr><td><code>WHOP_COMPANY_ID</code></td><td><code>biz_...</code></td><td>From the sandbox dashboard URL.</td></tr>
<tr><td><code>WHOP_PRODUCT_ID</code></td><td><code>prod_...</code></td><td>The product the codes discount.</td></tr>
<tr><td><code>WHOP_PLAN_ID</code></td><td><code>plan_...</code></td><td>The plan buyers check out with.</td></tr>
<tr><td><code>WHOP_PLAN_PRICE</code></td><td><code>40</code></td><td>The plan's list price, used to preview the discounted price before checkout.</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>ADMIN_API_KEY</code></td><td><code>a long random string</code></td><td>Set manually. Your admin tools send it to create and list codes.</td></tr>
<tr><td><code>APP_URL</code></td><td><code>http://localhost:3000</code></td><td>Your app origin, used for the embed's return URL.</td></tr>
</tbody></table>

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

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">whop.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { WhopClient, WhopEnvironment } from &quot;@whop/sdk&quot;;

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 === &quot;true&quot;
          ? WhopEnvironment.Sandbox
          : WhopEnvironment.Production,
    });
  }
  return cached;
}</code></pre>
  </div>
</div>

## 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`.

![Only percentages change on the way back](https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/08/promo-encoding.webp)

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:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">promo.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">export type PromoType = &quot;percentage&quot; | &quot;flat_amount&quot;;
export type PromoStatus = &quot;active&quot; | &quot;inactive&quot; | &quot;archived&quot;;

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 !== &quot;percentage&quot;) 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 === &quot;percentage&quot;
    ? `${displayAmount}% off`
    : `$${displayAmount.toFixed(2)} off`;
}

export function previewPrice(
  price: number,
  promoType: PromoType,
  displayAmount: number,
) {
  const discounted =
    promoType === &quot;percentage&quot;
      ? 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 === &quot;flat_amount&quot; ? &quot;flat_amount&quot; : &quot;percentage&quot;;
  const displayAmount = toDisplayAmount(promoType, raw.amount_off);

  return {
    id: raw.id,
    code: raw.code ?? &quot;&quot;,
    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,
  };
}</code></pre>
  </div>
</div>

## 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](https://docs.whop.com/api-reference/promo-codes/create-promo-code) 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:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { timingSafeEqual } from &quot;node:crypto&quot;;
import { z } from &quot;zod&quot;;
import { WhopError } from &quot;@whop/sdk&quot;;
import { getWhop } from &quot;@/lib/whop&quot;;
import { toSummary, type PromoSummary, type RawPromoCode } from &quot;@/lib/promo&quot;;

const createSchema = z.object({
  code: z
    .string()
    .trim()
    .min(3)
    .max(40)
    .regex(/^[A-Za-z0-9_-]+$/, &quot;Letters, numbers, hyphen and underscore only&quot;),
  promoType: z.enum([&quot;percentage&quot;, &quot;flat_amount&quot;]),
  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(&quot;authorization&quot;) ?? &quot;&quot;);
  return given.length === expected.length &amp;&amp; timingSafeEqual(given, expected);
}

export async function GET(request: Request) {
  if (!isAdmin(request)) {
    return Response.json({ error: &quot;unauthorized&quot; }, { 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: &quot;unauthorized&quot; }, { status: 401 });
  }

  const body: unknown = await request.json().catch(() =&gt; null);
  const parsed = createSchema.safeParse(body);
  if (!parsed.success) {
    return Response.json({ error: &quot;invalid_input&quot; }, { status: 400 });
  }
  const input = parsed.data;

  if (input.promoType === &quot;percentage&quot; &amp;&amp; (input.amount &lt; 1 || input.amount &gt; 100)) {
    return Response.json(
      { error: &quot;A percentage must be between 1 and 100.&quot; },
      { 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: &quot;usd&quot;,
      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 &quot;Whop turned this promo code down.&quot;;
}</code></pre>
  </div>
</div>

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"`.

<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">curl -X POST http://localhost:3000/api/promo-codes \
  -H &quot;Authorization: Bearer $ADMIN_API_KEY&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &#039;{&quot;code&quot;:&quot;LAUNCH25&quot;,&quot;promoType&quot;:&quot;percentage&quot;,&quot;amount&quot;:25,&quot;durationMonths&quot;:1,&quot;stock&quot;:100,&quot;onePerCustomer&quot;:true,&quot;newUsersOnly&quot;:false}&#039;</code></pre>
  </div>
</div>

> Whop turns every code into lowercase, so 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:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">PromoCheckout.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import { useState } from &quot;react&quot;;
import { WhopCheckoutEmbed } from &quot;@whop/checkout/react&quot;;
import type { WhopCheckoutPromoCode } from &quot;@whop/checkout/react&quot;;
import { toDisplayAmount } from &quot;@/lib/promo&quot;;

export function PromoCheckout({
  planId,
  environment,
  returnUrl,
  promoCode,
}: {
  planId: string;
  environment: &quot;sandbox&quot; | &quot;production&quot;;
  returnUrl: string;
  promoCode?: string;
}) {
  const [applied, setApplied] = useState&lt;WhopCheckoutPromoCode | null&gt;(null);

  return (
    &lt;div&gt;
      &lt;WhopCheckoutEmbed
        key={`${planId}:${promoCode ?? &quot;&quot;}`}
        planId={planId}
        environment={environment}
        returnUrl={returnUrl}
        promoCode={promoCode}
        onPromoCodeChanged={setApplied}
        onComplete={async (_planId, receiptId) =&gt; {
          if (!receiptId) return;
          await fetch(&quot;/api/verify&quot;, {
            method: &quot;POST&quot;,
            headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
            body: JSON.stringify({ receiptId }),
          });
        }}
      /&gt;

      {applied &amp;&amp; (
        &lt;p&gt;
          {applied.code} is on, worth{&quot; &quot;}
          {applied.type === &quot;percentage&quot;
            ? `${toDisplayAmount(&quot;percentage&quot;, applied.amount)}%`
            : `$${applied.amount}`}
        &lt;/p&gt;
      )}
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</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.

![Two ways promo codes reach checkout](https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/08/promo-two-ways-in.webp)

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:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import { PromoCheckout } from &quot;@/components/PromoCheckout&quot;;

export default async function Home({
  searchParams,
}: {
  searchParams: Promise&lt;{ promo?: string }&gt;;
}) {
  const { promo } = await searchParams;

  return (
    &lt;main&gt;
      &lt;h1&gt;Promo demo pass&lt;/h1&gt;
      &lt;PromoCheckout
        planId={process.env.WHOP_PLAN_ID as string}
        environment={process.env.WHOP_SANDBOX === &quot;true&quot; ? &quot;sandbox&quot; : &quot;production&quot;}
        returnUrl={`${process.env.APP_URL}/`}
        promoCode={promo}
      /&gt;
    &lt;/main&gt;
  );
}</code></pre>
  </div>
</div>

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

![Dead promo code example](https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/08/promo-silent-failure.webp)

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:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">promo-lookup.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 &quot;@/lib/whop&quot;;
import { toSummary, previewPrice, type PromoSummary, type RawPromoCode } from &quot;@/lib/promo&quot;;

export type CodeCheck =
  | { usable: true; promo: PromoSummary; preview: number }
  | { usable: false; reason: string };

export async function checkPromoCode(code: string): Promise&lt;CodeCheck&gt; {
  const wanted = code.trim().toLowerCase();
  if (!wanted) {
    return { usable: false, reason: &quot;No code given.&quot; };
  }

  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 === &quot;archived&quot;) {
      return { usable: false, reason: &quot;That code was retired.&quot; };
    }
    if (promo.status !== &quot;active&quot;) {
      return { usable: false, reason: &quot;That code is paused.&quot; };
    }
    if (!promo.unlimitedStock &amp;&amp; promo.uses &gt;= promo.stock) {
      return { usable: false, reason: &quot;That code is used up.&quot; };
    }
    if (promo.expiresAt &amp;&amp; new Date(promo.expiresAt).getTime() &lt; Date.now()) {
      return { usable: false, reason: &quot;That code has expired.&quot; };
    }

    return {
      usable: true,
      promo,
      preview: previewPrice(
        Number(process.env.WHOP_PLAN_PRICE),
        promo.promoType,
        promo.displayAmount,
      ),
    };
  }

  return { usable: false, reason: &quot;No code by that name.&quot; };
}</code></pre>
  </div>
</div>

Go to `app/api/promo-codes/lookup/` and create a file called `route.ts` with the following content:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { checkPromoCode } from &quot;@/lib/promo-lookup&quot;;

export async function GET(request: Request) {
  const code = new URL(request.url).searchParams.get(&quot;code&quot;) ?? &quot;&quot;;
  return Response.json(await checkPromoCode(code));
}</code></pre>
  </div>
</div>

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:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">page.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">import { PromoCheckout } from &quot;@/components/PromoCheckout&quot;;
import { checkPromoCode } from &quot;@/lib/promo-lookup&quot;;

export default async function Home({
  searchParams,
}: {
  searchParams: Promise&lt;{ promo?: string }&gt;;
}) {
  const { promo } = await searchParams;
  const check = promo ? await checkPromoCode(promo) : null;

  return (
    &lt;main&gt;
      &lt;h1&gt;Promo demo pass&lt;/h1&gt;
      {check &amp;&amp; !check.usable &amp;&amp; &lt;p&gt;{check.reason} The checkout opens at the full price.&lt;/p&gt;}
      {check?.usable &amp;&amp; (
        &lt;p&gt;
          {check.promo.label} with {check.promo.code}, so you pay ${check.preview.toFixed(2)}
        &lt;/p&gt;
      )}
      &lt;PromoCheckout
        planId={process.env.WHOP_PLAN_ID as string}
        environment={process.env.WHOP_SANDBOX === &quot;true&quot; ? &quot;sandbox&quot; : &quot;production&quot;}
        returnUrl={`${process.env.APP_URL}/`}
        promoCode={check?.usable ? check.promo.code : undefined}
      /&gt;
    &lt;/main&gt;
  );
}</code></pre>
  </div>
</div>

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

![Promo code receipts](https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/10/download.png)

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

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { z } from &quot;zod&quot;;
import { Whop } from &quot;@whop/sdk&quot;;
import { getWhop } from &quot;@/lib/whop&quot;;
import { toDisplayAmount } from &quot;@/lib/promo&quot;;

const bodySchema = z.object({
  receiptId: z.string().regex(/^pay_[A-Za-z0-9]{4,60}$/),
});

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

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

  if (payment.product_id !== process.env.WHOP_PRODUCT_ID) {
    return Response.json({ error: &quot;wrong_product&quot; }, { status: 403 });
  }
  if (payment.status === &quot;pending&quot; || payment.status === &quot;open&quot;) {
    return Response.json({ status: &quot;pending&quot; }, { status: 202 });
  }
  if (payment.status !== &quot;paid&quot;) {
    return Response.json({ error: &quot;not_paid&quot; }, { 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 &amp;&amp; promo.promo_type === &quot;percentage&quot;
        ? `${toDisplayAmount(&quot;percentage&quot;, promo.amount_off)}%`
        : promo
          ? `$${promo.amount_off.toFixed(2)}`
          : null,
  });
}</code></pre>
  </div>
</div>

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:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">PromoCheckout.tsx</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-tsx">&quot;use client&quot;;

import { useState } from &quot;react&quot;;
import { WhopCheckoutEmbed } from &quot;@whop/checkout/react&quot;;
import type { WhopCheckoutPromoCode } from &quot;@whop/checkout/react&quot;;
import { toDisplayAmount } from &quot;@/lib/promo&quot;;

interface Receipt {
  paid: number;
  saved: number;
  code: string | null;
}

async function verifyReceipt(receiptId: string) {
  for (let attempt = 1; attempt &lt;= 5; attempt++) {
    const response = await fetch(&quot;/api/verify&quot;, {
      method: &quot;POST&quot;,
      headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
      body: JSON.stringify({ receiptId }),
    });
    if (response.status === 202 || response.status === 404) {
      await new Promise((resolve) =&gt; 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: &quot;sandbox&quot; | &quot;production&quot;;
  returnUrl: string;
  promoCode?: string;
}) {
  const [applied, setApplied] = useState&lt;WhopCheckoutPromoCode | null&gt;(null);
  const [receipt, setReceipt] = useState&lt;Receipt | null&gt;(null);

  return (
    &lt;div&gt;
      &lt;WhopCheckoutEmbed
        key={`${planId}:${promoCode ?? &quot;&quot;}`}
        planId={planId}
        environment={environment}
        returnUrl={returnUrl}
        promoCode={promoCode}
        onPromoCodeChanged={setApplied}
        onComplete={async (_planId, receiptId) =&gt; {
          if (receiptId) setReceipt(await verifyReceipt(receiptId));
        }}
      /&gt;

      {applied &amp;&amp; (
        &lt;p&gt;
          {applied.code} is on, worth{&quot; &quot;}
          {applied.type === &quot;percentage&quot;
            ? `${toDisplayAmount(&quot;percentage&quot;, applied.amount)}%`
            : `$${applied.amount}`}
        &lt;/p&gt;
      )}

      {receipt &amp;&amp; (
        &lt;p&gt;
          Paid ${receipt.paid.toFixed(2)} and saved ${receipt.saved.toFixed(2)}
          {receipt.code ? ` with ${receipt.code}` : &quot;&quot;}
        &lt;/p&gt;
      )}
    &lt;/div&gt;
  );
}</code></pre>
  </div>
</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.

![Promo code archiving example](https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/08/promo-three-states.webp)

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:

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">promo-lifecycle.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 &quot;@/lib/whop&quot;;
import { toSummary, type RawPromoCode } from &quot;@/lib/promo&quot;;

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

## 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](https://whop.com/dashboard), build the product and plan again there, then update `WHOP_PRODUCT_ID`, `WHOP_PLAN_ID`, and `WHOP_PLAN_PRICE` in your environment variables.
- Create a new company API key with the same permissions and set it as `WHOP_COMPANY_API_KEY`.
- Set `WHOP_COMPANY_ID` to your new company.
- Delete `WHOP_SANDBOX`, or set it to `false`.
- Set `ADMIN_API_KEY` to a new long random string.
- Point `APP_URL` at your real domain.

> Promo codes do not move between the sandbox and the real Whop, so create the ones you want to launch with again.

## Use Whop in your projects

Promo codes are just one addition Whop can make to your app. You can add a [checkout API](https://whop.com/blog/integrate-checkout-api/), [express checkout](https://whop.com/blog/express-checkout/), [free trials](https://whop.com/blog/add-free-trials/), [paywalls](https://whop.com/blog/add-paywall/), 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](https://whop.com/blog/t/tutorials/) and the [Whop developer docs](https://docs.whop.com/).

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