Skip to main content

How to Add Subscription Billing with Lemon Squeezy

·APIScout Team
lemon squeezysubscription billingpaymentstutorialapi integration

How to Add Subscription Billing with Lemon Squeezy

Lemon Squeezy is a merchant of record — they handle taxes, compliance, and payment processing globally. You don't need a Stripe Tax setup, no VAT registration, no sales tax headaches. Just create a product, embed checkout, and get paid.

What You'll Build

  • Subscription checkout (monthly/yearly)
  • Webhook handling for billing events
  • Customer portal (manage subscription)
  • License key validation
  • Usage-based billing

Prerequisites: Next.js 14+, Lemon Squeezy account (free to create, 5% + $0.50 per transaction).

1. Setup

Install

npm install @lemonsqueezy/lemonsqueezy.js

Initialize

// lib/lemonsqueezy.ts
import { lemonSqueezySetup } from '@lemonsqueezy/lemonsqueezy.js';

lemonSqueezySetup({
  apiKey: process.env.LEMONSQUEEZY_API_KEY!,
});

Environment Variables

LEMONSQUEEZY_API_KEY=your_api_key
LEMONSQUEEZY_STORE_ID=your_store_id
LEMONSQUEEZY_WEBHOOK_SECRET=your_webhook_secret
NEXT_PUBLIC_LEMON_SQUEEZY_STORE_URL=https://yourstore.lemonsqueezy.com

2. Create Products

In Lemon Squeezy Dashboard:

  1. Products → New Product
  2. Name: "Pro Plan"
  3. Add variants:
    • Monthly: $29/month
    • Yearly: $290/year (save $58)
  4. Each variant gets a unique Variant ID

3. Checkout

Create Checkout Session

// app/api/checkout/route.ts
import { NextResponse } from 'next/server';
import { createCheckout } from '@lemonsqueezy/lemonsqueezy.js';

export async function POST(req: Request) {
  const { variantId, userId, email } = await req.json();

  const checkout = await createCheckout(
    process.env.LEMONSQUEEZY_STORE_ID!,
    variantId,
    {
      checkoutData: {
        email,
        custom: {
          user_id: userId, // Link to your user
        },
      },
      productOptions: {
        redirectUrl: `${process.env.NEXT_PUBLIC_URL}/dashboard?checkout=success`,
      },
    }
  );

  return NextResponse.json({
    checkoutUrl: checkout.data?.data.attributes.url,
  });
}

Checkout Button

'use client';

export function SubscribeButton({ variantId, label }: {
  variantId: string;
  label: string;
}) {
  const handleCheckout = async () => {
    const res = await fetch('/api/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        variantId,
        userId: 'current-user-id',
        email: 'user@example.com',
      }),
    });
    const { checkoutUrl } = await res.json();
    window.location.href = checkoutUrl;
  };

  return <button onClick={handleCheckout}>{label}</button>;
}

Overlay Checkout (No Redirect)

'use client';
import { useEffect } from 'react';

declare global {
  interface Window { createLemonSqueezy: () => void; LemonSqueezy: any; }
}

export function LemonSqueezyOverlay({ variantId }: { variantId: string }) {
  useEffect(() => {
    // Load Lemon Squeezy script
    const script = document.createElement('script');
    script.src = 'https://app.lemonsqueezy.com/js/lemon.js';
    script.onload = () => window.createLemonSqueezy();
    document.head.appendChild(script);
  }, []);

  return (
    <a
      href={`https://yourstore.lemonsqueezy.com/checkout/buy/${variantId}?embed=1`}
      className="lemonsqueezy-button"
    >
      Subscribe to Pro
    </a>
  );
}

4. Webhooks

Set Up Webhook

In Lemon Squeezy Dashboard → Settings → Webhooks:

  • URL: https://your-app.com/api/webhooks/lemonsqueezy
  • Secret: Generate and save to env
  • Events: All subscription events

