Skip to main content

LemonSqueezy Deep Dive

This page covers the complete LemonSqueezy integration, including checkout creation, subscription management, webhook processing, and product sync.

Overview

LemonSqueezy is a merchant-of-record payment provider that handles tax collection, compliance, and payment processing. The integration uses LemonSqueezy's hosted checkout flow, variant-based product model, and webhook system. Unlike Stripe, LemonSqueezy does not support setup intents or direct payment method management -- all payment handling occurs through their hosted UI.

Route Table

MethodPathAuthDescription
POST/api/lemonsqueezy/checkoutSession requiredCreate checkout session from JSON body
GET/api/lemonsqueezy/checkoutNoneCreate checkout session from query params
POST/api/lemonsqueezy/webhookSignature requiredProcess incoming webhook events

Checkout Creation (POST)

Request Body

interface LemonSqueezyCheckoutRequest {
variantId: string; // LemonSqueezy product variant ID
dark?: boolean; // Enable dark mode checkout
customPrice?: number; // Custom price in cents (optional)
metadata?: Record<string, string>; // Additional metadata
}

Example Request

curl -X POST /api/lemonsqueezy/checkout \
-H "Content-Type: application/json" \
-H "Cookie: session=..." \
-d '{
"variantId": "123456",
"dark": true,
"metadata": { "plan": "pro", "source": "website" }
}'

How It Works

  1. Authenticates the user via auth()
  2. Validates the request body using validateCheckoutRequestBody()
  3. Calls lemonsqueezyProvider.createCustomCheckout() with user metadata
  4. Returns the checkout URL

Provider Implementation

The createCustomCheckout method creates a LemonSqueezy checkout with comprehensive configuration:

const { data, error } = await createCheckout(Number(this.storeId), Number(params.variantId), {
customPrice: params.customPrice,
productOptions: {
redirectUrl: `${env.API_BASE_URL}/billing/success`,
receiptButtonText: 'View Receipt',
receiptLinkUrl: `${env.API_BASE_URL}/billing/receipt`,
receiptThankYouNote: 'Thank you for your purchase!',
enabledVariants: [Number(params.variantId)]
},
checkoutOptions: {
embed: true,
media: false,
logo: false,
dark: params.dark
},
checkoutData: {
email: params.email,
custom: params.metadata ?? {},
variantQuantities: [{ variantId: Number(params.variantId), quantity: 1 }]
},
testMode: process.env.NODE_ENV === 'development',
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString()
});

Success Response (200)

{
"success": true,
"data": {
"checkoutUrl": "https://checkout.lemonsqueezy.com/checkout/custom/abc123",
"email": "user@example.com",
"customPrice": 2999,
"variantId": "123456",
"metadata": {
"userId": "user_123abc",
"email": "user@example.com",
"name": "John Doe",
"plan": "pro"
}
},
"message": "Checkout session created successfully"
}

Checkout via Query Parameters (GET)

The GET endpoint supports creating checkouts via query parameters for direct link scenarios:

ParameterRequiredDescription
variantIdYesLemonSqueezy variant ID
emailYesCustomer email
customPriceNoCustom price in cents
metadataNoJSON string of metadata

Subscription Management

Creating Subscriptions

Subscriptions are created through the checkout flow. The createSubscription method wraps LemonSqueezy's checkout API:

const { data, error } = await createCheckout(Number(this.storeId), finalProductId, {
checkoutOptions: {
embed: true,
subscriptionPreview: true
},
checkoutData: {
email: email || '',
custom: metadata ?? {}
}
});

Cancelling Subscriptions

async cancelSubscription(subscriptionId: string): Promise<SubscriptionInfo> {
const { data, error } = await cancelSubscription(Number(subscriptionId));
return {
id: subscriptionId,
status: 'canceled' as SubscriptionStatus,
// ...
};
}

Updating Subscriptions

The update method supports plan changes, pausing, resuming, and reactivation:

// Plan change via variant ID
if (params.priceId) {
updatePayload.variantId = Number(params.priceId);
}

