HomeBlogLemonSqueezy Payment Integration for Next.js SaaS: Complete Guide
LemonSqueezyPaymentsNext.jsSaaS

LemonSqueezy Payment Integration for Next.js SaaS: Complete Guide

How to integrate LemonSqueezy subscriptions into your Next.js SaaS. Covers checkout, webhooks, subscription management, plan changes, cancellations, and failed payment recovery.

SaaSInMinutes
8 min read

Why LemonSqueezy for SaaS

If you're an indie hacker or small team, payments come with a hidden burden: tax compliance.

Stripe processes payments, but you're responsible for:

  • Calculating the correct sales tax for every US state
  • Handling EU VAT for European customers
  • Filing and remitting taxes in each jurisdiction
  • Managing tax ID validation
  • Providing proper invoices with tax breakdowns

LemonSqueezy is a Merchant of Record. They sell your product on your behalf and handle all tax obligations. You get a single monthly payout.

For a team of 1-3 people, this is a significant operational burden removed. This guide assumes you've already wired up Supabase authentication — payments are useless without users.

Setting Up Your LemonSqueezy Store

1. Create Products and Variants

In the LemonSqueezy dashboard:

  1. Create a Store for your SaaS
  2. Create a Product (e.g., "SaaS Pro Plan")
  3. Create Variants for each pricing tier:
    • Monthly ($19/month)
    • Annual ($190/year — ~2 months free)
  4. Note the Variant IDs — you'll need these in your code

2. Configure Webhooks

Under Settings > Webhooks, create a webhook pointing to your API:

  • URL: https://yoursaas.com/api/webhooks/lemonsqueezy
  • Events: Select all subscription events:
    • subscription_created
    • subscription_updated
    • subscription_cancelled
    • subscription_resumed
    • subscription_expired
    • subscription_paused
    • subscription_unpaused
    • subscription_payment_success
    • subscription_payment_failed

Save the Webhook Secret — you'll use it to verify signatures.

Checkout Integration

Creating a Checkout Session

The cleanest approach is using LemonSqueezy's checkout overlay:

// lib/lemonsqueezy.ts
export function getCheckoutUrl(variantId: string, userId: string, email: string) {
  const params = new URLSearchParams({
    'checkout[custom][user_id]': userId,
    'checkout[email]': email,
    'checkout[success_url]': `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?checkout=success`,
  });

  return `https://your-store.lemonsqueezy.com/checkout/buy/${variantId}?${params}`;
}

Embedding the Checkout Button

'use client';

export function PricingCard({ variantId, price, interval }: {
  variantId: string;
  price: string;
  interval: string;
}) {
  const handleCheckout = () => {
    const url = getCheckoutUrl(variantId, user.id, user.email);
    window.open(url, '_blank');
  };

  return (
    <div className="border rounded-lg p-6">
      <p className="text-3xl font-bold">{price}</p>
      <p className="text-gray-500">/{interval}</p>
      <button
        onClick={handleCheckout}
        className="w-full mt-4 bg-blue-600 text-white py-2 rounded-lg"
      >
        Subscribe
      </button>
    </div>
  );
}

The custom[user_id] parameter is crucial — it links the LemonSqueezy subscription to your user, and it arrives in the webhook payload.

Webhook Handler

This is the core of your payment integration. Every subscription event goes through here.

Signature Verification

// app/api/webhooks/lemonsqueezy/route.ts
import crypto from 'crypto';
import { NextRequest, NextResponse } from 'next/server';

function verifyWebhookSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const hmac = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(hmac),
    Buffer.from(signature)
  );
}

export async function POST(request: NextRequest) {
  const body = await request.text();
  const signature = request.headers.get('x-signature');

  if (!signature || !verifyWebhookSignature(
    body,
    signature,
    process.env.LEMONSQUEEZY_WEBHOOK_SECRET!
  )) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }

  const event = JSON.parse(body);
  // Process the event...
}

Handling Subscription Events

export async function POST(request: NextRequest) {
  // ... signature verification from above

  const event = JSON.parse(body);
  const eventName = event.meta.event_name;
  const subscription = event.data.attributes;
  const userId = event.meta.custom_data?.user_id;

  switch (eventName) {
    case 'subscription_created':
      await handleSubscriptionCreated(userId, subscription);
      break;

    case 'subscription_updated':
      await handleSubscriptionUpdated(userId, subscription);
      break;

    case 'subscription_cancelled':
      await handleSubscriptionCancelled(userId, subscription);
      break;

    case 'subscription_expired':
      await handleSubscriptionExpired(userId, subscription);
      break;

    case 'subscription_payment_failed':
      await handlePaymentFailed(userId, subscription);
      break;

    case 'subscription_payment_success':
      await handlePaymentSuccess(userId, subscription);
      break;
  }

  return NextResponse.json({ received: true });
}

Processing Each Event

async function handleSubscriptionCreated(userId: string, subscription: any) {
  await supabase
    .from('subscriptions')
    .upsert({
      user_id: userId,
      lemonsqueezy_subscription_id: subscription.first_subscription_item?.subscription_id,
      status: subscription.status,
      plan: determinePlan(subscription.variant_id),
      current_period_end: subscription.renews_at,
      created_at: new Date().toISOString(),
    });
}

async function handleSubscriptionCancelled(userId: string, subscription: any) {
  // Don't revoke access immediately — let them use it until period ends
  await supabase
    .from('subscriptions')
    .update({
      status: 'cancelled',
      cancel_at: subscription.ends_at,
    })
    .eq('user_id', userId);
}

