LogoStarterkitpro
Features/Payments

Lemon Squeezy

How to integrate Lemon Squeezy for payments and subscriptions.

Optional Feature

Stripe is the default payment gateway integrated in StarterKitPro. This guide explains how to replace Stripe with Lemon Squeezy.

Setup

Follow these steps to integrate Lemon Squeezy into your StarterKit Pro application.

Removing Stripe (If Switching)

If you are switching from the default Stripe integration, follow these steps first:

  1. Uninstall Stripe Package:
    Terminal
    npm uninstall stripe
  2. Remove Stripe Environment Variables: Delete these lines from your .env.local:
    .env.local
    STRIPE_PUBLIC_KEY=...
    STRIPE_SECRET_KEY=...
    STRIPE_WEBHOOK_SECRET=...
  3. Remove Stripe Configuration: Delete Stripe-related config from lib/app-config.ts.
  4. Remove Stripe Schema Fields: Remove priceId field from your prisma/schema.prisma.
  5. Remove Stripe Lib: Delete the file lib/stripe.ts.
  6. Remove Stripe Webhook: Delete the folder app/api/webhook/stripe.

Install the Lemon Squeezy package

Install the official Lemon Squeezy JavaScript library:

Terminal
npm install @lemonsqueezy/lemonsqueezy.js

Add the environment variables

Add the following variables to your .env.local file:

