Claude Code for Lemon Squeezy: Merchant of Record Payments — Claude Skills 360 Blog
Blog / Backend / Claude Code for Lemon Squeezy: Merchant of Record Payments
Backend

Claude Code for Lemon Squeezy: Merchant of Record Payments

Published: May 17, 2027
Read time: 6 min read
By: Claude Skills 360

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.

Keep Reading

Backend

Claude Code for Bun: Fast JavaScript Runtime and Toolkit

Build with Bun and Claude Code — Bun.serve for HTTP servers, Bun.file for fast file I/O, Bun.$ for shell commands, Bun.sql for SQLite and PostgreSQL, Bun.build for bundling, bun:test for testing, Bun.hash for hashing, bun.lock for deterministic installs, bun run for package.json scripts, hot reloading with --hot, bun init for project scaffolding, and compatibility with Node.js modules.

6 min read Jun 13, 2027
Backend

Claude Code for Express.js Advanced: Patterns for Production APIs

Advanced Express.js patterns with Claude Code — typed request handlers with RequestHandler generics, async error handling middleware, Zod validation middleware factory, rate limiting with express-rate-limit and Redis store, helmet security middleware, compression, dependency injection with tsyringe, file upload with multer and S3, pagination utilities, JWT middleware, and structured logging with pino.

6 min read Jun 8, 2027
Backend

Claude Code for KeystoneJS: Node.js CMS and App Framework

Build full-stack apps with KeystoneJS and Claude Code — config with lists, fields.text and fields.relationship for schema definition, access control with isAuthenticated and isAdmin functions, hooks with beforeOperation and afterOperation, GraphQL API auto-generation from schema, AdminUI for content management, session with statelessSessions, Prisma adapter for database, file storage with images and files fields, and custom REST endpoints.

6 min read Jun 7, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free