async function handleSubscriptionExpired(userId: string, subscription: any) {
  // Now revoke access
  await supabase
    .from('subscriptions')
    .update({
      status: 'expired',
    })
    .eq('user_id', userId);
}

async function handlePaymentFailed(userId: string, subscription: any) {
  // Send dunning email, but don't revoke access yet
  await sendEmail(userId, 'payment-failed', {
    updateUrl: subscription.urls.update_payment_method,
  });
}

Wiring webhooks, plan changes, dunning, and the customer portal cleanly takes most teams 20–30 hours. SaaSInMinutes ships all of it tested and production-ready. $49 one-time. Get instant access →

Subscription Management

Checking Subscription Status

export async function getUserSubscription(userId: string) {
  const { data } = await supabase
    .from('subscriptions')
    .select('*')
    .eq('user_id', userId)
    .in('status', ['active', 'cancelled']) // cancelled still has access until period ends
    .single();

  return data;
}

export function hasActiveSubscription(subscription: any): boolean {
  if (!subscription) return false;
  if (subscription.status === 'active') return true;

  // Cancelled but still within the paid period
  if (subscription.status === 'cancelled' && subscription.cancel_at) {
    return new Date(subscription.cancel_at) > new Date();
  }

  return false;
}

Customer Portal

LemonSqueezy provides a customer portal URL for each subscription where users can update their payment method, change plans, or cancel:

export async function getCustomerPortalUrl(subscriptionId: string): Promise<string> {
  const response = await fetch(
    `https://api.lemonsqueezy.com/v1/subscriptions/${subscriptionId}`,
    {
      headers: {
        Authorization: `Bearer ${process.env.LEMONSQUEEZY_API_KEY}`,
      },
    }
  );

  const data = await response.json();
  return data.data.attributes.urls.customer_portal;
}

Plan Changes

When a user upgrades or downgrades, LemonSqueezy handles proration automatically. The subscription_updated webhook fires with the new variant ID:

async function handleSubscriptionUpdated(userId: string, subscription: any) {
  const newPlan = determinePlan(subscription.variant_id);

  await supabase
    .from('subscriptions')
    .update({
      status: subscription.status,
      plan: newPlan,
      current_period_end: subscription.renews_at,
    })
    .eq('user_id', userId);
}

function determinePlan(variantId: number): string {
  const planMap: Record<number, string> = {
    123456: 'starter',
    123457: 'pro',
    123458: 'enterprise',
  };
  return planMap[variantId] || 'unknown';
}

Handling Edge Cases

Duplicate Webhooks

LemonSqueezy may send the same webhook multiple times. Make your handler idempotent:

// Use upsert instead of insert
await supabase
  .from('subscriptions')
  .upsert(
    { user_id: userId, ...subscriptionData },
    { onConflict: 'user_id' }
  );

Webhook Delivery Failures

If your endpoint is down, LemonSqueezy retries webhooks with exponential backoff. But if your app was down for extended time, you might miss events. Add a periodic sync:

// Run daily via cron to catch missed webhooks
async function syncSubscriptions() {
  const response = await fetch(
    'https://api.lemonsqueezy.com/v1/subscriptions?filter[store_id]=YOUR_STORE_ID',
    {
      headers: {
        Authorization: `Bearer ${process.env.LEMONSQUEEZY_API_KEY}`,
      },
    }
  );

  const { data: subscriptions } = await response.json();

  for (const sub of subscriptions) {
    const userId = sub.attributes.custom_data?.user_id;
    if (userId) {
      await syncSubscriptionToDatabase(userId, sub.attributes);
    }
  }
}

Grace Period for Failed Payments

Don't cut off access immediately when a payment fails. Give users time to fix their payment method:

const GRACE_PERIOD_DAYS = 3;

function shouldRevokeAccess(subscription: any): boolean {
  if (subscription.status !== 'past_due') return false;

  const failedAt = new Date(subscription.updated_at);
  const gracePeriodEnd = new Date(failedAt);
  gracePeriodEnd.setDate(gracePeriodEnd.getDate() + GRACE_PERIOD_DAYS);

  return new Date() > gracePeriodEnd;
}

Testing Payments

LemonSqueezy provides a test mode. Use these test card numbers:

  • Success: 4242 4242 4242 4242
  • Decline: 4000 0000 0000 0002
  • Requires authentication: 4000 0025 0000 3155

Always test:

  1. Successful checkout flow
  2. Webhook delivery and processing
  3. Plan upgrades and downgrades
  4. Cancellation (immediate and end-of-period)
  5. Failed payment and recovery
  6. Duplicate webhook handling

Pre-Built Payment Infrastructure

Reading through this guide gives you the surface area — checkout, webhook signature verification, idempotency, plan changes, dunning, grace periods, the customer portal, and the periodic sync to recover from missed events. Wiring it cleanly the first time takes most teams 20–30 hours, and the edge cases keep showing up for months after.

SaaSInMinutes ships it done. For $49 one-time, you get the complete LemonSqueezy integration — checkout, webhook handling with signature verification, subscription management, plan changes with proration, cancellation flows, failed payment recovery with grace periods, and the customer billing portal — all tested and production-ready. Plus auth, RLS, monitoring, email, and AWS deployment.

You configure your LemonSqueezy variant IDs, and the rest works out of the box.

Get instant access — $49 one-time →

Written by SaaSInMinutes