---
title: How to add free trials to your app or website
slug: add-free-trials
excerpt: You can add free trials to your app or website using Whop and skip building complex systems to check trial states. Learn how in this guide.
customExcerpt: You can add free trials to your app or website using Whop and skip building complex systems to check trial states. Learn how in this guide.
featureImage: "https://storage.ghost.io/c/12/7b/127b828b-bdc2-4972-9cf2-de857df9c324/content/images/2026/07/HowToAddFreeTrial.jpg"
status: published
publishedAt: "2026-07-21T10:27:39.000Z"
updatedAt: "2026-07-21T10:27:38.000Z"
createdAt: "2026-07-17T06:01:11.338Z"
tags:
  - { name: Tutorials, slug: tutorials }
  - { name: Developers, slug: developers }
authors:
  - { name: East, slug: east }
  - { name: Destinee Walston, slug: destinee }
---

# How to add free trials to your app or website

## Key takeaways

- Whop lets you add free trials without building billing state, a trials table, or expiration cron jobs.
- A $0-today checkout collects the card and starts a trialing membership that auto-converts to paid.
- You can gate premium content and extend or end trials with a single API call.

<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 free trials to your app without building billing state, a trials table, or a cron job that expires access. Whop handles this with a checkout that collects the customer's card but doesn't charge them, instead starting a membership with the `trialing` state that converts to a paid membership on its own.

In this tutorial, we're going to walk you through locking a premium page behind an embedded checkout, starting a real trial with a $0-today checkout that unlocks the page in place, showing the exact date of the first charge, and extending or ending the trial with a single API call.

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

## Prerequisites

For this tutorial, we're going to assume your app runs on Next.js and has a folder for utilities. We're going to add the files we'll work on under `lib/`, `components/`, `app/premium/`, and `app/api/`.

The trial we're going to use is going to gate a Whop product and it can be a community membership, a membership tier, a digital product, or anything you want. Our example is going to gate a single premium page and you can change it for whatever you sell.

