These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is Lemon Squeezy?
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.
Sign up for the Logbook, Strapi's Monthly newsletter
Why Integrate Lemon Squeezy with Strapi
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.
- Automatic tax compliance across 135+ countries: Lemon Squeezy operates as a merchant-of-record, automatically handling VAT, sales tax, and GST calculations and filing across 135+ supported countries. Your Strapi implementation never needs to build tax logic. Lemon Squeezy assumes all tax responsibility. Developers simply process the webhook events to sync subscription status and update user access permissions.
- Real-time content gating: When a
subscription_createdwebhook 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. - Decoupled scaling: Your content delivery infrastructure scales independently from payment processing. Traffic spikes on viral articles won't affect checkout flows, and flash sales won't overwhelm your CMS.
- Native TypeScript support on both sides: Strapi v5 provides factory functions with full typing, while Lemon Squeezy offers an official TypeScript SDK. Type-safe contracts across your entire payment flow reduce runtime surprises.
- Subscription lifecycle management via API: Pause, resume, upgrade, or cancel subscriptions programmatically. Build customer portals within Strapi's admin panel or expose self-service endpoints for your frontend.
- Event-driven architecture: Webhook events replace polling. Payment state changes propagate immediately to content access rules, enabling premium content delivery the moment a transaction completes.
How to Integrate Lemon Squeezy with Strapi
This step-by-step guide walks you through setting up webhooks, processing payments, and implementing content gating in your Strapi v5 application.
Prerequisites
Before starting, ensure you have:
- Node.js 18 or higher installed
- A Strapi v5 project (create one with
npx create-strapi@latest my-project) - A Lemon Squeezy account with at least one product configured
- Your Lemon Squeezy API key (Settings → API in your dashboard)
- A webhook signing secret (generated when creating a webhook endpoint)
- Ngrok or similar tool for local webhook testing
Step 1: Configure Environment Variables
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_secretExisting Strapi v5 Variables
According 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:
- Stored in
.envfiles (never committed to version control) - Rotated per environment (development, staging, production)
- Accessed through the
env()helper function in configuration files for type safety - At least 16 characters long for cryptographic keys
The 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'),
},
});Step 2: Extend the User Model
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.
Step 4: Build the Webhook Service
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,
}
});
}
}));Step 5: Configure Custom Routes
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: [],
},
},
],
};Step 6: Create a Checkout Session Endpoint
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,
},
},
],
};Step 7: Implement Content Access Middleware
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.
Step 8: Configure the Webhook in Lemon Squeezy
With your Strapi endpoint ready, configure Lemon Squeezy to send events:
- Navigate to Settings → Webhooks in your Lemon Squeezy dashboard
- Click "Add Webhook"
- Enter your endpoint URL:
https://your-domain.com/api/webhooks/lemonsqueezy - Select the events you want to receive:
order_createdsubscription_createdsubscription_updatedsubscription_cancelledsubscription_payment_failed
- Copy the signing secret and add it to your
.envfile - Save the webhook configuration
For 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.
Project Example: Premium Course Platform
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.
Data Model Architecture
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"
}Course Access Controller
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,
}
};
}
}));Frontend Checkout Integration
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';
}
}
});
}Subscription Management Service
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.
Strapi Open Office Hours
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.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.