.env.local
LEMONSQUEEZY_API_KEY=your_api_key_here
LEMONSQUEEZY_STORE_ID=your_store_id_here
LEMONSQUEEZY_SIGNING_SECRET=your_webhook_signing_secret_here
  • LEMONSQUEEZY_API_KEY: Create a new API key from your Lemon Squeezy dashboard under Settings > API.
  • LEMONSQUEEZY_STORE_ID: Find your store ID under Settings > Stores. Copy only the numeric ID (without the #).
  • LEMONSQUEEZY_SIGNING_SECRET: Generate a random, secure string. This is used to verify webhook signatures.

Get Product/Variant ID

  1. Create a new product in your Lemon Squeezy dashboard under Store > Products.
  2. Ensure you add at least one variant to the product, even if it has a single price.
  3. Navigate to the variant's details page. The variantId is the number in the URL after /variants/. (e.g., https://app.lemonsqueezy.com/product/[productId]/variants/[variantId])

Code Implementation

Now, update the necessary files in your codebase.

Update your config

Modify your application configuration:

lib/app-config.ts
lemonsqueezy: {
  // Trail period in days (30 days) change 30 to according to your days
  // Set to 0 for no trail period or remove it and also remove from `lib/session`
  trailPeriod: 30 * 24 * 60 * 60 * 1000,
  billingRoute: "/dashboard/settings/billing",
  plans: [
    {
      variantId: process.env.NODE_ENV === "development" ? "765545" : "741411",
      price: 99,
      anchorPrice: 149,
      title: "Starter",
      // Payment mode is used for one time payment or lifetime
      mode: "payment", // "payment" or "subscription"
    },
    {
      variantId: process.env.NODE_ENV === "development" ? "765546" : "741411",
      price: 139,
      anchorPrice: 149,
      title: "Pro",
      // subscription mode is used for recurring payments
      mode: "subscription",
    },
  ],
},

Update your User file model

Adjust your Prisma schema to include Lemon Squeezy customer/subscription fields:

prisma/schema.prisma
// if using mongodb
model User {
  // ...other fileds
  customerId     String?
  variantId      String?
  hasAccess      Boolean @default(false)
  // ...other fileds
}
 
// if using postgresql then
model User {
  // ...other fileds
  customerId     String?   @map("customer_id")
  variantId      String?   @map("variant_id")
  hasAccess      Boolean   @default(false) @map("has_access")
  // ...other fileds
}

Run Commands

Run npm run db:generate and npm run db:push to apply the changes to your database.

Update your Pricing component

Adjust the pricing component to fetch Lemon Squeezy plans/variants:

components/sections/pricing.tsx
"use client";
import { Check, X } from "lucide-react";
import { SectionHeader } from "@/components/ui/custom/section-headers";
import { PurchaseButton } from "@/components/shared/purchase-button";
import { Separator } from "@/components/ui/separator";
import { appConfig } from "@/lib/app-config";
 
interface PlanProps {
  title: string;
  popular: boolean;
  description: string;
  priceTagline?: string;
  features: { title: string; isIncluded?: boolean }[];
  variantId: string;
  price: number;
  anchorPrice: number;
  footerTagline?: string;
}
 
const plans: PlanProps[] = [
  {
    popular: false,
    description: "Perfect for small projects",
    priceTagline: "lifetime deal",
    ...appConfig.lemonsqueezy.plans[0],
    footerTagline: "Perfect life time deal",
    features: [
      { title: "NextJS boilerplate" },
      { title: "Blog & Doc" },
      { title: "Sendgrid / Resend email" },
      { title: "Stripe / Lemon Sqeezy" },
      { title: "Social Login / Magic Link" },
      { title: "Open Ai" },
      { title: "S3 / Cloudinary" },
      { title: "ChatGPT prompts for terms & privacy" },
      { title: "Other Ui tailwind library links" },
      { title: "No Updates", isIncluded: false },
    ],
  },
 
  {
    popular: true,
    description: "Need more power",
    priceTagline: "per month",
    ...appConfig.lemonsqueezy.plans[1],
    footerTagline: "Subscribe to get more",
    features: [
      { title: "NextJS boilerplate" },
      { title: "Blog & Doc" },
      { title: "Sendgrid / Resend email" },
      { title: "Stripe / Lemon Sqeezy" },
      { title: "Social Login / Magic Link" },
      { title: "Open Ai" },
      { title: "S3 / Cloudinary" },
      { title: "ChatGPT prompts for terms & privacy" },
      { title: "Other Ui tailwind library links" },
      { title: "Lifetime updates", isIncluded: true },
    ],
  },
];
 
export default function Pricing() {
  return (
    <div id="pricing">
      <SectionHeader>
        <SectionHeader.HeaderContent>
          <SectionHeader.Badge>PRICING</SectionHeader.Badge>
          <SectionHeader.Heading>Pricing</SectionHeader.Heading>
          <SectionHeader.Text>
            Don&apos;t just take our word for it. Here&apos;s what others have
            to say.
          </SectionHeader.Text>
        </SectionHeader.HeaderContent>
 
        <SectionHeader.Content>
          <section className="flex flex-col sm:flex-row sm:flex-wrap justify-center gap-8">
            <Plans />
          </section>
        </SectionHeader.Content>
      </SectionHeader>
    </div>
  );
}
 
export function Plans() {
  return plans.map((plan: PlanProps, index) => (
    <PlanCard key={index} plan={plan} />
  ));
}
 
function PlanCard({ plan }: { plan: PlanProps }) {
  const anchorPrice = plan.anchorPrice;
 
  return (
    <div
      className={`bg-card rounded-xl text-card-foreground shadow w-full sm:w-96 justify-between py-1 mx-auto sm:mx-0 flex flex-col ${
        plan.popular ? "border-2 border-primary/50" : ""
      }`}
    >
      <div className="flex flex-col space-y-1.5 p-6">
        <div className="flex justify-between">
          <h3 className="text-lg lg:text-xl font-bold ">{plan.title}</h3>
          {plan.popular && (
            <div className="font-medium whitespace-nowrap rounded-lg px-2.5 h-fit text-sm py-1 bg-primary text-primary-foreground">
              Popular
            </div>
          )}
        </div>
        <div>
          <p>{plan.description}</p>
        </div>
        <Separator className="my-4" />
 
        <div>
          <div className="flex gap-0.5">
            <div className="flex gap-2">
              <div className="flex flex-col justify-end mb-[4px] text-lg ">
                <p className="opacity-80">
                  <span className="absolute bg-base-content h-[1.5px] inset-x-0 top-[48%]"></span>
                  <span className="text-2xl font-semibold line-through">
                    {anchorPrice}
                  </span>
                </p>
              </div>
              <p className="text-6xl tracking-tight font-extrabold">
                {plan.price}
              </p>
              <div className="flex flex-col justify-end mb-[4px]">
                <p className="text-xs opacity-60 uppercase font-semibold">
                  USD
                </p>
              </div>
            </div>
          </div>
          <p className="text-sm text-muted-foreground mt-1">
            {plan.priceTagline}
          </p>
        </div>
        <Separator className="my-4" />
        <div className="pt-2 flex flex-col gap-2">
          {plan.features.map((feature, index) => (
            <Feature key={index} {...feature} />
          ))}
        </div>
      </div>
      <div className="flex flex-col mt-auto items-center pt-0 p-6 w-full">
        <PurchaseButton variantId={plan.variantId} />
        <p className="flex mt-2 text-muted-foreground items-center justify-center gap-2 text-sm text-center font-medium">
          {plan.footerTagline}
        </p>
      </div>
    </div>
  );
}
 
function Feature({
  title,
  isIncluded,
}: {
  title: string;
  isIncluded?: boolean;
}) {
  return (
    <>
      {isIncluded === undefined ? (
        <div className="flex gap-2 items-center space-y-1 leading-relaxed text-base">
          <Check className="mt-1" size={18} />
          <p className="font-medium">{title}</p>
        </div>
      ) : (
        <div className="flex gap-2 items-center space-y-1 leading-relaxed text-base">
          {isIncluded ? (
            <Check className="mt-1 text-green-500" size={18} />
          ) : (
            <X className="mt-1 text-red-500" size={18} />
          )}
          <p
            className={`font-medium ${isIncluded ? "text-green-500" : "text-red-500"}`}
          >
            {title}
          </p>
        </div>
      )}
    </>
  );
}

Update your PurchaseButton component

Modify the purchase button to initiate Lemon Squeezy checkout:

components/shared/purchase-button.tsx
"use client";
 
import { Button } from "@/components/ui/button";
import { createCheckoutSessionAction } from "@/actions/payment-actions";
import { useTransition } from "react";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSession } from "next-auth/react";
import { redirect } from "next/navigation";
import { appConfig } from "@/lib/app-config";
 