If you would rather have durable payment records and webhook-driven state instead, we cover that in the [checkout article](https://whop.com/blog/add-checkout-to-nextjs-app), and the two approaches compose.

### Create the product and the trial plan

We're going to use the Whop sandbox because we want to do our tests without moving real money. At the end of this tutorial, we're going to switch to production at Whop.com.

Let's follow the steps below:

- Go to [sandbox.whop.com](https://sandbox.whop.com/dashboard), create a whop, and go to its dashboard. There, create a product for the thing that your trial is going to unlock. While creating the product, choose the Recurring pricing type and set a subscription price (since we're in the sandbox, any amount works; ours is $10 a month).
- Then, under the Advanced Options, check the [Free trial option](https://docs.whop.com/manage-your-business/products/free-trials) and pick the number of free days you want to offer (ours is 3, and the rest of the article uses these numbers). Keep in mind that free trials only exist on recurring plans.
- After you create the product, you'll be redirected to the product page of your dashboard. There, use the context menu button (three dots at the right side of the product) to find the Details option, which will give you its product ID that starts with `prod_`.
- Then, go to the Checkout links section in your dashboard. There, you'll see the same product listed. From its context menu, get its plan ID that starts with `plan_`.

> 

Alternatively, you can create your product using API calls. This is useful if you want a reproducible or a scripted setup.

This requires the Company API key from the next section (with the `access_pass:create` permission) plus the SDK install and `.env.local` that follow it, so come back here after those.

Go to `scripts/` and create a file called `setup.mjs` with the content:

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

const whop = new Whop({
  apiKey: process.env.WHOP_COMPANY_API_KEY,
  baseURL: &quot;https://sandbox-api.whop.com/api/v1&quot;,
});

// Your company id, from the dashboard URL: whop.com/dashboard/biz_...
const COMPANY_ID = &quot;biz_...&quot;;

const product = await whop.products.create({
  company_id: COMPANY_ID,
  title: &quot;Pro&quot;,
  visibility: &quot;visible&quot;,
});

const plan = await whop.plans.create({
  product_id: product.id,
  plan_type: &quot;renewal&quot;,
  billing_period: 30,
  initial_price: 0,
  renewal_price: 10,
  trial_period_days: 3,
  currency: &quot;usd&quot;,
  release_method: &quot;buy_now&quot;,
  visibility: &quot;visible&quot;,
});

console.log(&quot;WHOP_PRO_PRODUCT_ID=&quot; + product.id);
console.log(&quot;WHOP_PRO_PLAN_ID=&quot; + plan.id);</code></pre>
  </div>
</div>

Run it once and paste the two IDs it prints into your `.env.local`:

<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">node --env-file=.env.local scripts/setup.mjs</code></pre>
  </div>
</div>

### Get a Company API key

In your dashboard, go to the Developer page, then under the Company API keys section, create an API key and give it the permissions `payment:basic:read`, `plan:basic:read`, `access_pass:basic:read`, `member:basic:read`, `member:manage`, `membership:cancel`, and `member:email:read`. If you're creating the product from code, also add `access_pass:create`.

### Install packages

We need the Whop server SDK, the [checkout embed](https://docs.whop.com/payments/checkout-embed), an encrypted session cookie, and runtime validation.

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

### Environment variables

Add these to the `.env.local` file. We'll also add them to Vercel's (or your provider's) environment variables when we deploy.

<table>
<tbody><tr><th>Variable</th><th>Example</th><th>How to get it</th></tr>
<tr><td><code>WHOP_COMPANY_API_KEY</code></td><td><code>apik_...</code></td><td>Whop company &gt; Dashboard &gt; Developer &gt; API keys.</td></tr>
<tr><td><code>WHOP_PRO_PRODUCT_ID</code></td><td><code>prod_...</code></td><td>The product your trial unlocks.</td></tr>
<tr><td><code>WHOP_PRO_PLAN_ID</code></td><td><code>plan_...</code></td><td>The recurring plan with the free trial days.</td></tr>
<tr><td><code>WHOP_SANDBOX</code></td><td><code>true</code></td><td>Set manually. <code>true</code> in development; remove or set to <code>false</code> in production.</td></tr>
<tr><td><code>SESSION_SECRET</code></td><td><code>...</code></td><td>Generate with <code>openssl rand -base64 32</code>. 32+ chars. iron-session uses it to encrypt the cookie.</td></tr>
<tr><td><code>APP_URL</code></td><td><code>http://localhost:3000</code></td><td>Our app origin, used for the embed's return URL.</td></tr>
</tbody></table>

### Validate env vars at startup

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

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

const envSchema = z.object({
  WHOP_COMPANY_API_KEY: z.string().startsWith(&quot;apik_&quot;),
  WHOP_PRO_PRODUCT_ID: z.string().startsWith(&quot;prod_&quot;),
  WHOP_PRO_PLAN_ID: z.string().startsWith(&quot;plan_&quot;),
  WHOP_SANDBOX: z
    .string()
    .optional()
    .transform((v) =&gt; v === &quot;true&quot;),
  SESSION_SECRET: z.string().min(32),
  APP_URL: z.string().url(),
});

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

let cached: Env | null = null;

export function getEnv(): Env {
  if (cached) return cached;
  cached = envSchema.parse({
    WHOP_COMPANY_API_KEY: process.env.WHOP_COMPANY_API_KEY?.trim(),
    WHOP_PRO_PRODUCT_ID: process.env.WHOP_PRO_PRODUCT_ID?.trim(),
    WHOP_PRO_PLAN_ID: process.env.WHOP_PRO_PLAN_ID?.trim(),
    WHOP_SANDBOX: process.env.WHOP_SANDBOX?.trim(),
    SESSION_SECRET: process.env.SESSION_SECRET,
    APP_URL: process.env.APP_URL?.trim(),
  });
  return cached;
}</code></pre>
  </div>
</div>

Note that the `.trim()` calls are helping us because a trailing newline pasted into a dashboard env field fails API authentication with errors that never mention whitespace.

## The SDK client

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

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

let cached: Whop | null = null;

export function getWhop(): Whop {
  if (cached) return cached;
  const env = getEnv();
  cached = new Whop({
    apiKey: env.WHOP_COMPANY_API_KEY,
    baseURL: env.WHOP_SANDBOX
      ? &quot;https://sandbox-api.whop.com/api/v1&quot;
      : &quot;https://api.whop.com/api/v1&quot;,
  });
  return cached;
}</code></pre>
  </div>
</div>

> 

## The session cookie

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

<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">session.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import {
  getIronSession,
  type IronSession,
  type SessionOptions,
} from &quot;iron-session&quot;;
import { cookies } from &quot;next/headers&quot;;
import { getEnv } from &quot;@/lib/env&quot;;

export interface SessionData {
  whopUserId?: string;
  username?: string;
  membershipId?: string;
  unlockedAt?: number;
}

export function sessionOptions(): SessionOptions {
  return {
    password: getEnv().SESSION_SECRET,
    cookieName: &quot;whop_session&quot;,
    cookieOptions: {
      httpOnly: true,
      secure: process.env.NODE_ENV === &quot;production&quot;,
      sameSite: &quot;lax&quot;,
      path: &quot;/&quot;,
    },
  };
}

export async function getSession(): Promise&lt;IronSession&lt;SessionData&gt;&gt; {
  const store = await cookies();
  return getIronSession&lt;SessionData&gt;(store, sessionOptions());
}</code></pre>
  </div>
</div>

## The gate

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

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

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

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

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

One thing worth noting is that `access_level` differentiates between `admin` and `customer`. This is why members of your whop team can bypass the gate.

## The trial checkout drop-in

To create a minimal version of the checkout drop-in, go to the `components/` folder and create a file called `TrialCheckoutCard.tsx` with the content:

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

import { useCallback, useEffect, useRef, useState } from &quot;react&quot;;
import { useRouter } from &quot;next/navigation&quot;;
import { WhopCheckoutEmbed } from &quot;@whop/checkout/react&quot;;

interface TrialCheckoutCardProps {
  planId: string;
  environment: &quot;production&quot; | &quot;sandbox&quot;;
  returnUrl: string;
}

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

const POLL_MS = 2000;
const MAX_ATTEMPTS = 10;

export function TrialCheckoutCard({
  planId,
  environment,
  returnUrl,
}: TrialCheckoutCardProps) {
  const router = useRouter();
  const [phase, setPhase] = useState&lt;Phase&gt;({ name: &quot;checkout&quot; });
  const cancelled = useRef(false);

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

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

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

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

        setPhase({
          name: &quot;error&quot;,
          message:
            &quot;We couldn&#039;t verify the checkout. Keep your receipt id and contact support if the trial doesn&#039;t appear.&quot;,
          receiptId,
        });
        return;
      }
      setPhase({
        name: &quot;error&quot;,
        message: &quot;The trial is taking longer than expected to confirm. Try again.&quot;,
        receiptId,
      });
    },
    [router],
  );

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

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

  return (
    &lt;WhopCheckoutEmbed
      planId={planId}
      environment={environment}
      returnUrl={returnUrl}
      onComplete={(_planId, receiptId) =&gt; {
        if (!receiptId) {
          setPhase({
            name: &quot;error&quot;,
            message:
              &quot;The checkout didn&#039;t hand back a receipt. Check your email for it.&quot;,
          });
          return;
        }
        void verify(receiptId);
      }}
    /&gt;
  );
}</code></pre>
  </div>
</div>

> 

## The gated page

Now, go to `app/premium/` and create a file called `page.tsx` with the content below. This is a placeholder for the content you want to gate.

<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 { TrialCheckoutCard } from &quot;@/components/TrialCheckoutCard&quot;;
import { getEnv } from &quot;@/lib/env&quot;;
import { hasAccess } from &quot;@/lib/paywall&quot;;

export default async function PremiumPage() {
  const env = getEnv();
  const unlocked = await hasAccess([env.WHOP_PRO_PRODUCT_ID]);

  return (
    &lt;main&gt;
      &lt;h1&gt;Pro workspace&lt;/h1&gt;
      {unlocked ? (
        &lt;section&gt;
          &lt;p&gt;Everything you sell renders here, for members and trialers alike.&lt;/p&gt;
        &lt;/section&gt;
      ) : (
        &lt;div&gt;
          &lt;p&gt;Try everything free for 3 days. $10 a month after that.&lt;/p&gt;
          &lt;TrialCheckoutCard
            planId={env.WHOP_PRO_PLAN_ID}
            environment={env.WHOP_SANDBOX ? &quot;sandbox&quot; : &quot;production&quot;}
            returnUrl={`${env.APP_URL}/premium`}
          /&gt;
        &lt;/div&gt;
      )}
    &lt;/main&gt;
  );
}</code></pre>
  </div>
</div>

Run the dev server using the command below and go to `/premium`. You should now see the embedded checkout, and the premium content isn't rendered.

```bash
npm run dev

```

## The unlock route

For the unlock route, go to `app/api/unlock/` and create a file called `route.ts` with the 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 { NotFoundError } from &quot;@whop/sdk&quot;;
import { getEnv } from &quot;@/lib/env&quot;;
import { getSession } from &quot;@/lib/session&quot;;
import { getWhop } from &quot;@/lib/whop&quot;;

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

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

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

  if (payment.product?.id !== getEnv().WHOP_PRO_PRODUCT_ID) {
    return Response.json({ error: &quot;wrong_product&quot; }, { status: 403 });
  }

  if (payment.status === &quot;pending&quot; || payment.status === &quot;open&quot;) {
    return Response.json({ status: &quot;pending&quot; }, { status: 202 });
  }
  if (payment.status !== &quot;paid&quot; || payment.substatus?.includes(&quot;refund&quot;)) {
    return Response.json({ error: &quot;not_paid&quot; }, { status: 403 });
  }

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

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

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

Now, go to `/premium` and start the trial with the test card `4242 4242 4242 4242`, and the page will unlock. In the backend, Whop created a membership with the trial already started: [retrieve it](https://docs.whop.com/api-reference/memberships/retrieve-membership) and you get the exact date of the first charge, which is what our demo's countdown displays.

One catch when re-testing: each user gets a plan's free trial once, so use a fresh email each run.

> 

## Extend a trial

Go to `app/api/extend-trial/` and create a file called `route.ts` with the 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 { getSession } from &quot;@/lib/session&quot;;
import { getWhop } from &quot;@/lib/whop&quot;;

export async function POST() {
  const session = await getSession();
  if (!session.membershipId) {
    return Response.json({ error: &quot;no_membership&quot; }, { status: 400 });
  }

  const membership = await getWhop().memberships.addFreeDays(
    session.membershipId,
    { free_days: 3 },
  );

  return Response.json({
    ok: true,
    renewalPeriodEnd: membership.renewal_period_end,
  });
}</code></pre>
  </div>
</div>

[Adding free days](https://docs.whop.com/api-reference/memberships/add-free-days-membership) delays the next charge by that many days. During a trial, this simply means the trial got longer. It works on regular subscriptions too, where it delays the next renewal, which is how you can build a "get a free week/month" system.

## End a trial early

Go to `app/api/end-trial/` and create a file called `route.ts` with the 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 { getSession } from &quot;@/lib/session&quot;;
import { getWhop } from &quot;@/lib/whop&quot;;

export async function POST() {
  const session = await getSession();
  if (!session.membershipId) {
    return Response.json({ error: &quot;no_membership&quot; }, { status: 400 });
  }

  await getWhop().memberships.cancel(session.membershipId, {
    cancellation_mode: &quot;immediate&quot;,
  });

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

There are [two ways to cancel](https://docs.whop.com/api-reference/memberships/cancel-membership), and the mode picks between them. The default option lets the user keep their access until the free trial ends and they won't be charged. The other option, `immediate`, takes away the access instantly.

> 

## When the trial ends on its own

If no action is taken before the trial ends, like a cancellation, Whop handles the conversion day on its own. The customer's card will be charged and the trial will become a paid subscription.

If the charge fails, Whop will retry the card for a few days and once the retries run out, the page will be locked for the user in their next visit.

One thing the live checks won't give you is the advance "your trial ends tomorrow" notice in the email. This is an important aspect of free trials, and Whop covers it with a [webhook](https://docs.whop.com/api-reference/memberships/membership-trial-ending-soon) that fires about three days before a trial converts.

## Returning users

There's nothing to do for returning users in the same browser since the cookie persists. For new devices or browsers, you can add a "Sign in with Whop" button by following our [user authentication article](https://whop.com/blog/add-user-authentication).

Since we collected the customer's identity in the free trial checkout, signing in with the same identity lets the returning user keep their access.

## Sandbox to production

Everything we've created so far runs on the Whop sandbox ([sandbox.whop.com](https://sandbox.whop.com/dashboard)). Now, you should switch to production at [whop.com](https://whop.com/dashboard). Here are the steps you should follow:

- Go to Whop.com, create a whop, and in it, recreate the product and the trial plan in production. Then, swap `WHOP_PRO_PRODUCT_ID` and `WHOP_PRO_PLAN_ID` in the env.
- Create a production Company API key with the same read and write permissions and set it as `WHOP_COMPANY_API_KEY`.
- Remove `WHOP_SANDBOX` or set it to `false`.
- Generate a fresh `SESSION_SECRET` for production and set `APP_URL` to your real origin.

## Use Whop in your projects

Adding free trials is just one of many aspects Whop can help you build or improve your project with. From building entire platforms like a [YouTube clone](YouTube clone) or a [Ko-fi clone](https://whop.com/blog/build-kofi-clone/) to integrating a [checkout](https://whop.com/blog/add-checkout-to-nextjs-app/) or [user authentication](https://whop.com/blog/add-user-authentication/) to your project, Whop offers a variety of solutions.

If you want to learn more about what you can do with Whop, check out 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/)**
