These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is Paddle?
Paddle is a payment infrastructure platform that acts as the Merchant of Record (MoR) for transactions rather than simply processing payments. In this model, Paddle becomes the legal seller, handling payment collection, global tax compliance (sales tax and VAT), fraud prevention, and customer billing support. For developers, this architecture eliminates the need to build separate systems for tax calculation across jurisdictions or implement fraud detection logic.
The platform provides a REST API organized around five core entities—products, prices, customers, subscriptions, and transactions—plus an official Node.js SDK for server-side integration. Paddle.js handles frontend checkout experiences with support for 30 currencies and multiple payment methods, including credit cards, PayPal, Apple Pay, and Google Pay.
Why Integrate Paddle with Strapi
Combining Paddle with Strapi delivers four technical advantages that accelerate development for subscription-based applications: automated tax compliance handling, webhook-driven content access management, simplified subscription state synchronization, and reduced integration complexity through official SDKs.
- Automated tax compliance: Paddle's Merchant of Record model means tax calculations, collection, and remittance happen automatically without implementing custom tax logic. For applications serving multiple countries, this eliminates engineering overhead and compliance risk.
- Event-driven access control: Paddle webhooks (
subscription.created,subscription.updated,subscription.cancelled) send notifications to a Strapi webhook endpoint, where signature verification confirms authenticity. Custom controller logic processes verified events to update user roles and content permissions automatically. Users gain or lose access to premium content based on their subscription status without manual provisioning, through Strapi's role-based access control system that synchronizes with each webhook event. - Simplified state management: Paddle serves as the source of truth for payment state, while Strapi mirrors this data locally for fast content access queries. This unidirectional data flow prevents sync conflicts and reduces API calls during content requests.
- Reduced integration complexity: Official SDKs for both platforms handle authentication, error handling, and API versioning automatically. This is particularly valuable for full-stack developers who need to maintain both content management and payment processing without building custom HTTP clients.
How to Integrate Paddle with Strapi
This implementation creates a webhook endpoint that receives Paddle events, verifies their authenticity, and updates Strapi content types and user roles based on subscription status.
Prerequisites
Before starting the integration, ensure you have:
- Strapi v5 installed and running.
- Paddle account with API credentials from the Developer Tools section.
- Node.js environment for SDK compatibility.
- HTTPS endpoint for webhook delivery (use ngrok for local development).
Create Subscription Content Type
Start by defining a content type to track subscription data. Create api/subscription/content-types/subscription/schema.json:
{
"kind": "collectionType",
"collectionName": "subscriptions",
"info": {
"singularName": "subscription",
"pluralName": "subscriptions",
"displayName": "Subscription"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"paddleSubscriptionId": {
"type": "string",
"unique": true,
"required": true
},
"status": {
"type": "enumeration",
"enum": ["active", "paused", "cancelled", "past_due", "trialing"],
"required": true
},
"currentPeriodStart": {
"type": "datetime"
},
"currentPeriodEnd": {
"type": "datetime"
},
"planId": {
"type": "string"
},
"user": {
"type": "relation",
"relation": "oneToOne",
"target": "plugin::users-permissions.user"
}
}
}Setting draftAndPublish to false disables the draft/publish workflow for subscription records, which is appropriate because subscriptions represent transactional data that doesn't require editorial approval before becoming accessible.
Configure Custom Webhook Route
Create a custom route without authentication since external webhook services like Paddle cannot provide Strapi JWT tokens. The authentication is disabled and replaced with signature verification as the security mechanism. Create api/paddle-webhook/routes/paddle-webhook.js:
module.exports = {
routes: [
{
method: 'POST',
path: '/paddle-webhook',
handler: 'paddle-webhook.handleWebhook',
config: {
auth: false,
policies: [],
middlewares: [],
},
},
],
};The auth: false setting bypasses JWT authentication because external services like Paddle cannot provide Strapi JWT tokens. When authentication is disabled, webhook signature verification becomes the critical security mechanism, using HMAC-SHA256 cryptography to confirm webhooks originate from Paddle and to detect tampering.
Implement Signature Verification
Paddle signs all webhooks using HMAC-SHA256. Create a verification service in api/paddle-webhook/services/signature-verification.js:
const crypto = require('crypto');
module.exports = {
verifySignature(signature, body, webhookSecret) {
try {
const signatureParts = signature.split(';');
let timestamp, h1;
signatureParts.forEach(part => {
const [key, value] = part.split('=');
if (key === 'ts') timestamp = value;
if (key === 'h1') h1 = value;
});
if (!timestamp || !h1) {
return false;
}
// Validate timestamp to prevent replay attacks (5-minute tolerance)
const TOLERANCE = 300000; // 5 minutes in milliseconds
const eventTimestamp = parseInt(timestamp) * 1000;
const currentTime = Date.now();
if (Math.abs(currentTime - eventTimestamp) > TOLERANCE) {
return false;
}
// Construct signed payload per Paddle specification
const signedPayload = `${timestamp}:${JSON.stringify(body)}`;
// Compute HMAC-SHA256
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(signedPayload)
.digest('hex');
// Timing-safe comparison prevents timing attacks
return crypto.timingSafeEqual(
Buffer.from(h1),
Buffer.from(expectedSignature)
);
} catch (error) {
strapi.log.error('Signature verification error:', error);
return false;
}
}
};Timestamp validation prevents replay attacks by rejecting events outside a five-minute tolerance window from the current time.
Build Webhook Controller
Create the controller in api/paddle-webhook/controllers/paddle-webhook.js:
'use strict';
module.exports = {
async handleWebhook(ctx) {
const signature = ctx.request.headers['paddle-signature'];
const rawBody = ctx.request.body;
// Verify signature
const signatureService = strapi.service('api::paddle-webhook.signature-verification');
const isValid = signatureService.verifySignature(
signature,
rawBody,
process.env.PADDLE_WEBHOOK_SECRET
);
if (!isValid) {
strapi.log.error('Invalid Paddle webhook signature');
return ctx.badRequest('Invalid signature');
}
const { event_type, data } = rawBody;
try {
switch (event_type) {
case 'subscription.created':
await this.handleSubscriptionCreated(data);
break;
case 'subscription.updated':
await this.handleSubscriptionUpdated(data);
break;
case 'subscription.cancelled':
await this.handleSubscriptionCancelled(data);
break;
case 'transaction.completed':
await this.handleTransactionCompleted(data);
break;
case 'transaction.payment_failed':
await this.handlePaymentFailed(data);
break;
default:
strapi.log.info(`Unhandled event type: ${event_type}`);
}
ctx.status = 200;
ctx.body = { received: true };
} catch (error) {
strapi.log.error('Webhook processing error:', error);
ctx.status = 500;
ctx.body = { error: 'Processing failed' };
}
},
async handleSubscriptionCreated(data) {
const { id, customer_id, status, items, current_billing_period } = data;
const user = await strapi.documents('plugin::users-permissions.user').findFirst({
filters: { paddleCustomerId: { $eq: customer_id } },
});
if (!user) {
strapi.log.error(`User not found for customer_id: ${customer_id}`);
return;
}
// Create subscription record using Document Service API
await strapi.documents('api::subscription.subscription').create({
data: {
paddleSubscriptionId: id,
status: status,
planId: items[0]?.price?.product_id || null,
currentPeriodStart: current_billing_period?.starts_at,
currentPeriodEnd: current_billing_period?.ends_at,
user: user.documentId,
},
});
// Update user role to 'paid'
const paidRole = await strapi.documents('plugin::users-permissions.role').findFirst({
filters: { type: { $eq: 'paid' } },
});
if (paidRole) {
await strapi.documents('plugin::users-permissions.user').update(user.documentId, {
data: { role: paidRole.documentId },
});
}
strapi.log.info(`Subscription created for user ${user.documentId}`);
},
async handleSubscriptionUpdated(data) {
const { id, status, current_billing_period } = data;
const subscription = await strapi.documents('api::subscription.subscription').findFirst({
filters: { paddleSubscriptionId: { $eq: id } },
populate: ['user'],
});
if (!subscription) {
strapi.log.error(`Subscription not found: ${id}`);
return;
}
// Update subscription status and billing period
await strapi.documents('api::subscription.subscription').update(subscription.documentId, {
data: {
status: status,
currentPeriodStart: current_billing_period?.starts_at,
currentPeriodEnd: current_billing_period?.ends_at,
},
});
// Synchronize user role
if (status === 'active') {
const paidRole = await strapi.documents('plugin::users-permissions.role').findFirst({
filters: { type: { $eq: 'paid' } },
});
if (paidRole && subscription.user) {
await strapi.documents('plugin::users-permissions.user').update(subscription.user.documentId, {
data: { role: paidRole.documentId },
});
}
} else if (status === 'cancelled' || status === 'past_due') {
const freeRole = await strapi.documents('plugin::users-permissions.role').findFirst({
filters: { type: { $eq: 'authenticated' } },
});
if (freeRole && subscription.user) {
await strapi.documents('plugin::users-permissions.user').update(subscription.user.documentId, {
data: { role: freeRole.documentId },
});
}
}
strapi.log.info(`Subscription updated: ${id} - Status: ${status}`);
},
async handleSubscriptionCancelled(data) {
const { id } = data;
const subscription = await strapi.documents('api::subscription.subscription').findFirst({
filters: { paddleSubscriptionId: { $eq: id } },
populate: ['user'],
});
if (!subscription) return;
await strapi.documents('api::subscription.subscription').update(subscription.documentId, {
data: { status: 'cancelled' },
});
// Downgrade to free role
const freeRole = await strapi.documents('plugin::users-permissions.role').findFirst({
filters: { type: { $eq: 'authenticated' } },
});
if (freeRole && subscription.user) {
await strapi.documents('plugin::users-permissions.user').update(subscription.user.documentId, {
data: { role: freeRole.documentId },
});
}
},
async handleTransactionCompleted(data) {
const { subscription_id, status } = data;
if (status === 'completed' && subscription_id) {
const subscription = await strapi.documents('api::subscription.subscription').findFirst({
filters: { paddleSubscriptionId: { $eq: subscription_id } },
populate: ['user'],
});
if (subscription && subscription.status !== 'active') {
await strapi.documents('api::subscription.subscription').update(subscription.documentId, {
data: { status: 'active' },
});
const paidRole = await strapi.documents('plugin::users-permissions.role').findFirst({
filters: { type: { $eq: 'paid' } },
});
if (paidRole && subscription.user) {
await strapi.documents('plugin::users-permissions.user').update(subscription.user.documentId, {
data: { role: paidRole.documentId },
});
}
}
}
strapi.log.info(`Transaction completed for subscription: ${subscription_id}`);
},
async handlePaymentFailed(data) {
const { subscription_id } = data;
if (subscription_id) {
const subscription = await strapi.documents('api::subscription.subscription').findFirst({
filters: { paddleSubscriptionId: { $eq: subscription_id } },
populate: ['user'],
});
if (subscription) {
await strapi.documents('api::subscription.subscription').update(subscription.documentId, {
data: { status: 'past_due' },
});
}
}
strapi.log.error(`Payment failed for subscription: ${subscription_id}`);
}
};The controller responds within Paddle's 5-second timeout requirement by returning HTTP 200 immediately, then processing business logic asynchronously since synchronous processing would cause timeouts.
Configure Environment Variables
Add Paddle credentials to .env:
PADDLE_API_KEY=your_paddle_api_key_here
PADDLE_WEBHOOK_SECRET=your_webhook_secret_key_here
NODE_ENV=productionNever commit these values to version control. Generate API keys in the Paddle Dashboard under Developer Tools → Authentication, and retrieve webhook secrets from Developer Tools → Notifications. Store both securely in environment variables and never expose them in frontend code or version control systems.
Set Up Role-Based Access Control
Create a "Paid" role in the Strapi Admin Panel by navigating to Settings → Users & Permissions Plugin → Roles, then create a new role with type: 'paid' and configure permissions to grant access to premium content types.
- Navigate to the Strapi admin panel and access the Users & Permissions plugin to create a new role.
- Create a new role with
type: 'paid'to distinguish premium subscribers from free-tier users. - Configure permissions for this role to grant access to premium content types you've created in your Strapi backend.
Role assignment is handled automatically through webhook-triggered controller logic when subscriptions are created or updated via Paddle webhooks, with developers implementing custom handlers that update user roles based on subscription status changes.
Configure Webhook Endpoint in Paddle
Set up the webhook in Paddle Dashboard:
- Navigate to Developer Tools → Notifications.
- Add endpoint URL:
https://your-domain.com/api/paddle-webhook. - Select event types to receive.
- Save webhook secret key for signature verification.
For local development, use ngrok to create an HTTPS tunnel:
ngrok http 1337This command creates a secure tunnel to your Strapi application running on port 1337, generating a temporary URL like https://your-ngrok-url.ngrok.io that Paddle can use to deliver webhooks during local development.
Configure the ngrok URL in Paddle, then test webhook delivery using Paddle's webhook simulator.
Implement Frontend Checkout
On the frontend, load Paddle.js and initialize checkout. Create a checkout component:
import { useEffect } from 'react';
function CheckoutButton({ priceId, userEmail }) {
useEffect(() => {
// Paddle.js script must be loaded via Script component
// before Initialize is called
if (window.Paddle) {
window.Paddle.Initialize({
token: process.env.NEXT_PUBLIC_PADDLE_TOKEN,
eventCallback: (event) => {
if (event.name === 'checkout.completed') {
// Handle successful checkout
const transactionId = event.data.transaction_id;
window.location.href = `/success?transaction=${transactionId}`;
}
}
});
}
}, []);
const openCheckout = () => {
window.Paddle.Checkout.open({
items: [{ priceId: priceId, quantity: 1 }],
customer: {
email: userEmail
}
});
};
return (
<>
<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
<button onClick={openCheckout}>Subscribe Now</button>
</>
);
}Pass the Paddle client-side token (not API key) to frontend code. Fetch user email from Strapi's REST API or GraphQL API to prefill checkout.
Project Example: Membership Content Platform
Consider building a technical documentation platform where premium subscribers access advanced implementation guides, code examples, and video tutorials. Free users see basic documentation, while paid members unlock premium content based on their Paddle subscription tier.
This requires implementing webhook-driven content access management: when Paddle sends subscription lifecycle events (subscription.created, subscription.updated, subscription.cancelled) to a custom Strapi webhook endpoint, Paddle signature verification confirms authenticity. Then, the webhook handler updates user roles in Strapi to grant or revoke access to premium content collections.
The architecture maintains synchronized subscription state between Paddle (payment processor) and Strapi (content CMS) through event-driven webhooks with idempotency checks to handle Paddle's automatic retry logic, ensuring premium content access remains current with subscription status without requiring API calls on every request.
Architecture Overview
The integration architecture consists of three core layers: the Paddle payment processing layer (handling RESTful API requests, webhook events, and Merchant of Record services), the Strapi content management layer (storing subscriptions, managing user roles, and exposing custom webhook endpoints), and the frontend layer (initializing Paddle.js checkout and handling customer interactions).
- Frontend: Next.js application consuming Strapi's API.
- Content Management: Strapi storing documentation articles, tutorials, and user data.
- Payment Processing: Paddle handling subscriptions and webhook notifications.
Content Model Design
Create two content types in Strapi:
Article Content Type (api/article/content-types/article/schema.json):
{
"kind": "collectionType",
"collectionName": "articles",
"info": {
"singularName": "article",
"pluralName": "articles",
"displayName": "Article"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"title": {
"type": "string",
"required": true
},
"content": {
"type": "richtext",
"required": true
},
"tier": {
"type": "enumeration",
"enum": ["free", "basic", "premium"],
"default": "free"
},
"slug": {
"type": "uid",
"targetField": "title"
},
"category": {
"type": "relation",
"relation": "manyToOne",
"target": "api::category.category"
}
}
}Access this content type using Document Service API in Strapi v5:
// Find articles by tier (server-side controller)
const articles = await strapi.documents('api::article.article').findMany({
filters: { tier: { $eq: 'premium' } },
populate: '*'
});Subscription tier mapping:
- Free tier: Access to
tier: "free"articles only. - Basic tier: Access to
tier: "free"andtier: "basic"articles. - Premium tier: Access to all articles including
tier: "premium"content.
Subscription Flow Implementation
When a user subscribes, the following sequence occurs:
- User clicks "Upgrade to Premium" button on frontend.
- Button triggers Paddle checkout with the premium price ID.
- User completes payment in Paddle's hosted checkout.
- Paddle sends
subscription.createdwebhook to Strapi. - Strapi webhook handler verifies signature.
- Handler creates subscription record linked to user.
- Handler updates user role to 'premium'.
- User refreshes page and sees premium content.
Content Access Control
Configure permissions in Strapi Admin Panel:
Public Role:
- Read access to articles where
tier = "free".
Authenticated Role:
- Read access to articles where
tier = "free".
Basic Role:
- Read access to articles where
tier = "free"ortier = "basic".
Paid User Role:
- Read access to all articles regardless of subscription tier.
Frontend Implementation
Fetch content based on user's authentication status:
async function getArticles(token) {
const response = await fetch('https://your-strapi.com/api/articles?populate=*', {
headers: {
'Authorization': `Bearer ${token}`
}
});
const data = await response.json();
return data;
}Note: This example uses the traditional REST API pattern for frontend requests. For Strapi v5 backend controllers, the official Document Service API should be used:
// Strapi v5 Document Service API (server-side only)
const articles = await strapi.documents('api::article.article').findMany({
populate: '*'
});The Strapi Content API automatically filters results based on the user's role, ensuring users only receive content they're authorized to access.
Handling Subscription Changes
When subscriptions are cancelled or payments fail, Paddle sends webhooks that update user roles:
Payment Failure: transaction.payment_failed webhook updates subscription status to past_due and triggers automatic role downgrade to prevent access to premium content, reverting the user to the free tier until payment is resolved.
Cancellation: subscription. cancelled webhook updates status to cancelled and downgrades role to 'authenticated', removing premium access immediately.
Renewal: transaction.completed webhook confirms successful payment processing, which should maintain active subscription status and premium role assignment when combined with subscription.updated webhooks for comprehensive subscription management.
Testing the Integration
Test critical subscription scenarios:
- New Subscription: Create test subscription in Paddle sandbox, verify user receives premium role and subscription record is created in Strapi.
- Failed Payment: Trigger payment failure in sandbox, confirm subscription status updates to
past_dueand content access is appropriately restricted (note: Paddle's sandbox environment exhibits inconsistent webhook delivery for payment failure events, requiring production testing for reliability validation). - Cancellation: Cancel subscription in sandbox, verify subscription status updates to
cancelledand user role reverts to authenticated tier. - Upgrade/Downgrade: Switch between subscription tiers in sandbox, confirm subscription items update and user role adjusts to match new tier access level.
Use Paddle's webhook simulator to test event handling without processing actual payments.
Production Considerations
Before deploying to production:
- Configure separate Paddle accounts for sandbox and production environments.
- Store API credentials in environment variables (using Strapi's environment configuration as documented in the official documentation).
- Set up monitoring for webhook processing failures using Strapi's logging and error handling patterns.
- Implement daily reconciliation jobs to verify subscription states match between Paddle and Strapi, querying Paddle's REST API endpoints to validate webhook-synced data.
- Configure request timeout and rate limiting on custom API endpoints in Strapi route configurations.
- Enable database backup features for subscription data through your hosting provider or database management system.
- Test webhook signature verification thoroughly using HMAC-SHA256 verification as specified in https://developer.paddle.com/webhooks/signature-verification to prevent unauthorized access.
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 Paddle 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.