Claude Code with Lemon Squeezy: MoR Done Right
Why Lemon Squeezy without CLAUDE.md generates checkouts that silently lose orders
Lemon Squeezy is the merchant-of-record platform a lot of independent SaaS builders moved to after the VAT compliance work for Stripe got too heavy. The pitch is real: Lemon Squeezy handles sales tax, VAT, and reverse charge in 100+ jurisdictions, and you keep building the product. The API is clean, the documentation is thorough, and the webhook event model is consistent.
The problem is that Claude Code, without explicit guidance, generates Lemon Squeezy integrations that look correct but fail in subtle and expensive ways. The most common Claude defaults: writing field names in camelCase when the API uses snake_case, forgetting that the API follows the JSON:API specification (so payloads need to be wrapped in data.attributes), missing the test_mode flag during development (so test purchases hit your production order log), not verifying webhook signatures, treating webhook events as exactly-once when they are at-least-once, and using the wrong HTTP status code on the webhook handler (Lemon Squeezy retries non-200 responses, so returning 500 on an error you have already logged means the webhook fires again).
This guide covers the CLAUDE.md configuration that locks Claude Code into Lemon Squeezy's actual API contract: JSON:API request shapes, snake_case throughout, signature verification on every webhook, idempotent event handlers, and the specific patterns for license key activation that subscription products need. For the alternative payment processor path, Claude Code with Stripe covers the differences and when each platform makes sense. For the broader API integration pattern, Claude Code with Resend shows the same approach applied to transactional email.
The Lemon Squeezy CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Lemon Squeezy integration it needs to declare: the API version, the authentication method, the JSON:API request shape, the snake_case naming convention, the webhook signature verification pattern, the idempotency strategy, and the hard rules that block the mistakes Claude makes most often.
# Lemon Squeezy payments rules
## Stack
- Lemon Squeezy API v1 (JSON:API specification)
- @lemonsqueezy/lemonsqueezy.js ^4.x (official SDK)
- Node.js 20+, TypeScript 5.x strict
- LEMONSQUEEZY_API_KEY in .env.local (test key for dev, live key for prod)
- LEMONSQUEEZY_WEBHOOK_SECRET in .env.local (different from API key)
- LEMONSQUEEZY_STORE_ID in .env.local (numeric ID from store settings)
## Project structure
- src/lib/lemonsqueezy.ts , SDK client singleton
- src/app/api/checkout/ , Next.js checkout creation route
- src/app/api/webhooks/ls/ , webhook handler
- src/lib/licenses.ts , license key activation logic
- src/db/orders.ts , idempotent order persistence
## API conventions (CRITICAL)
- ALL field names are snake_case: store_id, product_id, variant_id, custom_price
- ALL request bodies follow JSON:API: { data: { type, attributes, relationships } }
- ALL responses follow JSON:API: { data: { id, type, attributes, relationships } }
- NEVER write field names in camelCase, even though TypeScript convention prefers it
- The SDK wraps JSON:API formatting, but raw fetch calls MUST format manually
## Authentication
- API key via Authorization: Bearer <key> header
- Content-Type: application/vnd.api+json (NOT application/json)
- Accept: application/vnd.api+json
## Checkout creation
- POST /v1/checkouts with data.type = 'checkouts'
- Include store_id and variant_id in relationships, not in attributes
- Use checkout_data.custom for metadata (NOT a top-level field)
- ALWAYS set test_mode: true in development, false in production
- Use product_options.redirect_url for post-purchase redirect
## Webhook handling (MANDATORY)
- Verify signature with HMAC-SHA256 using LEMONSQUEEZY_WEBHOOK_SECRET
- Header: X-Signature
- Compare with timingSafeEqual, NEVER plain string equality
- Read raw request body for signature, NOT the parsed JSON
- Return 200 for valid events you process AND valid events you ignore
- Return 400 ONLY for invalid signature
- Return 500 ONLY for genuine server errors that need retry
- ALWAYS log the event ID for idempotency lookup
## Idempotency
- Every webhook event has a unique meta.event_id
- ALWAYS check the database for existing event_id before processing
- If event_id already processed: return 200, do NOT process again
- Lemon Squeezy retries on non-200 responses, so duplicates WILL arrive
## Hard rules
- NEVER hardcode LEMONSQUEEZY_API_KEY
- NEVER skip signature verification on webhook handlers
- NEVER use application/json Content-Type, MUST be application/vnd.api+json
- NEVER process webhook events without idempotency check
- NEVER return non-200 from a webhook handler unless you want a retry
- ALWAYS use test_mode: true in non-production environments
- ALWAYS validate that store_id matches your store before processing webhooks
Three rules here prevent the majority of production failures Claude generates without them.
The JSON:API rule is the biggest API-level constraint. Lemon Squeezy is one of the few modern APIs that strictly follows the JSON:API specification. Every request body needs { data: { type: 'checkouts', attributes: { ... }, relationships: { ... } } }. Claude defaults to { store_id, variant_id, custom_price } because that is how most REST APIs work. Lemon Squeezy rejects this with a 422 Unprocessable Entity error. The CLAUDE.md rule forces Claude to wrap every payload correctly.
The signature verification rule prevents the most common security mistake. Without verification, anyone can POST to your webhook endpoint and trigger your order-fulfilment logic. Claude omits verification because the Lemon Squeezy webhook payload looks like trusted data when you read the docs in isolation. The rule combined with a concrete code example produces correct signature verification every time.
The idempotency rule prevents duplicate orders. Lemon Squeezy retries webhook deliveries on 5xx responses and on network timeouts. The same order_created event can fire two or three times. Without an idempotency check, the second delivery creates a duplicate order, sends a duplicate confirmation email, and (worst case) issues a duplicate license key. The meta.event_id field is stable across retries, so storing it as a primary key in your processed_events table is sufficient.
Install and API key setup
Install the Lemon Squeezy SDK:
npm i @lemonsqueezy/lemonsqueezy.js
Get your API key from the dashboard at app.lemonsqueezy.com/settings/api. Lemon Squeezy issues a single key with scoped permissions. For local development, use a test mode key (works with the test_mode flag and never charges real money). For production, use a live key.
Add the credentials to .env.local:
LEMONSQUEEZY_API_KEY=your_api_key_here
LEMONSQUEEZY_WEBHOOK_SECRET=your_webhook_secret_here
LEMONSQUEEZY_STORE_ID=123456
Create the singleton SDK client:
// src/lib/lemonsqueezy.ts
import { lemonSqueezySetup } from '@lemonsqueezy/lemonsqueezy.js';
if (!process.env.LEMONSQUEEZY_API_KEY) {
throw new Error('LEMONSQUEEZY_API_KEY is not defined');
}
lemonSqueezySetup({
apiKey: process.env.LEMONSQUEEZY_API_KEY,
onError: (error) => console.error('[LemonSqueezy SDK]', error),
});
The SDK uses a global setup function rather than a class instance. Importing this file once at server startup (for example from src/middleware.ts in Next.js, or from the entry point in Express) registers the credentials for every subsequent SDK call. Claude often misses this and tries to instantiate a new client per request, which fails because the SDK methods are top-level functions, not instance methods.
Add the SDK setup pattern to CLAUDE.md:
## SDK setup pattern
- lemonSqueezySetup({ apiKey }) is called ONCE at server startup
- ALL SDK functions are imported as top-level functions: createCheckout, getOrder, etc.
- NEVER instantiate a new client per request
- The setup file must run before the first API call (import it from server entry)
- onError callback receives non-thrown errors, log them with context
Creating checkouts
A checkout in Lemon Squeezy represents one purchase session. You create one server-side, get a URL back, and redirect the customer to it. The checkout URL is hosted on Lemon Squeezy, handles the payment form, runs the tax calculation, and posts back to your webhook on completion.
Creating a checkout in TypeScript:
// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createCheckout } from '@lemonsqueezy/lemonsqueezy.js';
import '@/lib/lemonsqueezy'; // ensure setup ran
export async function POST(req: NextRequest) {
const { variantId, email, name, userId } = await req.json();
const storeId = process.env.LEMONSQUEEZY_STORE_ID;
if (!storeId) {
return NextResponse.json({ error: 'Store ID not configured' }, { status: 500 });
}
const { data, error } = await createCheckout(Number(storeId), Number(variantId), {
checkoutOptions: {
embed: false,
media: false,
logo: true,
},
checkoutData: {
email,
name,
custom: {
user_id: userId,
source: 'web_app',
},
},
productOptions: {
redirectUrl: 'https://claudify.tech/welcome?session={checkout_id}',
receiptButtonText: 'Open Claudify',
receiptLinkUrl: 'https://claudify.tech/dashboard',
receiptThankYouNote: 'Thanks for joining Claudify.',
},
testMode: process.env.NODE_ENV !== 'production',
});
if (error) {
console.error('[LS] Checkout creation failed:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({
checkoutUrl: data.data.attributes.url,
checkoutId: data.data.id,
});
}
Three things to call out in this pattern. First, Number(storeId) and Number(variantId) because the SDK expects numeric IDs and the env vars and request body deliver them as strings. Second, checkoutData.custom is where you attach your application's metadata (the user ID, the source, anything else you need on the webhook). Third, testMode is read from NODE_ENV so local development hits the Lemon Squeezy sandbox and production hits the real payment processor.
Add a checkout section to CLAUDE.md:
## Checkout pattern
- Endpoint: src/app/api/checkout/route.ts (or equivalent)
- ALWAYS cast IDs to Number: Number(storeId), Number(variantId)
- ALWAYS pass test_mode: process.env.NODE_ENV !== 'production'
- checkoutData.custom is the ONLY place to put your app's metadata
- Use {checkout_id} in redirectUrl for post-purchase tracking
- receiptLinkUrl points to the user-facing dashboard, NOT the checkout
- The response data.data.attributes.url is the checkout URL to redirect to
The {checkout_id} placeholder is Lemon Squeezy's interpolation token. It is the only piece of dynamic data you can include in the redirect URL because the rest of the URL is set at checkout creation time and the customer has not yet completed payment.
Webhook signature verification
Lemon Squeezy signs every webhook with HMAC-SHA256 using your webhook secret. The signature appears in the X-Signature header. Your handler verifies the signature against the raw request body before parsing the JSON.
The verification pattern in Next.js App Router:
// src/app/api/webhooks/ls/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'node:crypto';
const webhookSecret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET;
export async function POST(req: NextRequest) {
if (!webhookSecret) {
return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 });
}
const rawBody = await req.text();
const signature = req.headers.get('x-signature');
if (!signature) {
return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
}
const hmac = crypto.createHmac('sha256', webhookSecret);
const digest = hmac.update(rawBody).digest('hex');
const signatureBuffer = Buffer.from(signature, 'utf8');
const digestBuffer = Buffer.from(digest, 'utf8');
if (
signatureBuffer.length !== digestBuffer.length ||
!crypto.timingSafeEqual(signatureBuffer, digestBuffer)
) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
// Signature valid, parse the event
const event = JSON.parse(rawBody) as LemonSqueezyEvent;
await processEvent(event);
return NextResponse.json({ ok: true });
}
async function processEvent(event: LemonSqueezyEvent) {
// Idempotency check
const eventId = event.meta.event_id;
const existing = await db.processedEvents.findUnique({ where: { id: eventId } });
if (existing) {
console.log(`[LS] Event ${eventId} already processed, skipping`);
return;
}
switch (event.meta.event_name) {
case 'order_created':
await handleOrderCreated(event);
break;
case 'subscription_created':
await handleSubscriptionCreated(event);
break;
case 'subscription_cancelled':
await handleSubscriptionCancelled(event);
break;
case 'license_key_created':
await handleLicenseKeyCreated(event);
break;
default:
console.log(`[LS] Unhandled event: ${event.meta.event_name}`);
}
await db.processedEvents.create({ data: { id: eventId, processedAt: new Date() } });
}
interface LemonSqueezyEvent {
meta: {
event_id: string;
event_name: string;
custom_data?: Record<string, unknown>;
};
data: {
id: string;
type: string;
attributes: Record<string, unknown>;
relationships: Record<string, unknown>;
};
}
Four points worth highlighting. First, req.text() instead of req.json(). The signature is computed over the raw body bytes, so parsing the JSON first means the signature will not match. Second, timingSafeEqual instead of ===. Plain string comparison leaks timing information that lets an attacker brute-force signatures one character at a time. Third, idempotency check before processing. Fourth, the handler returns 200 even for events it ignores. Returning 4xx or 5xx tells Lemon Squeezy to retry, which floods your logs.
Add a webhook section to CLAUDE.md:
## Webhook handler
- Endpoint: src/app/api/webhooks/ls/route.ts
- ALWAYS req.text() before signature verification, NEVER req.json()
- HMAC-SHA256 with LEMONSQUEEZY_WEBHOOK_SECRET
- timingSafeEqual for signature comparison, NEVER plain ===
- Idempotency check via meta.event_id BEFORE processing
- Persist event_id to processed_events table AFTER successful processing
- Return 200 for: valid events processed, valid events ignored, duplicates
- Return 400 ONLY for: missing signature, invalid signature
- Return 500 ONLY for: genuine retryable server errors
Get Claudify. The CLAUDE.md template Lemon Squeezy developers use to stop dropping webhook events.
License key activation flow
Lemon Squeezy generates a license key for every order of a license-key-enabled product variant. The license arrives in the license_key_created webhook event with a key string and an activation count. Your application validates the key when the customer activates it.
Activating a license key from the client:
// src/app/api/license/activate/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const { licenseKey, instanceName } = await req.json();
const response = await fetch('https://api.lemonsqueezy.com/v1/licenses/activate', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: new URLSearchParams({
license_key: licenseKey,
instance_name: instanceName,
}),
});
const data = await response.json();
if (!data.activated) {
return NextResponse.json({ error: data.error || 'Activation failed' }, { status: 400 });
}
return NextResponse.json({
instanceId: data.instance.id,
activatedAt: data.instance.created_at,
licenseStatus: data.license_key.status,
});
}
The activation endpoint uses application/x-www-form-urlencoded, not JSON. This is an exception to the JSON:API rule and one of the things Claude gets wrong consistently. The license endpoints are a separate family from the main API and they use form encoding.
Validating an active license:
// src/lib/licenses.ts
export async function validateLicense(licenseKey: string, instanceId: string) {
const response = await fetch('https://api.lemonsqueezy.com/v1/licenses/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: new URLSearchParams({
license_key: licenseKey,
instance_id: instanceId,
}),
});
const data = await response.json();
return {
valid: data.valid as boolean,
status: data.license_key?.status as string | undefined,
instanceName: data.instance?.name as string | undefined,
};
}
export async function deactivateLicense(licenseKey: string, instanceId: string) {
const response = await fetch('https://api.lemonsqueezy.com/v1/licenses/deactivate', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: new URLSearchParams({
license_key: licenseKey,
instance_id: instanceId,
}),
});
return response.json();
}
Add a license section to CLAUDE.md:
## License key endpoints
- DIFFERENT from main API: use application/x-www-form-urlencoded, NOT JSON:API
- Endpoints: /v1/licenses/activate, /v1/licenses/validate, /v1/licenses/deactivate
- Each call requires license_key, and optionally instance_id or instance_name
- Activation returns { activated, instance: { id, created_at } }
- Store instance_id with the user account, you need it for validation
- Validate on session start, not on every request (cache for the session)
- Status can be: inactive, active, expired, disabled
For the broader user account pattern that holds activated licenses, Claude Code with Supabase covers the auth-aware data model that pairs cleanly with license-key gating.
Subscription lifecycle handling
For subscription products, Lemon Squeezy fires events at each lifecycle transition: subscription_created, subscription_updated, subscription_cancelled, subscription_resumed, subscription_expired, subscription_paused, subscription_unpaused. Your application updates the user's subscription record on each event.
The events you must handle:
| Event | When it fires | What to do |
|---|---|---|
subscription_created |
New subscription successfully charged | Create subscription record, grant access |
subscription_updated |
Plan change, card update | Update plan ID, log change |
subscription_payment_success |
Renewal charge succeeded | Extend access period, log payment |
subscription_payment_failed |
Renewal charge failed | Flag account, send recovery email |
subscription_cancelled |
Customer cancelled (still active until period end) | Set cancel_at_period_end flag |
subscription_expired |
Active period ended after cancellation | Revoke access |
subscription_paused |
Subscription paused | Suspend access |
subscription_unpaused |
Subscription resumed | Restore access |
A subscription handler skeleton:
async function handleSubscriptionCreated(event: LemonSqueezyEvent) {
const attrs = event.data.attributes;
const userId = event.meta.custom_data?.user_id as string | undefined;
if (!userId) {
console.error('[LS] Subscription event missing user_id in custom_data');
return;
}
await db.subscriptions.upsert({
where: { lemonSqueezyId: event.data.id },
create: {
lemonSqueezyId: event.data.id,
userId,
status: attrs.status as string,
productId: attrs.product_id as number,
variantId: attrs.variant_id as number,
renewsAt: new Date(attrs.renews_at as string),
endsAt: attrs.ends_at ? new Date(attrs.ends_at as string) : null,
},
update: {
status: attrs.status as string,
renewsAt: new Date(attrs.renews_at as string),
endsAt: attrs.ends_at ? new Date(attrs.ends_at as string) : null,
},
});
}
The userId comes from meta.custom_data, which is the same object you set on checkoutData.custom when creating the checkout. The chain is: your app generates a checkout with custom: { user_id: '...' }, the customer completes payment, Lemon Squeezy fires a subscription_created event with that user_id echoed in meta.custom_data. Without this round-trip, you have no way to map subscriptions to your application's users.
Common Claude Code mistakes with Lemon Squeezy
Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.
1. camelCase field names
Claude generates: { storeId: 123, variantId: 456, customPrice: 1000 }
Correct pattern: { store_id: 123, variant_id: 456, custom_price: 1000 }. The Lemon Squeezy API rejects camelCase with a 422 error.
2. Flat request body without JSON:API wrapper
Claude generates: body: JSON.stringify({ store_id: 123, variant_id: 456 })
Correct pattern: body: JSON.stringify({ data: { type: 'checkouts', attributes: { ... }, relationships: { ... } } }).
3. Plain string signature comparison
Claude generates: if (signature === digest) { ... }
Correct pattern: crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest)). Plain comparison leaks timing.
4. Parsed JSON for signature verification
Claude generates: const body = await req.json(); const digest = hmac.update(JSON.stringify(body))...
Correct pattern: const rawBody = await req.text(); const digest = hmac.update(rawBody).... JSON re-serialisation changes byte order.
5. Missing idempotency check
Claude generates: webhook handler that processes every event without checking event_id.
Correct pattern: check meta.event_id against processed_events table before processing.
6. application/json on license endpoints
Claude generates: fetch('/v1/licenses/activate', { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ license_key }) })
Correct pattern: application/x-www-form-urlencoded with URLSearchParams. The license family of endpoints uses form encoding, unlike the rest of the API.
Add a common mistakes section to CLAUDE.md with these six pairs. Claude responds better to before/after comparisons than to abstract rules.
Permission hooks for payment scripts
A Lemon Squeezy integration accumulates scripts: order reconciliation, license key bulk generation, refund processing, subscription cleanup. Permission hooks gate the destructive ones.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(node scripts/check-webhook-health.js*)",
"Bash(node scripts/list-products.js*)",
"Bash(node scripts/reconcile-orders.js --dry-run*)"
],
"deny": [
"Bash(node scripts/refund-order.js*)",
"Bash(node scripts/cancel-subscription.js*)",
"Bash(node scripts/disable-license.js*)",
"Bash(node scripts/reconcile-orders.js --commit*)"
]
}
}
Read operations and dry-runs are safe. Refunds, cancellations, license disables, and any --commit flag require explicit confirmation. The reconciliation script is whitelisted in dry-run mode and denied in commit mode, which forces Claude to surface the actual data changes for review before applying them.
Building Lemon Squeezy integrations that survive production traffic
The Lemon Squeezy CLAUDE.md in this guide produces integrations where every API call uses snake_case and JSON:API formatting, webhook signatures are verified with timing-safe comparison, every event is checked for idempotency before processing, license endpoints use form encoding correctly, test mode is enabled in non-production environments, and dangerous scripts require explicit confirmation.
The underlying principle is the same as any API integration with Claude Code. Lemon Squeezy without a CLAUDE.md produces code that looks reasonable and may even work in test mode, but fails in production when the API rejects camelCase payloads, when webhooks arrive twice and create duplicate orders, when signature verification is skipped and an attacker triggers your fulfilment endpoint, and when the license activation endpoint is called with the wrong content type. The CLAUDE.md template removes each failure mode by making the correct pattern the only pattern Claude can generate.
For the broader SaaS billing pattern, Lemon Squeezy works alongside Claude Code with Stripe for shops that need both platforms, and Claudify includes a Lemon Squeezy-specific CLAUDE.md template with the JSON:API rules, signature verification pattern, idempotency strategy, license key flow, and all six common-mistake rules pre-configured.
Get Claudify. The CLAUDE.md template Lemon Squeezy developers use to ship merchant-of-record integrations that pass audits.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify