Lemon Squeezy is a Merchant of Record platform for digital products and SaaS — it handles VAT, taxes, chargebacks, and compliance globally. lemonSqueezySetup({ apiKey }) initializes the SDK. createCheckout(storeId, variantId, { checkoutData: { email, custom: { userId } } }) creates a checkout URL. getSubscription(subscriptionId) retrieves subscription status. updateSubscription(id, { pause: { mode: "void" } }) pauses billing. getCustomerPortalUrl(customerId) returns the self-service portal URL. Webhooks use X-Signature HMAC-SHA256 validation. subscription_created, subscription_updated, and subscription_cancelled power SaaS plan management. License keys: activateLicense(licenseKey, instanceName) and validateLicense(licenseKey). listOrders({ filter: { userEmail } }) retrieves purchases. Claude Code generates Lemon Squeezy checkout flows, subscription webhooks, customer portals, and license activation endpoints.
CLAUDE.md for Lemon Squeezy
## Lemon Squeezy Stack
- Version: @lemonsqueezy/lemonsqueezy.js >= 3.3
- Init: lemonSqueezySetup({ apiKey: process.env.LEMONSQUEEZY_API_KEY! })
- Checkout: const { data } = await createCheckout(STORE_ID, VARIANT_ID, { checkoutData: { email, custom: { userId } } })
- URL: data.data.attributes.url — redirect user here
- Webhook: validateEvent(rawBody, headers, secret) — verify X-Signature header
- Event types: "subscription_created" | "subscription_updated" | "subscription_cancelled" | "order_created"
- Portal: const { data } = await getCustomerPortalUrl(customerId) — returns URL
Lemon Squeezy Client
// lib/billing/lemonsqueezy.ts — Lemon Squeezy utilities
import {
lemonSqueezySetup,
createCheckout,
getSubscription,
updateSubscription,
listSubscriptions,
activateLicense,
validateLicense,
deactivateLicense,
} from "@lemonsqueezy/lemonsqueezy.js"
import crypto from "crypto"
// Initialize once
lemonSqueezySetup({
apiKey: process.env.LEMONSQUEEZY_API_KEY!,
onError: (error) => console.error("[Lemon Squeezy]", error),
})
const STORE_ID = process.env.LEMONSQUEEZY_STORE_ID!
// Plan variant IDs
const PLAN_VARIANTS: Record<string, string> = {
pro_monthly: process.env.LS_PRO_MONTHLY_VARIANT!,
pro_yearly: process.env.LS_PRO_YEARLY_VARIANT!,
enterprise_monthly: process.env.LS_ENTERPRISE_MONTHLY_VARIANT!,
}
// ── Checkout ───────────────────────────────────────────────────────────────
export async function createLSCheckout(params: {
userId: string
email: string
name?: string
plan: string
interval: "monthly" | "yearly"
redirectUrl?: string
}): Promise<string> {
const variantId = PLAN_VARIANTS[`${params.plan}_${params.interval}`]
if (!variantId) throw new Error(`Unknown plan variant: ${params.plan}_${params.interval}`)
const { data, error } = await createCheckout(STORE_ID, variantId, {
checkoutOptions: {
embed: false,
media: false,
logo: true,
},
checkoutData: {
email: params.email,
name: params.name,
custom: {
userId: params.userId,
},
},
productOptions: {
redirectUrl: params.redirectUrl ?? `${process.env.APP_URL}/dashboard?welcome=1`,
receiptButtonText: "Go to Dashboard",
receiptThankYouNote: "Thank you for subscribing! Your account has been upgraded.",
},
expiresAt: null, // No expiry
})
if (error || !data) throw new Error(error?.message ?? "Failed to create checkout")
return data.data.attributes.url
}
// ── Subscription management ────────────────────────────────────────────────
export async function pauseSubscription(subscriptionId: string): Promise<void> {
const { error } = await updateSubscription(subscriptionId, {
pause: { mode: "void" }, // Stops billing, keeps subscription active
})
if (error) throw new Error(error.message)
}
export async function resumeSubscription(subscriptionId: string): Promise<void> {
const { error } = await updateSubscription(subscriptionId, {
pause: null,
})
if (error) throw new Error(error.message)
}
export async function cancelSubscriptionAtPeriodEnd(subscriptionId: string): Promise<void> {
// Cancel at end of billing period
const { error } = await updateSubscription(subscriptionId, {
cancelled: true,
})
if (error) throw new Error(error.message)
}
export async function switchSubscriptionPlan(
subscriptionId: string,
newVariantId: string,
): Promise<void> {
const { error } = await updateSubscription(subscriptionId, {
variantId: parseInt(newVariantId),
})
if (error) throw new Error(error.message)
}
// ── Webhook validation ─────────────────────────────────────────────────────
export function verifyLSWebhook(rawBody: string, signature: string): boolean {
const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET!
const hmac = crypto.createHmac("sha256", secret)
hmac.update(rawBody)
const expected = hmac.digest("hex")
try {
return crypto.timingSafeEqual(
Buffer.from(signature, "hex"),
Buffer.from(expected, "hex"),
)
} catch {
return false
}
}
// ── License keys ───────────────────────────────────────────────────────────
export async function activateLicenseKey(
licenseKey: string,
instanceName: string,
): Promise<{ valid: boolean; productId: string | null; instanceId: string | null }> {
const { data, error } = await activateLicense(licenseKey, instanceName)
if (error || !data) {
return { valid: false, productId: null, instanceId: null }
}
return {
valid: data.activated,
productId: String(data.meta.product_id),
instanceId: data.instance?.id ?? null,
}
}
Webhook Handler
// app/api/webhooks/lemonsqueezy/route.ts — subscription lifecycle
import { NextRequest, NextResponse } from "next/server"
import { verifyLSWebhook } from "@/lib/billing/lemonsqueezy"
import { db } from "@/lib/db"
import { users, subscriptions } from "@/lib/db/schema"
import { eq } from "drizzle-orm"
type LSSubscriptionEvent = {
meta: {
event_name: string
custom_data?: { userId?: string }
webhook_id: string
}
data: {
id: string
type: "subscriptions"
attributes: {
status: "on_trial" | "active" | "paused" | "past_due" | "unpaid" | "cancelled" | "expired"
customer_id: number
order_id: number
product_id: number
variant_id: number
user_email: string
trial_ends_at: string | null
renews_at: string | null
ends_at: string | null
created_at: string
}
}
}
const PLAN_BY_VARIANT: Record<string, "pro" | "enterprise"> = {
[process.env.LS_PRO_MONTHLY_VARIANT!]: "pro",
[process.env.LS_PRO_YEARLY_VARIANT!]: "pro",
[process.env.LS_ENTERPRISE_MONTHLY_VARIANT!]: "enterprise",
}
export async function POST(request: NextRequest) {
const rawBody = await request.text()
const signature = request.headers.get("x-signature") ?? ""
if (!verifyLSWebhook(rawBody, signature)) {
return new Response("Invalid signature", { status: 403 })
}
const payload: LSSubscriptionEvent = JSON.parse(rawBody)
const { event_name, custom_data, webhook_id } = payload.meta
// Idempotency
const processed = await db.query.lsWebhookEvents.findFirst({
where: eq(lsWebhookEvents.webhookId, webhook_id),
})
if (processed) return NextResponse.json({ ok: true })
const attrs = payload.data.attributes
const userId = custom_data?.userId
if (!userId) {
console.warn("[LS webhook] No userId in custom_data for event", event_name)
return NextResponse.json({ ok: true })
}
const plan = PLAN_BY_VARIANT[String(attrs.variant_id)] ?? "free"
const activeStatuses = ["active", "on_trial", "past_due"]
try {
switch (event_name) {
case "subscription_created":
case "subscription_updated":
case "subscription_resumed": {
await db.insert(subscriptions).values({
userId,
lsSubscriptionId: payload.data.id,
lsCustomerId: String(attrs.customer_id),
lsVariantId: String(attrs.variant_id),
status: attrs.status,
plan,
trialEndsAt: attrs.trial_ends_at ? new Date(attrs.trial_ends_at) : null,
renewsAt: attrs.renews_at ? new Date(attrs.renews_at) : null,
endsAt: attrs.ends_at ? new Date(attrs.ends_at) : null,
}).onConflictDoUpdate({
target: subscriptions.lsSubscriptionId,
set: {
status: attrs.status,
plan,
trialEndsAt: attrs.trial_ends_at ? new Date(attrs.trial_ends_at) : null,
renewsAt: attrs.renews_at ? new Date(attrs.renews_at) : null,
endsAt: attrs.ends_at ? new Date(attrs.ends_at) : null,
},
})
await db.update(users)
.set({ plan: activeStatuses.includes(attrs.status) ? plan : "free" })
.where(eq(users.id, userId))
break
}
case "subscription_cancelled":
case "subscription_expired": {
await db.update(subscriptions)
.set({ status: attrs.status, endsAt: attrs.ends_at ? new Date(attrs.ends_at) : null })
.where(eq(subscriptions.lsSubscriptionId, payload.data.id))
if (attrs.status === "expired") {
await db.update(users).set({ plan: "free" }).where(eq(users.id, userId))
}
break
}
}
await db.insert(lsWebhookEvents).values({ webhookId: webhook_id }).onConflictDoNothing()
return NextResponse.json({ ok: true })
} catch (err) {
console.error("[LS webhook error]", err)
return NextResponse.json({ error: "Handler error" }, { status: 500 })
}
}
Customer Portal Route
// app/api/billing/portal/route.ts — generate customer portal URL
import { NextRequest, NextResponse } from "next/server"
import { getSubscription } from "@lemonsqueezy/lemonsqueezy.js"
import { auth } from "@clerk/nextjs/server"
import { db } from "@/lib/db"
import { subscriptions } from "@/lib/db/schema"
import { eq } from "drizzle-orm"
export async function GET(request: NextRequest) {
const { userId } = await auth()
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const sub = await db.query.subscriptions.findFirst({
where: eq(subscriptions.userId, userId),
orderBy: (s, { desc }) => [desc(s.createdAt)],
})
if (!sub?.lsSubscriptionId) {
return NextResponse.json({ error: "No active subscription" }, { status: 404 })
}
const { data, error } = await getSubscription(sub.lsSubscriptionId)
if (error || !data) {
return NextResponse.json({ error: "Failed to fetch subscription" }, { status: 500 })
}
const portalUrl = data.data.attributes.urls?.customer_portal
if (!portalUrl) {
return NextResponse.json({ error: "Portal not available" }, { status: 404 })
}
return NextResponse.json({ url: portalUrl })
}
For the Stripe alternative when more granular billing control, complex subscription items, metered usage billing, multiple payment methods beyond cards, and direct bank transfers are required — Stripe is more flexible and has more billing primitives than Lemon Squeezy, but requires you to handle tax compliance yourself, see the Stripe Subscriptions guide. For the Paddle alternative when similar Merchant of Record coverage with a stronger focus on optimized checkout conversion in specific regions and B2B invoicing features is needed — Paddle and Lemon Squeezy are both MoR solutions with similar positioning; Paddle has more enterprise features while Lemon Squeezy is simpler for indie developers, see the Paddle guide. The Claude Skills 360 bundle includes Lemon Squeezy skill sets covering checkout, subscriptions, and webhooks. Start with the free tier to try MoR payment generation.