Handle Events

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

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

  // Verify webhook signature
  const hmac = crypto.createHmac('sha256', process.env.LEMONSQUEEZY_WEBHOOK_SECRET!);
  const digest = hmac.update(body).digest('hex');

  if (digest !== signature) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }

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

  switch (eventName) {
    case 'subscription_created':
      await activateSubscription(userId, {
        subscriptionId: event.data.id,
        plan: data.variant_name,
        status: data.status,
        currentPeriodEnd: data.renews_at,
      });
      break;

    case 'subscription_updated':
      await updateSubscription(userId, {
        status: data.status,
        plan: data.variant_name,
        currentPeriodEnd: data.renews_at,
      });
      break;

    case 'subscription_cancelled':
      // Still active until period ends
      await markCancelled(userId, {
        endsAt: data.ends_at,
      });
      break;

    case 'subscription_expired':
      await deactivateSubscription(userId);
      break;

    case 'subscription_payment_success':
      await recordPayment(userId, {
        amount: data.total,
        currency: data.currency,
      });
      break;

    case 'subscription_payment_failed':
      await handlePaymentFailure(userId);
      break;
  }

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

5. Customer Portal

Let customers manage their own subscription:

// app/api/portal/route.ts
import { NextResponse } from 'next/server';
import { getSubscription } from '@lemonsqueezy/lemonsqueezy.js';

export async function POST(req: Request) {
  const { subscriptionId } = await req.json();

  const sub = await getSubscription(subscriptionId);
  const portalUrl = sub.data?.data.attributes.urls.customer_portal;

  return NextResponse.json({ portalUrl });
}

The portal lets customers:

  • Update payment method
  • Switch plans (upgrade/downgrade)
  • Cancel subscription
  • View invoice history

6. License Keys

For software distribution or API access:

// Validate a license key
import { validateLicense } from '@lemonsqueezy/lemonsqueezy.js';

export async function checkLicense(licenseKey: string) {
  const result = await validateLicense(licenseKey);

  return {
    valid: result.data?.valid ?? false,
    status: result.data?.license_key.status, // 'active', 'inactive', 'expired', 'disabled'
    customerEmail: result.data?.meta.customer_email,
    productName: result.data?.meta.product_name,
    variant: result.data?.meta.variant_name,
  };
}

Pricing (Lemon Squeezy Fees)

ItemFee
Transaction fee5% + $0.50
Payout fee$0 (free)
Monthly fee$0
Tax handlingIncluded (they handle VAT/sales tax)
Chargebacks$15 per chargeback

Compare: Stripe charges 2.9% + $0.30 but you handle taxes yourself. After adding Stripe Tax ($0.50/transaction) and tax compliance costs, Lemon Squeezy's all-in-one pricing is competitive.

Lemon Squeezy vs Stripe

FeatureLemon SqueezyStripe
Merchant of Record✅ (handles taxes)❌ (you're the MoR)
Global tax compliance✅ Included❌ Extra setup + Stripe Tax
Transaction fee5% + $0.502.9% + $0.30 + tax costs
Checkout UI✅ Hosted (customizable)✅ Hosted or embedded
Customer portal✅ Built-in✅ Built-in
License keys✅ Built-in❌ Third-party needed
Developer experienceGoodExcellent

Common Mistakes

MistakeImpactFix
Not verifying webhook signaturesSecurity vulnerabilityAlways verify HMAC signature
Deactivating on subscription_cancelledUser loses access before period endsDeactivate on subscription_expired instead
Not storing custom_dataCan't link subscription to userPass user_id in checkout custom data
Ignoring subscription_payment_failedUsers stay active despite failed paymentsHandle grace period and notify user
Not testing with test modeAccidental real chargesUse test mode until ready for production

Choosing a billing provider? Compare Lemon Squeezy vs Stripe vs Paddle on APIScout — merchant of record vs direct payments.

Comments