Subscription Billing with Lemon Squeezy 2026
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_idin checkout custom data — this is how you link subscription events back to your user in webhook handlers - Deactivate subscriptions on
subscription_expired, notsubscription_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:
- Products → New Product
- Name: "Pro Plan"
- Add variants:
- Monthly: $29/month
- Yearly: $290/year (save $58)
- 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)
| Item | Fee |
|---|---|
| Transaction fee | 5% + $0.50 |
| Payout fee | $0 (free) |
| Monthly fee | $0 |
| Tax handling | Included (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:
subscription_created— user just subscribed, activate their accesssubscription_updated— plan changed (upgrade/downgrade) or renewal processedsubscription_payment_failed— payment failed, start grace period (typically 3 days)subscription_cancelled— user cancelled, mark as cancelled but keep activesubscription_expired— billing period ended, revoke accesssubscription_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
| Feature | Lemon Squeezy | Stripe |
|---|---|---|
| Merchant of Record | ✅ (handles taxes) | ❌ (you're the MoR) |
| Global tax compliance | ✅ Included | ❌ Extra setup + Stripe Tax |
| Transaction fee | 5% + $0.50 | 2.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 experience | Good | Excellent |
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
| Not verifying webhook signatures | Security vulnerability | Always verify HMAC signature |
Deactivating on subscription_cancelled | User loses access before period ends | Deactivate on subscription_expired instead |
Not storing custom_data | Can't link subscription to user | Pass user_id in checkout custom data |
Ignoring subscription_payment_failed | Users stay active despite failed payments | Handle grace period and notify user |
| Not testing with test mode | Accidental real charges | Use test mode until ready for production |
| String equality for signature verification | Timing attack vulnerability | Use 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.