This guide walks you through integrating Lemon Squeezy's merchant-of-record payment platform with Strapi v5. Learn to handle webhooks, process subscriptions, and build content-gated applications with automatic tax compliance.

Lemon Squeezy is a merchant-of-record payment platform built specifically for developers selling digital products and software subscriptions. Unlike traditional payment processors where you're responsible for tax collection and compliance, Lemon Squeezy takes legal responsibility for every transaction, automatically handling tax compliance across 135+ countries.
The platform automatically calculates and collects VAT, sales tax, and GST across 135+ countries. It handles payment processing through both Stripe and PayPal, manages subscription lifecycles with automatic retry logic for failed payments, and provides license key management for software products.
For developers, this means one integration handles everything: payment processing, tax compliance, fraud prevention, and customer billing support. The trade-off is Lemon Squeezy's bundled pricing model: developers pay approximately 5% + ~£0.40 per transaction, which includes what you'd otherwise pay separately for payment processing, tax calculation services, and compliance infrastructure.
Connecting Lemon Squeezy to your Strapi backend creates a clean separation between content management and payment processing, with payment state changes synchronized to content access control through webhook events. When subscription or order events occur in Lemon Squeezy, webhook POST requests trigger Strapi backend handlers that update user permissions and subscription status, enabling real-time gating of premium content based on payment state.
subscription_created webhook fires, your Strapi controller updates the user's role and subscription status. The Content API then enforces tier-based access automatically through Strapi's users-permissions plugin and custom policies that check the user's subscription status against required content tiers.This step-by-step guide walks you through setting up webhooks, processing payments, and implementing content gating in your Strapi v5 application.
Before starting, ensure you have:
npx create-strapi@latest my-project)Create or update your .env file with the Lemon Squeezy credentials. Strapi v5 uses environment variables extensively, and keeping secrets out of your codebase is non-negotiable.
# Lemon Squeezy Configuration
LEMONSQUEEZY_API_KEY=your_api_key
LEMONSQUEEZY_STORE_ID=your_store_id
LEMONSQUEEZY_WEBHOOK_SECRET=your_webhook_secretAccording to the Strapi v5 Environment Configuration documentation, Strapi v5 applications require the following environment variables, which must be generated during project initialization and configured securely:
# Server Configuration
HOST=0.0.0.0
PORT=1337
# Security Keys (required for encryption and authentication)
# APP_KEYS must contain at least four keys of 16+ characters each
APP_KEYS=key1,key2,key3,key4
API_TOKEN_SALT=randomString
ADMIN_JWT_SECRET=randomString
TRANSFER_TOKEN_SALT=randomString
JWT_SECRET=randomStringCritical Security Requirements:
Per the Access and Cast Environment Variables guide, all sensitive values must be:
.env files (never committed to version control)env() helper function in configuration files for type safetyThe env() function provides type-safe access with casting methods including env('VAR') for strings, env.int('PORT') for integers, env.bool('VAR') for booleans, and env.array('VAR') for comma-separated arrays.
Access these values in your Strapi configuration files using the env() helper with type casting, which provides officially supported methods for type-safe environment variable access across your application.
// config/server.ts
export default ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
app: {
keys: env.array('APP_KEYS'),
},
lemonsqueezy: {
apiKey: env('LEMONSQUEEZY_API_KEY'),
storeId: env('LEMONSQUEEZY_STORE_ID'),
webhookSecret: env('LEMONSQUEEZY_WEBHOOK_SECRET'),
},
});You need to track subscription status alongside user data. Strapi's users-permissions plugin handles authentication, and you'll extend it with payment-related fields such as subscriptionStatus (enumeration: active, canceled, past_due, trialing), membershipTier (enumeration: Free, Premium, VIP), and payment provider IDs like stripeCustomerId to link users to their payment processor accounts.
Create custom backend endpoints for users by adding controllers, services, and routes through Strapi v5's backend customization framework, or define content type structures using the Content-Type Builder:
Step 3: Create the Webhook Controller
The webhook endpoint receives POST requests from Lemon Squeezy and processes payment events. Signature verification is critical here: the HMAC SHA-256 signature must be verified against the raw request body bytes (not the parsed JSON object) before trusting any incoming webhook data.
According to Lemon Squeezy's webhook signing documentation, this raw body verification is mandatory for security and is the most common source of signature verification failures in production implementations.
// src/api/webhook/controllers/webhook.ts
import { factories } from '@strapi/strapi';
import crypto from 'crypto';
export default factories.createCoreController('api::webhook.webhook', ({ strapi }) => ({
async handleLemonSqueezy(ctx) {
const signature = ctx.request.headers['x-signature'];
const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET;
// Get raw body for signature verification - critical for HMAC validation
const rawBody = ctx.request.body[Symbol.for('unparsedBody')] || JSON.stringify(ctx.request.body);
// Verify HMAC SHA256 signature using timing-safe comparison
const crypto = require('crypto');
const hmac = crypto.createHmac('sha256', secret);
const digest = hmac.update(rawBody).digest('hex');
// Use timing-safe comparison to prevent timing attacks
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest))) {
strapi.log.warn('Invalid webhook signature received');
return ctx.status(401).json({ error: 'Invalid signature' });
}
const payload = typeof ctx.request.body === 'string'
? JSON.parse(ctx.request.body)
: ctx.request.body;
const eventType = payload.meta?.event_name;
try {
await strapi.service('api::webhook.webhook').processEvent(eventType, payload);
ctx.body = { received: true };
} catch (error) {
strapi.log.error('Webhook processing failed:', error);
ctx.status(500).json({ error: 'Processing failed' });
}
}
}));The crypto.timingSafeEqual() function prevents timing attacks when verifying webhook signatures from payment providers. Using regular string equality (===) can leak information about the expected signature through response time variations, allowing attackers to forge valid webhook requests.
Services in Strapi contain reusable business logic that encapsulates functionality for testing and reuse across controllers. Services can be used for various purposes, including processing webhook events and updating application data accordingly.
// src/api/webhook/services/webhook.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::webhook.webhook', ({ strapi }) => ({
async processEvent(eventType: string, payload: any) {
const { data, meta } = payload;
// Idempotency check - prevent duplicate processing
const webhookId = `${eventType}_${data.id}`;
const existing = await strapi.db.query('api::processed-webhook.processed-webhook').findOne({
where: { webhookId }
});
if (existing) {
strapi.log.info(`Webhook ${webhookId} already processed, skipping`);
return;
}
switch (eventType) {
case 'order_created':
await this.handleOrderCreated(data, meta);
break;
case 'subscription_created':
await this.handleSubscriptionCreated(data, meta);
break;
case 'subscription_updated':
await this.handleSubscriptionUpdated(data, meta);
break;
case 'subscription_cancelled':
await this.handleSubscriptionCancelled(data, meta);
break;
case 'subscription_payment_failed':
await this.handlePaymentFailed(data, meta);
break;
default:
strapi.log.info(`Unhandled event type: ${eventType}`);
}
// Record processed webhook
await strapi.db.query('api::processed-webhook.processed-webhook').create({
data: { webhookId, eventType, processedAt: new Date() }
});
},
async handleSubscriptionCreated(data: any, meta: any) {
const email = data.attributes.user_email;
const customData = meta.custom_data || {};
// Find user by email or custom user_id passed during checkout
const user = await strapi.db.query('plugin::users-permissions.user').findOne({
where: customData.user_id
? { id: customData.user_id }
: { email }
});
if (!user) {
strapi.log.warn(`No user found for subscription: ${data.id}`);
return;
}
// Map variant to tier (configure based on your Lemon Squeezy products)
const tierMapping = {
'starter_monthly': 'starter',
'starter_yearly': 'starter',
'pro_monthly': 'pro',
'pro_yearly': 'pro',
};
const variantName = data.attributes.variant_name?.toLowerCase().replace(/\s+/g, '_');
const tier = tierMapping[variantName] || 'starter';
await strapi.db.query('plugin::users-permissions.user').update({
where: { id: user.id },
data: {
subscriptionId: data.id,
subscriptionStatus: data.attributes.status,
customerId: data.attributes.customer_id.toString(),
subscriptionTier: tier,
subscriptionEndsAt: data.attributes.renews_at,
}
});
strapi.log.info(`Subscription created for user ${user.id}: ${tier} tier`);
},
async handleSubscriptionUpdated(data: any, meta: any) {
const user = await strapi.db.query('plugin::users-permissions.user').findOne({
where: { subscriptionId: data.id }
});
if (!user) return;
await strapi.db.query('plugin::users-permissions.user').update({
where: { id: user.id },
data: {
subscriptionStatus: data.attributes.status,
subscriptionEndsAt: data.attributes.ends_at || data.attributes.renews_at,
}
});
},
async handleSubscriptionCancelled(data: any, meta: any) {
const user = await strapi.db.query('plugin::users-permissions.user').findOne({
where: { subscriptionId: data.id }
});
if (!user) return;
// Note: User retains access until subscriptionEndsAt
await strapi.db.query('plugin::users-permissions.user').update({
where: { id: user.id },
data: {
subscriptionStatus: 'cancelled',
subscriptionEndsAt: data.attributes.ends_at,
}
});
},
async handlePaymentFailed(data: any, meta: any) {
const user = await strapi.db.query('plugin::users-permissions.user').findOne({
where: { subscriptionId: data.id }
});
if (!user) return;
await strapi.db.query('plugin::users-permissions.user').update({
where: { id: user.id },
data: {
subscriptionStatus: 'past_due',
}
});
// Lemon Squeezy automatically retries failed payments 4 times over 2 weeks
// Subscription status transitions: active → past_due → unpaid → cancelled
// Consider sending a notification to the user here
},
async handleOrderCreated(data: any, meta: any) {
// For one-time purchases (digital products)
const email = data.attributes.user_email;
const customData = meta.custom_data || {};
// Store order record for fulfillment
await strapi.db.query('api::order.order').create({
data: {
lemonsqueezyOrderId: data.id,
customerEmail: email,
userId: customData.user_id,
productId: data.attributes.first_order_item?.product_id,
total: data.attributes.total,
status: data.attributes.status,
}
});
}
}));Routes connect HTTP endpoints to your controller actions. The webhook endpoint should be configured with auth: false to bypass Strapi's JWT authentication, since webhook security is enforced through HMAC SHA-256 signature verification in the X-Signature header rather than authentication tokens.
// src/api/webhook/routes/webhook.ts
export default {
routes: [
{
method: 'POST',
path: '/webhooks/lemonsqueezy',
handler: 'webhook.handleLemonSqueezy',
config: {
auth: false,
middlewares: [],
},
},
],
};Your frontend needs a way to initiate the checkout process. This endpoint generates a Lemon Squeezy checkout URL with the user's information pre-filled.
// src/api/checkout/controllers/checkout.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::checkout.checkout', ({ strapi }) => ({
async create(ctx) {
// Controller action implementation
ctx.body = { data: 'checkout created' };
}
}));import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::checkout.checkout', ({ strapi }) => ({
async createSession(ctx) {
const user = ctx.state.user;
if (!user) {
return ctx.unauthorized('Authentication required');
}
const { variantId } = ctx.request.body;
if (!variantId) {
return ctx.badRequest('Variant ID required');
}
const apiKey = process.env.LEMONSQUEEZY_API_KEY;
const storeId = process.env.LEMONSQUEEZY_STORE_ID;
const response = await fetch('https://api.lemonsqueezy.com/v1/checkouts', {
method: 'POST',
headers: {
'Accept': 'application/vnd.api+json',
'Content-Type': 'application/vnd.api+json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
data: {
type: 'checkouts',
attributes: {
checkout_data: {
email: user.email,
name: user.username,
custom: {
user_id: user.id.toString(),
},
},
checkout_options: {
embed: true,
media: true,
logo: true,
},
product_options: {
enabled_variants: [parseInt(variantId)],
redirect_url: `${process.env.FRONTEND_URL}/checkout/success`,
},
},
relationships: {
store: {
data: {
type: 'stores',
id: storeId,
},
},
variant: {
data: {
type: 'variants',
id: variantId,
},
},
},
},
}),
});
if (!response.ok) {
const error = await response.json();
strapi.log.error('Checkout creation failed:', error);
return ctx.throw(500, 'Failed to create checkout');
}
const checkout = await response.json();
ctx.body = {
checkoutUrl: checkout.data.attributes.url,
};
}
}));Add the route for the checkout endpoint using the custom route array pattern. According to the Strapi Routes Documentation, create a POST route in ./src/api/payment/routes/custom-payment.js:
// ./src/api/payment/routes/custom-payment.js
module.exports = {
routes: [
{
method: 'POST',
path: '/payments/checkout',
handler: 'payment.createCheckout',
config: {
auth: true,
policies: [],
middlewares: ['api::payment.validate-payment']
}
}
]
};This route configuration specifies the POST method for the checkout endpoint, references the controller handler createCheckout that will process the request, requires user authentication via JWT token, and applies payment validation middleware to sanitize inputs before the controller receives the request.
// src/api/checkout/routes/checkout.ts
export default {
routes: [
{
method: 'POST',
path: '/checkout/create',
handler: 'checkout.createSession',
config: {
auth: true,
},
},
],
};Now tie payment status to content access using custom Strapi middleware and policies. Create a custom policy that checks the user's subscription status from the webhook-synchronized data and enforces access control at the controller level before serving premium content:
// ./src/api/content/policies/check-subscription.js
module.exports = async (policyContext, config, { strapi }) => {
const user = policyContext.state.user;
if (!user || user.subscriptionStatus !== 'active') {
return false;
}
const requiredTier = policyContext.params.requiredTier;
return user.membershipTier >= requiredTier;
};Apply this policy to your premium content routes in the route configuration to enforce subscription-based access control before the controller executes.
According to Strapi v5's middleware and policies documentation, subscription-based access control should be implemented using Strapi's built-in policy system rather than custom middleware. Here is the recommended implementation pattern:
// ./src/api/subscription/policies/subscription-required.ts
import type { Core } from '@strapi/strapi';
export default (policyContext: any, config: any, { strapi }: { strapi: Core.Strapi }) => {
const user = policyContext.state.user;
if (!user) {
return false;
}
const activeStatuses = ['active', 'trialing'];
const hasActiveSubscription = activeStatuses.includes(user.subscriptionStatus);
// Check if in grace period (cancelled but not yet expired)
const inGracePeriod = user.subscriptionStatus === 'cancelled'
&& user.subscriptionEndsAt
&& new Date(user.subscriptionEndsAt) > new Date();
if (!hasActiveSubscription && !inGracePeriod) {
return false;
}
const requiredTier = config.requiredTier || 'starter';
const tierHierarchy = ['free', 'starter', 'pro', 'enterprise'];
const userTierIndex = tierHierarchy.indexOf(user.subscriptionTier || 'free');
const requiredTierIndex = tierHierarchy.indexOf(requiredTier);
return userTierIndex >= requiredTierIndex;
};This implementation follows Strapi v5's official policy pattern from the Controllers and Routes documentation, using the policy context object and returning a boolean value. Policies should be applied at the route level in route configuration files rather than as middleware, which provides better integration with Strapi's access control system and allows fine-grained permission management per endpoint.
Apply the middleware to protected routes:
// src/api/premium-content/routes/premium-content.ts
export default {
routes: [
{
method: 'GET',
path: '/premium-content',
handler: 'premium-content.find',
config: {
auth: true,
},
},
{
method: 'GET',
path: '/premium-content/:id',
handler: 'premium-content.findOne',
config: {
auth: true,
},
},
],
};Note: For subscription-based content gating, implement access control through custom policies or middleware at the service layer rather than route-level configuration. Reference the research guidance on role-based permissions and custom middleware for content access control patterns.
With your Strapi endpoint ready, configure Lemon Squeezy to send events:
https://your-domain.com/api/webhooks/lemonsqueezyorder_createdsubscription_createdsubscription_updatedsubscription_cancelledsubscription_payment_failed.env fileFor local testing, use ngrok to expose your development server to receive Lemon Squeezy webhooks locally. According to the ngrok webhook integration documentation, developers can create a secure tunnel using the command:
ngrok http 1337This generates a public HTTPS URL that forwards webhook requests from Lemon Squeezy to your local development server. Configure this URL in your Lemon Squeezy dashboard webhook settings, and the platform will deliver test webhook events to your localhost endpoint, enabling safe development without deploying to production.
ngrok http 1337Then use ngrok to create a secure tunnel to your local development server (ngrok http 3000), copy the generated ngrok URL, configure it as your webhook endpoint in Lemon Squeezy's dashboard settings, and use Lemon Squeezy's test mode to manually trigger webhook events for testing without processing real transactions.
Let's build a practical example: a course platform where users purchase subscriptions to access video lessons. This demonstrates the complete integration pattern with content gating.
The platform integration typically requires multiple Collection Types working together to manage content access, payments, and user data synchronization.
Courses Collection stores course metadata:
// Content-Type: Course
{
"title": "string",
"description": "richtext",
"thumbnail": "media",
"requiredTier": "enumeration (free, starter, pro)",
"lessons": "relation (one-to-many with Lesson)"
}Lessons Collection holds individual video content:
// Content-Type: Lesson
{
"title": "string",
"videoUrl": "string",
"course": "relation (many-to-one with Course)",
"order": "integer"
}Progress Collection tracks user advancement:
// Content-Type: Progress
{
"user": "relation (many-to-one with User)",
"lesson": "relation (many-to-one with Lesson)",
"completed": "boolean",
"completedAt": "datetime",
"watchTime": "integer"
}This controller serves courses based on subscription tier, returning different data for free versus paid users:
// src/api/course/controllers/course.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::course.course', ({ strapi }) => ({
async find(ctx) {
const user = ctx.state.user;
const userTier = user?.subscriptionTier || 'free';
const tierHierarchy = ['free', 'starter', 'pro', 'enterprise'];
const userTierIndex = tierHierarchy.indexOf(userTier);
// Fetch all courses
const courses = await strapi.entityService.findMany('api::course.course', {
populate: ['thumbnail', 'lessons'],
});
// Filter and annotate based on access
const annotatedCourses = courses.map(course => {
const requiredTierIndex = tierHierarchy.indexOf(course.requiredTier);
const hasAccess = userTierIndex >= requiredTierIndex;
return {
...course,
hasAccess,
lessonCount: course.lessons?.length || 0,
// Only include lessons array if user has access
lessons: hasAccess ? course.lessons : undefined,
};
});
ctx.body = { data: annotatedCourses };
},
async findOne(ctx) {
const { id } = ctx.params;
const user = ctx.state.user;
const userTier = user?.subscriptionTier || 'free';
const course = await strapi.entityService.findOne('api::course.course', id, {
populate: ['thumbnail', 'lessons', 'lessons.resources'],
});
if (!course) {
return ctx.notFound('Course not found');
}
const tierHierarchy = ['free', 'starter', 'pro', 'enterprise'];
const userTierIndex = tierHierarchy.indexOf(userTier);
const requiredTierIndex = tierHierarchy.indexOf(course.requiredTier);
if (userTierIndex < requiredTierIndex) {
// Return limited preview for unauthorized users
ctx.body = {
data: {
id: course.id,
title: course.title,
description: course.description,
thumbnail: course.thumbnail,
requiredTier: course.requiredTier,
lessonCount: course.lessons?.length || 0,
hasAccess: false,
}
};
return;
}
// Get user progress for this course
let progress = [];
if (user) {
progress = await strapi.entityService.findMany('api::progress.progress', {
filters: {
user: user.id,
lesson: {
course: course.id,
},
},
populate: ['lesson'],
});
}
const progressMap = new Map(
progress.map(p => [p.lesson.id, p])
);
const lessonsWithProgress = course.lessons.map(lesson => ({
...lesson,
completed: progressMap.get(lesson.id)?.completed || false,
watchTime: progressMap.get(lesson.id)?.watchTime || 0,
}));
ctx.body = {
data: {
...course,
lessons: lessonsWithProgress,
hasAccess: true,
completedLessons: progress.filter(p => p.completed).length,
}
};
}
}));On the frontend, integrate the checkout flow using Lemon Squeezy's overlay, hosted checkout pages, or embedded checkout methods via the Lemon.js SDK:
<!-- Include Lemon.js -->
<script src="https://app.lemonsqueezy.com/js/lemon.js" defer></script>// Frontend checkout handler
async function startCheckout(variantId) {
const token = localStorage.getItem('jwt');
const response = await fetch('/api/checkout/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': __INLINECODE_20__,
},
body: JSON.stringify({ variantId }),
});
if (!response.ok) {
console.error('Checkout creation failed');
return;
}
const { checkoutUrl } = await response.json();
// Open Lemon Squeezy overlay
window.LemonSqueezy.Url.Open(checkoutUrl);
// Handle checkout completion
window.LemonSqueezy.Setup({
eventHandler: (event) => {
if (event.event === 'Checkout.Success') {
// Refresh user data to get updated subscription status
window.location.href = '/dashboard?upgraded=true';
}
}
});
}Implement subscription management methods that allow users to control their account status. According to the Lemon Squeezy API documentation, developers should expose endpoints for updating subscriptions (plan changes with proration), pausing/resuming subscriptions with configurable modes (void or free), and canceling subscriptions with grace period handling.
Create Strapi services that wrap Lemon Squeezy's PATCH endpoints for variant updates and pause state management, paired with controllers that verify user authentication and authorization. Implement customer-facing portal links from webhook data (urls.customer_portal) for self-service updates, and handle subscription status changes (active, paused, cancelled, past_due, unpaid) through webhook events that update user roles and content access permissions in Strapi's permission system.
// src/api/subscription/services/subscription.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::subscription.subscription', ({ strapi }) => ({
async processSubscription(subscriptionData: any) {
// Service implementation here
}
}));export default factories.createCoreService('api::subscription.subscription', ({ strapi }) => ({
async getSubscriptionDetails(userId: number) {
const user = await strapi.db.query('plugin::users-permissions.user').findOne({
where: { id: userId },
});
if (!user?.subscriptionId) {
return null;
}
const apiKey = process.env.LEMONSQUEEZY_API_KEY;
const response = await fetch(
__INLINECODE_21__,
{
headers: {
'Accept': 'application/vnd.api+json',
'Authorization': __INLINECODE_22__,
},
}
);
if (!response.ok) {
return null;
}
const subscription = await response.json();
return {
status: subscription.data.attributes.status,
currentPeriodEnd: subscription.data.attributes.renews_at,
cancelAtPeriodEnd: subscription.data.attributes.cancelled,
customerPortalUrl: subscription.data.attributes.urls.customer_portal,
updatePaymentUrl: subscription.data.attributes.urls.update_payment_method,
};
},
async cancelSubscription(userId: number) {
const user = await strapi.db.query('plugin::users-permissions.user').findOne({
where: { id: userId },
});
if (!user?.subscriptionId) {
throw new Error('No active subscription');
}
const apiKey = process.env.LEMONSQUEEZY_API_KEY;
const response = await fetch(
__INLINECODE_23__,
{
method: 'DELETE',
headers: {
'Accept': 'application/vnd.api+json',
'Authorization': __INLINECODE_24__,
},
}
);
if (!response.ok) {
throw new Error('Failed to cancel subscription');
}
// Webhook will update the user record
return { cancelled: true };
},
async pauseSubscription(userId: number, mode: 'void' | 'free' = 'void') {
const user = await strapi.db.query('plugin::users-permissions.user').findOne({
where: { id: userId },
});
if (!user?.subscriptionId) {
throw new Error('No active subscription');
}
const apiKey = process.env.LEMONSQUEEZY_API_KEY;
const response = await fetch(
__INLINECODE_25__,
{
method: 'PATCH',
headers: {
'Accept': 'application/vnd.api+json',
'Content-Type': 'application/vnd.api+json',
'Authorization': __INLINECODE_26__,
},
body: JSON.stringify({
data: {
type: 'subscriptions',
id: user.subscriptionId,
attributes: {
pause: { mode },
},
},
}),
}
);
if (!response.ok) {
throw new Error('Failed to pause subscription');
}
return { paused: true, mode };
}
}));This architecture keeps your content in Strapi's database while Lemon Squeezy handles the entire payment lifecycle. Users can browse free content, hit the paywall, complete checkout without leaving your domain, and immediately access premium content.
If you have any questions about Strapi 5 or just would like to stop by and say hi, you can join us at Strapi's Discord Open Office Hours, Monday through Friday, from 12:30 pm to 1:30 pm CST: Strapi Discord Open Office Hours.
For more details, visit the Strapi documentation and Lemon Squeezy documentation.
Use ngrok to create a secure tunnel to your local Strapi instance by running ngrok http 1337. Copy the HTTPS URL and configure it as your webhook endpoint in Lemon Squeezy's Settings → Webhooks. Enable Test Mode in Lemon Squeezy to simulate checkout flows without processing real payments. Use test card number 4242 4242 4242 4242 with any future expiry date and any 3-digit CVC for all test transactions.
Lemon Squeezy automatically handles payment failures through its dunning system, attempting 4 retries over a two-week period with increasing intervals. Your webhook receives subscription_payment_failed events during this time. The subscription status transitions from active to past_due to unpaid and finally cancelled if all retries fail. You don't need to implement retry logic; just update user access based on the status in your webhook handler.
Yes, Lemon Squeezy supports both payment models through the same integration. Subscriptions trigger subscription_created webhooks while one-time purchases trigger order_created webhooks. Configure different products in your Lemon Squeezy dashboard for each model. The webhook payload structure differs slightly: subscriptions include renews_at and status fields, while orders include download links and license keys. Your webhook service should handle both event types with separate processing logic.
As a merchant-of-record, Lemon Squeezy takes legal responsibility for tax compliance. The platform automatically detects customer location through IP geolocation and billing address, calculates applicable VAT, sales tax, or GST based on local regulations, and collects the correct amount during checkout. Tax information appears in webhook payloads with tax, tax_name, and tax_rate fields. You never need to implement tax calculation logic, maintain rate databases, or file tax returns for these transactions.
Use Lemon Squeezy's subscription update API endpoint with the new variant_id to change plans. By default, Lemon Squeezy calculates prorated amounts automatically: upgrades charge the difference immediately while downgrades apply credit to the next invoice. Set disable_prorations: true in your PATCH request if you want the change to take effect at the next billing cycle instead. The subscription_updated webhook fires after any plan change, allowing your Strapi service to update the user's tier and access permissions accordingly.