export function PurchaseButton({
  variantId,
  className = "",
}: {
  variantId: string;
  className?: string;
}) {
  let [isPending, startTransition] = useTransition();
  const { data: session } = useSession();
 
  const handlePurchaseClick = () => {
    if (!session) {
      redirect(appConfig.auth.login);
    }
 
    // Handle the checkout process
    startTransition(async () => {
      const result = await createCheckoutSessionAction(variantId);
      if (result.status === "success" && result.url) {
        // Redirect to the LemonSqueezy checkout page
        window.location.href = result.url;
      } else {
        // Handle error (could show a toast notification here)
        console.log("Failed to create checkout session");
      }
    });
  };
 
  return (
    <Button
      className={cn("w-full px-12 py-6 font-bold text-base", className)}
      disabled={isPending}
      onClick={handlePurchaseClick}
    >
      {isPending && <Loader2 className="mr-2 animate-spin" />}
      Purchase Now
    </Button>
  );
}

Update your CustomerPortalButton component

Update the customer portal button to redirect users to their Lemon Squeezy billing portal:

components/shared/customer-portal-button.tsx
"use client";
 
import { Button } from "@/components/ui/button";
import { createCustomerPortalAction } from "@/actions/payment-actions";
import { useTransition } from "react";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
 
export function CustomerPortalButton({
  className = "",
}: {
  className?: string;
}) {
  let [isPending, startTransition] = useTransition();
 
  const handlePortalClick = () => {
    // Handle the customer portal process
    startTransition(async () => {
      const result = await createCustomerPortalAction();
      if (result.status === "success" && result.url) {
        // Redirect to the LemonSqueezy customer portal
        window.location.href = result.url;
      } else {
        // Handle error (could show a toast notification here)
        console.log("Failed to create customer portal session");
      }
    });
  };
 
  return (
    <Button
      className={cn("px-4 py-2", className)}
      disabled={isPending}
      onClick={handlePortalClick}
    >
      {isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
      Manage Subscription
    </Button>
  );
}

Update your payment-actions file

Add server actions related to Lemon Squeezy checkout and portal:

actions/payment-actions.ts
"use server";
 
import { getCurrentUser } from "@/lib/session";
import {
  createLemonSqueezyCheckout,
  createCustomerPortal,
} from "@/lib/lemonsqueezy";
import { appConfig } from "@/lib/app-config";
 
/**
 * Creates a checkout session with LemonSqueezy and redirects the user to the checkout page
 */
export const createCheckoutSessionAction = async (variantId: string) => {
  try {
    const currentUser = await getCurrentUser();
    if (!currentUser) {
      throw new Error("Unauthorized");
    }
 
    // Get the redirect URL from the app config or use a default
    const redirectUrl = `${appConfig.domainUrl}/dashboard?success=true`;
 
    // Create a checkout session with LemonSqueezy
    const checkoutUrl = await createLemonSqueezyCheckout({
      variantId,
      email: currentUser.email,
      redirectUrl,
      userId: currentUser.id,
    });
 
    if (!checkoutUrl) {
      throw new Error("Failed to create checkout session");
    }
 
    // Return the checkout URL so the client can redirect
    return { status: "success", url: checkoutUrl };
  } catch (error) {
    console.error("Error creating checkout session:", error);
    return { status: "error" };
  }
};
 
/**
 * Creates a customer portal session for the user to manage their subscription
 */
export const createCustomerPortalAction = async () => {
  try {
    const currentUser = await getCurrentUser();
 
    if (!currentUser) {
      throw new Error("Unauthorized");
    }
 
    if (!currentUser.customerId) {
      throw new Error("User has no customer ID");
    }
 
    // Create a customer portal session with the customer ID
    const portalUrl = await createCustomerPortal({
      customerId: currentUser.customerId,
    });
 
    console.log("Portal URL:", portalUrl);
 
    if (!portalUrl) {
      throw new Error("Failed to create customer portal");
    }
 
    // Return the portal URL so the client can redirect
    return { status: "success", url: portalUrl };
  } catch (error) {
    console.error("Error creating customer portal:", error);
    return { status: "error" };
  }
};

Create a lemonsqueezy lib

Create a utility file for interacting with the Lemon Squeezy API:

lib/lemonsqueezy.ts
import {
  NewCheckout,
  createCheckout,
  getCustomer,
  lemonSqueezySetup,
} from "@lemonsqueezy/lemonsqueezy.js";
 
/**
 * Parameters for creating a LemonSqueezy checkout
 */
interface CheckoutParams {
  variantId: string;
  redirectUrl: string;
  // discountCode: These will be used if you want to prefill the discount code.
  discountCode?: string;
  email: string;
  userId?: string;
}
 
/**
 * Parameters for creating a customer portal
 */
interface CustomerPortalParams {
  customerId: string;
}
 
/**
 * This is used to create a LemonSqueezy Checkout for one-time payments or subscriptions.
 * It's usually triggered with the <PurchaseButton /> component.
 * Webhooks are used to update the user's state in the database.
 */
export const createLemonSqueezyCheckout = async ({
  email,
  redirectUrl,
  variantId,
  discountCode,
  userId,
}: CheckoutParams): Promise<string | undefined> => {
  try {
    lemonSqueezySetup({ apiKey: process.env.LEMONSQUEEZY_API_KEY as string });
 
    const storeId = process.env.LEMONSQUEEZY_STORE_ID as string;
 
    const newCheckout: NewCheckout = {
      productOptions: {
        redirectUrl,
      },
      checkoutData: {
        discountCode,
        email,
        custom: {
          user_id: userId,
        },
      },
    };
 
    const { data, error } = await createCheckout(
      storeId,
      variantId,
      newCheckout
    );
 
    if (error) {
      throw error;
    }
 
    return data.data.attributes.url;
  } catch (e) {
    console.error(e);
    return undefined;
  }
};
 
/**
 * This is used to create Customer Portal sessions, so users can manage their subscriptions
 * (payment methods, cancel, etc.)
 */
export const createCustomerPortal = async ({
  customerId,
}: CustomerPortalParams): Promise<string | null> => {
  try {
    lemonSqueezySetup({ apiKey: process.env.LEMONSQUEEZY_API_KEY as string });
 
    const { data, error } = await getCustomer(customerId);
 
    if (error) {
      throw error;
    }
 
    return data.data.attributes.urls.customer_portal;
  } catch (error) {
    console.error(error);
    return null;
  }
};

Update appConfig References

Update references to Stripe with Lemon Squeezy in these files:

 
# Replace appConfig.stripe with appConfig.lemonsqueezy
# Replace priceId with variantId in your code
 
lib/utils.ts
app/dashboard/(paid)/layout.tsx
components/settings/billing-details.tsx
lib/api.ts
lib/session.ts
components/layouts/dashboard/top-nav-user.tsx
 
# Use ctrl+shift+f or cmd+shift+f to find all instances

Webhook Setup

Add Webhook URL

  1. Go to Settings > Webhooks in your Lemon Squeezy dashboard.
  2. Click "Create webhook".
  3. Enter your application's webhook endpoint URL (e.g., https://yourdomain.com/api/webhook/lemonsqueezy).
  4. Paste the LEMONSQUEEZY_SIGNING_SECRET you generated earlier into the "Signing secret" field.
  5. Select the events you want to subscribe to (e.g., subscription_created, subscription_updated, order_created).
  6. Save the webhook.

Create Webhook API Route

Create the API route handler to process incoming webhooks:

app/api/webhook/lemonsqueezy/route.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { appConfig } from "@/lib/app-config";
import { prisma } from "@/lib/db";
import { User } from "@prisma/client";
// import { sendEmail, renderEmail } from "@/lib/resend";
// import { RetentionOfferEmail } from "@/components/mails/retention-offer-email";
 
export async function POST(request: NextRequest) {
  console.log("Webhook received from Lemonsqueezy");
  const rawBody = await request.text();
  const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET!;
  if (!secret) {
    console.error("LEMONSQUEEZY_WEBHOOK_SECRET is not defined");
    return NextResponse.json("Server configuration error", { status: 400 });
  }
  // 1. Verify webhook signature
  const hmac = crypto.createHmac("sha256", secret);
  const digest = Buffer.from(hmac.update(rawBody).digest("hex"), "utf8");
  const signature = Buffer.from(
    request.headers.get("X-Signature") || "",
    "utf8"
  );
 
  if (!crypto.timingSafeEqual(digest, signature)) {
    console.error("Invalid webhook signature");
    return NextResponse.json({ message: "Invalid signature" }, { status: 401 });
  }
 
  // 2. Parse payload
  const payload = JSON.parse(rawBody);
  const eventName = payload.meta.event_name;
  const attributes = payload.data.attributes;
  const lsCustomerId = attributes.customer_id;
 
  console.log(`Processing Lemonsqueezy event: ${eventName}`);
  console.log("payload.meta?.custom_data:", payload.meta?.custom_data);
  console.log("attributes:", attributes);
 
  try {
    // 3. Handle different webhook events
    switch (eventName) {
      case "order_created":
        // Handles both initial subscription payments and one-time purchases.
        console.log("Handling 'order_created' event...");
 
        // Extract necessary info from the order event
        const userId = payload.meta?.custom_data?.user_id; // User ID passed during checkout recommended
        const customerEmail = attributes.user_email;
        const lsVariantId = attributes.first_order_item?.variant_id; // The specific plan/product purchased
        console.log("userId:", userId);
 
        let user: User | null = null;
 
        // Find the user associated with the order.
        // Priority: Use the userId from custom_data (more reliable)
        if (userId) {
          console.log(`Attempting to find user by userId: ${userId}`);
          user = await prisma.user.findUnique({ where: { id: userId } });
        }
        // Fallback: Use the customer's email if userId wasn't found or provided
        if (!user && customerEmail) {
          console.log(`Attempting to find user by email: ${customerEmail}`);
          user = await prisma.user.findUnique({
            where: { email: customerEmail },
          });
        }
 
        if (!user) {
          console.error(
            `Webhook Error: User not found for order. Email: ${customerEmail}, userId: ${userId}`
          );
          // Acknowledge receipt to Lemon Squeezy even if user not found
          return NextResponse.json(
            { message: "User not found, webhook acknowledged." },
            { status: 200 }
          );
        }
        console.log(`Found user: ${user.id}`);
 
        // Verify the purchased plan/variant exists in app config
        const plan = appConfig.lemonsqueezy.plans.find(
          (p) => p.variantId.toString() === lsVariantId.toString()
        );
        if (!plan) {
          console.error(
            `Plan configuration missing for variantId: ${lsVariantId}`
          );
          // Consider how to handle this (e.g., log, return 200, or throw)
          throw new Error(
            `Plan configuration missing for variantId: ${lsVariantId}`
          );
        }
 
        // Update the user record upon successful order: store customer/variant IDs, grant access.
        console.log(`Updating user ${user.id} for order. Granting access.`);
        await prisma.user.update({
          where: { id: user.id },
          data: {
            customerId: lsCustomerId?.toString() || null, // Store LS customer ID
            variantId: lsVariantId?.toString() || null, // Store purchased variant ID
            hasAccess: true, // Grant access
          } as Partial<User>,
        });
        console.log(`User ${user.id} updated successfully for order_created.`);
        break;
 
      case "subscription_updated":
        // Placeholder: Handles plan changes (upgrades/downgrades), renewals.
        // Consider adding logic here to:
        // 1. Find the user (likely via `lsCustomerId`).
        // 2. Update `variantId` if `attributes.variant_id` has changed.
        // 3. Update `hasAccess` based on `attributes.status` (e.g., 'active', 'past_due').
        console.log(
          "Handling 'subscription_updated' event (currently no action)."
        );
        break;
 
      case "subscription_expired":
        // Handles the end of a subscription period (access should be revoked).
        console.log("Handling 'subscription_expired' event...");
 
        // Find user by the Lemon Squeezy Customer ID associated with the subscription
        const userForSubExpire = await prisma.user.findFirst({
          where: { customerId: lsCustomerId?.toString() },
        });
 
        if (userForSubExpire) {
          console.log(
            `Updating user ${userForSubExpire.id} for subscription expiration. Revoking access.`
          );
          // Revoke access for the user
          await prisma.user.update({
            where: { id: userForSubExpire.id }, // Find user by customerId
            data: {
              hasAccess: false, // Revoke access
            } as Partial<User>,
          });
          console.log(
            `User ${userForSubExpire.id} updated successfully for subscription_expired.`
          );
        } else {
          console.error(
            `Webhook Error: User not found for subscription_expired. LS Customer ID: ${lsCustomerId}`
          );
        }
 
        // Send retention email offer
        // try {
        //   console.log(
        //     `Attempting to send retention email to ${userForSubExpire?.email}...`
        //   );
        //   const emailHtml = await renderEmail(RetentionOfferEmail, {
        //     name: userForSubExpire?.name ?? undefined, // Pass name if available
        //     discountCode: "COMEBACK20", // Placeholder discount code
        //     discountOffer: "20% off your next 3 months", // Example offer text
        //     resubscribeUrl: `${appConfig.domainUrl}/#pricing`,
        //   });
 
        //   await sendEmail({
        //     to: userForSubExpire?.email!,
        //     subject: `Regarding your ${appConfig.appName} Subscription`,
        //     html: emailHtml,
        //   });
        //   console.log(
        //     `Retention email sent successfully to ${userForSubExpire?.email}.`
        //   );
        // } catch (emailError: any) {
        //   console.error(
        //     `Failed to send retention email: ${emailError.message}`
        //   );
        //   // Do not fail the webhook response due to email error
        // }
 
        break;
 
      // Add other events if needed
 
      default:
        // Log events that aren't explicitly handled
        console.log(`Ignoring event: ${eventName}`);
    }
 
    // 4. Respond with 200 OK to acknowledge receipt
    return NextResponse.json(
      { message: "Webhook received and processed" },
      { status: 200 }
    );
  } catch (error) {
    console.error("Error processing webhook:", error);
    // Respond with 500 on processing errors
    return NextResponse.json(
      { message: "Internal server error processing webhook" },
      { status: 500 }
    );
  }
}

Test vs. Live Mode

Lemon Squeezy provides a Test Mode for development and testing purposes, allowing you to simulate transactions without processing real payments. Understanding the differences between Test and Live modes is crucial.

Enabling Test Mode

  • You can toggle Test Mode on or off directly within your Lemon Squeezy dashboard. Look for a switch, usually located in the header or sidebar.

Key Considerations for Test Mode

  • Store ID: Your LEMONSQUEEZY_STORE_ID remains the same for both Test and Live modes.
  • API Key: You must generate separate API keys for Test Mode and Live Mode. Ensure you use the correct key (LEMONSQUEEZY_API_KEY) corresponding to the mode you are operating in (Test during development, Live for production).
  • Test Products/Variants: You must create separate products and variants while in Test Mode. Use the variantIds from these test products in your application during development. Live products/variants are not accessible in Test Mode, and vice versa.
  • Test Webhooks: Webhook events triggered in Test Mode are separate from Live Mode events. If you need to test webhooks, configure your webhook endpoint (e.g., /api/webhook/lemonsqueezy) to receive test events under Settings > Webhooks while Test Mode is active. You might need a separate Signing Secret for test webhooks or use the same one.
  • Test Card: Use Lemon Squeezy's provided test card numbers to simulate purchases.