// Pause subscription
if (params.metadata?.pauseMode) {
updatePayload.pause = {
mode: params.metadata.pauseMode as 'void' | 'free',
resumesAt: params.metadata.pauseUntil || null
};
}

// Resume subscription
if (params.metadata?.resumeAction) {
if (currentSubscription?.status === 'paused') {
updatePayload.pause = null;
} else if (currentSubscription?.status === 'cancelled') {
updatePayload.cancelled = false;
}
}

Webhook Processing

Signature Verification

LemonSqueezy uses HMAC SHA-256 for webhook signature verification. The provider verifies signatures using the Web Crypto API:

const cryptoKey = await crypto.subtle.importKey(
'raw', encoder.encode(this.webhookSecret),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const signatureBuffer = await crypto.subtle.sign('HMAC', cryptoKey, messageData);
const calculatedSignature = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');

if (calculatedSignature !== signature) {
return { received: false, type: 'verification_failed', ... };
}

Event Mapping

LemonSqueezy EventInternal Type
subscription_createdSUBSCRIPTION_CREATED
subscription_updatedSUBSCRIPTION_UPDATED
subscription_cancelledSUBSCRIPTION_CANCELLED
subscription_payment_successSUBSCRIPTION_PAYMENT_SUCCEEDED
subscription_payment_failedSUBSCRIPTION_PAYMENT_FAILED
subscription_trial_will_endSUBSCRIPTION_TRIAL_ENDING
order_createdPAYMENT_SUCCEEDED
order_refundedREFUND_SUCCEEDED

Webhook Handler Structure

Each handler follows a consistent pattern:

async function handleSubscriptionCreated(data: any) {
if (isSponsorAdSubscription(data)) {
await handleSponsorAdActivation(data);
return;
}
try {
const result = await webhookSubscriptionService.handleSubscriptionCreated(data);
// ... log result
} catch (error) {
console.error('Error handling subscription created:', error);
}
}

LemonSqueezy uses custom_data instead of Stripe's metadata:

function isSponsorAdSubscription(data: Record<string, unknown>): boolean {
const customData = data.custom_data as Record<string, string> | undefined;
const meta = data.meta as Record<string, unknown> | undefined;
const metaCustomData = meta?.custom_data as Record<string, string> | undefined;
return customData?.type === 'sponsor_ad' || metaCustomData?.type === 'sponsor_ad';
}

Customer Management

The provider follows the same three-step customer resolution pattern as other providers:

  1. Check user metadata for lemonsqueezy_customer_id
  2. Query the PaymentAccount database table
  3. Create a new customer via the LemonSqueezy API
const { data, error } = await createCustomer(Number(this.storeId), {
email: params.email,
name: params.name || '',
city: params.metadata?.city || '',
region: params.metadata?.region || '',
country: params.metadata?.country || ''
});

Error Handling

StatusError CodeCause
400VALIDATION_ERRORInvalid request body or parameters
401UnauthorizedNo authenticated session
500CONFIGURATION_ERRORMissing environment variables
500INTERNAL_ERRORUnhandled error
503PAYMENT_SERVICE_ERRORLemonSqueezy API unavailable

Configuration Requirements

VariableRequiredDescription
LEMONSQUEEZY_API_KEYYesLemonSqueezy API key
LEMONSQUEEZY_WEBHOOK_SECRETYesWebhook signing secret
LEMONSQUEEZY_STORE_IDYesNumeric store ID

Limitations

  • No setup intents: LemonSqueezy does not support saving cards without a purchase. The createSetupIntent method throws an error.
  • No direct refund API: Refunds must be processed through the LemonSqueezy dashboard.
  • Variant-based pricing: Products use variant IDs instead of price IDs. Plan changes use variantId.

Security Considerations

  • Webhook signatures are verified using HMAC SHA-256
  • The raw body text is used for signature verification to prevent JSON re-serialization issues
  • API keys are never exposed to the client
  • Development mode logging sanitizes PII (email addresses are partially redacted)