Skip to main content

Subscription Billing with Lemon Squeezy 2026

·APIScout Team
Share:

Subscription Billing with Lemon Squeezy 2026

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.

TL;DR

  • Lemon Squeezy acts as the Merchant of Record: they collect tax, file VAT returns in the EU, handle sales tax in US states, and deal with chargebacks — you receive payouts minus their fee
  • The fee (5% + $0.50 per transaction) is higher than Stripe's 2.9% + $0.30, but Stripe requires you to handle tax compliance yourself, which has real operational cost
  • Pass user_id in checkout custom data — this is how you link subscription events back to your user in webhook handlers
  • Deactivate subscriptions on subscription_expired, not subscription_cancelled — users remain active during their paid period after cancellation
  • License keys are built in, no third-party tool needed — this is a significant advantage over Stripe for software distribution

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.

Why Lemon Squeezy for SaaS

The Merchant of Record (MoR) model is the core value proposition that makes Lemon Squeezy worth its higher transaction fee. Understanding what MoR actually means makes the pricing tradeoff clear.

When you sell software directly via Stripe, your company is the merchant of record. You are legally responsible for collecting the correct tax on every transaction — sales tax in the US (which varies by state and sometimes by product type), VAT in the EU (which varies by country for digital services), GST in Australia and Canada, and dozens of other jurisdictions. As of 2026, EU VAT rules require registering for the One Stop Shop (OSS) scheme and filing quarterly returns across all EU member states. US economic nexus rules require registering for sales tax collection once you exceed thresholds in each state. This is a real compliance burden that requires either a dedicated person or an external service.

Lemon Squeezy eliminates this entirely. They are the merchant of record. They collect tax on your behalf, file the returns, and remit to tax authorities globally. You receive payouts minus their fee, with no tax exposure. For a solo founder or small team, this is not just a convenience — it's the difference between legally compliant and potentially non-compliant operation in markets you might not even know you're selling into.

The comparison to Paddle is useful because Paddle also uses the MoR model. Paddle targets larger companies (minimum MRR requirements, higher fees in some tiers). Lemon Squeezy is accessible to early-stage products with no minimum volume requirements. Paddle's dashboard and developer experience are more mature; Lemon Squeezy's are improving but still behind Paddle in some areas.

The comparison to Stripe: Stripe is the right choice when you need maximum payment flexibility (complex pricing models, platform payouts, financial services), when you have the compliance infrastructure to handle taxes, or when you're at a scale where the fee difference matters — at $100K MRR, the 2.1% fee difference between Stripe (2.9%) and Lemon Squeezy (5%) is $2,100/month. At $1K MRR, it's $21/month and the compliance savings outweigh it. See our guide on choosing a payment API for a full comparison.

Checkout and Pricing Configuration

Lemon Squeezy's pricing configuration happens primarily in the dashboard rather than code. Products represent what you're selling (Pro Plan, Enterprise Plan). Variants represent the pricing tiers within a product (monthly, annual, per-seat options).

Trial periods are configured per-variant in the dashboard — set a free trial duration and Lemon Squeezy handles the trial period, sends reminders before the trial ends, and converts to a paid subscription automatically. No code changes needed.

The monthly vs annual pricing toggle is a common SaaS pattern. Create two variants (Monthly and Annual) under the same product, then in your pricing page UI, pass the appropriate variantId based on which billing period the user selects:

const PLANS = {
  pro: {
    monthly: {
      variantId: process.env.NEXT_PUBLIC_LS_PRO_MONTHLY_VARIANT_ID!,
      price: 29,
      period: 'month',
    },
    annual: {
      variantId: process.env.NEXT_PUBLIC_LS_PRO_ANNUAL_VARIANT_ID!,
      price: 290,
      period: 'year',
    },
  },
};

Custom checkout fields let you collect additional data at purchase time — company name, use case, how they heard about you. Configure these in the product's Checkout tab in the dashboard. The data appears in the order_created webhook payload under checkout_data.custom.

Checkout overlays (shown in section 3) keep users on your page rather than redirecting to Lemon Squeezy's hosted checkout. This generally converts better because users don't feel like they're leaving your product. The overlay approach requires loading the Lemon Squeezy JavaScript embed, which adds a small JS dependency. Redirect-to-checkout is simpler to implement and is the right choice for early iterations.

Webhook Implementation

The webhook handler in section 4 shows the core implementation, but several details deserve deeper explanation.

Signature verification uses HMAC-SHA256 with the webhook secret you set in the Lemon Squeezy dashboard. The signature comparison using === in the basic implementation is vulnerable to timing attacks. For production, use constant-time comparison:

import { timingSafeEqual } from 'crypto';

function verifySignature(body: string, signature: string, secret: string): boolean {
  const hmac = crypto.createHmac('sha256', secret);
  const digest = hmac.update(body).digest('hex');
  const sigBuffer = Buffer.from(signature);
  const digestBuffer = Buffer.from(digest);
  if (sigBuffer.length !== digestBuffer.length) return false;
  return timingSafeEqual(sigBuffer, digestBuffer);
}

For a full treatment of webhook signature verification, see our webhooks guide.

The subscription state machine needs to handle these events in order:

  1. subscription_created — user just subscribed, activate their access
  2. subscription_updated — plan changed (upgrade/downgrade) or renewal processed
  3. subscription_payment_failed — payment failed, start grace period (typically 3 days)
  4. subscription_cancelled — user cancelled, mark as cancelled but keep active
  5. subscription_expired — billing period ended, revoke access
  6. subscription_payment_success — payment recovered after failure, extend access

The most important state transition mistake is deactivating access on subscription_cancelled. When a user cancels, they've already paid for their current billing period and are entitled to access until it ends. Only deactivate on subscription_expired, which fires when the actual paid period is over. The ends_at field on the cancellation event tells you exactly when access should end — you can use this to show users a countdown or confirmation message.

Customer Portal Integration

Lemon Squeezy's hosted customer portal handles all self-service subscription management: updating payment methods (new card when the old one expires), switching between monthly and annual plans, cancelling subscriptions, and viewing invoice history. Generating the portal URL is a one-line API call.

The important UX detail: the portal URL expires quickly. Generate it on-demand when the user clicks "Manage Subscription" — don't generate it at page load and cache it. The pattern is a button click → API call → redirect to portal URL.

// components/ManageSubscriptionButton.tsx
'use client';

export function ManageSubscriptionButton({ subscriptionId }: { subscriptionId: string }) {
  const handleClick = async () => {
    const res = await fetch('/api/portal', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ subscriptionId }),
    });
    const { portalUrl } = await res.json();
    window.location.href = portalUrl;
  };

  return (
    <button onClick={handleClick}>
      Manage Subscription
    </button>
  );
}

When a user upgrades or downgrades through the portal, you receive subscription_updated webhooks with the new plan information. Process these webhooks to update the user's access level in your database. Immediate plan upgrades are prorated — Lemon Squeezy charges the difference for the remainder of the current billing period. Downgrades typically take effect at the next renewal. The subscription_updated event includes the effective date.

License Key Management

License keys are Lemon Squeezy's built-in feature for software that needs to be activated on specific machines — desktop apps, CLI tools, VS Code extensions, plugins. This is a significant advantage over Stripe, which has no license key functionality and requires a third-party service.

Configure license key generation per-variant in the product settings. Each purchase generates one or more license keys (configurable). The activation limit controls how many times each key can be activated — a "single machine" license has an activation limit of 1, a "team" license might allow 5 activations.

Machine fingerprinting is your application's responsibility. When activating a license, include a machine identifier in the activation request so you can track which machines have activated a given key:

import { activateLicense } from '@lemonsqueezy/lemonsqueezy.js';

async function activateLicenseOnMachine(
  licenseKey: string,
  machineId: string  // Generate a stable ID for this machine
) {
  const result = await activateLicense(licenseKey, machineId);

  if (!result.data?.activated) {
    if (result.data?.license_key.activation_limit <= result.data?.license_key.activation_usage) {
      throw new Error('License key activation limit reached');
    }
    throw new Error('License activation failed');
  }

  return {
    instanceId: result.data.instance.id,
    expiresAt: result.data.license_key.expires_at,
  };
}

For offline license validation (applications that can't make API calls), Lemon Squeezy doesn't natively support this pattern. Offline validation requires a different architecture — typically signing a license file at activation time with your private key and validating the signature locally. If offline support is critical, consider whether Lemon Squeezy is the right billing provider, or implement a separate activation service that issues signed license tokens after Lemon Squeezy validation.

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
String equality for signature verificationTiming attack vulnerabilityUse timingSafeEqual for comparison

Choosing a billing provider? Explore payment and billing APIs on APIScout. Also see our guides on API webhooks for reliable webhook handling and how to choose a payment API for a full payment provider comparison.

The API Integration Checklist (Free PDF)

Step-by-step checklist: auth setup, rate limit handling, error codes, SDK evaluation, and pricing comparison for 50+ APIs. Used by 200+ developers.

Join 200+ developers. Unsubscribe in one click.