These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is Polar?
Polar is a monetization and payments platform built for developers. It functions as a Merchant of Record, which means Polar takes on the legal responsibility for processing payments, handling global tax compliance, and managing billing infrastructure on your behalf.
The platform supports three payment models: recurring subscriptions, usage-based billing, and one-time payments. It also includes a benefits delivery system for managing digital entitlements tied to those payments.
For developers, the key draw is the API-first architecture. Polar exposes endpoints (and a TypeScript SDK) for managing subscriptions and configuring webhooks, and provides an official Python SDK; however, its documentation does not confirm dedicated checkout-session or customer-data endpoints, nor official SDKs for PHP or Go.
The TypeScript SDK (@polar-sh/sdk) is particularly relevant for Strapi integrations, offering full type safety and tree-shakeable module imports.
Sign up for the Logbook, Strapi's Monthly newsletter
Why Integrate Polar with Strapi
Pairing Polar's billing infrastructure with Strapi's content management capabilities creates a stack where payment logic and content delivery share a single backend.
- Subscription-gated content without custom billing code. Polar manages the entire subscription lifecycle—creation, renewals, cancellations—while Strapi controls which content is accessible at each tier.
- Real-time sync through webhooks. Polar's webhook events notify your Strapi instance the moment a subscription changes status, so content access stays current without polling.
- Global tax compliance handled for you. As a Merchant of Record, Polar manages VAT, sales tax, and regional compliance, removing a significant operational burden from your team.
- Strapi v5's Document Service API simplifies data management. The Document Service gives you a clean interface for creating and updating subscription records directly from webhook handlers.
- Flexible monetization models. Whether you need recurring subscriptions for a membership site, one-time purchases for premium guides, or usage-based billing for API access, the same integration pattern applies.
- Developer-friendly SDK on both sides. Polar's TypeScript SDK and Strapi's factory functions (
createCoreController,createCoreService) reduce boilerplate and provide type safety across the integration.
How to Integrate Polar with Strapi
Prerequisites
Before starting, confirm you have the following:
- Node.js 18+ (LTS recommended)
- Strapi v5 project initialized (
npx create-strapi@latest) - Polar account with an organization created at polar.sh
- Polar Organization Access Token generated from your Polar dashboard
- At least one Polar product configured with pricing (subscription or one-time)
- A tool for testing webhooks locally — Polar provides a CLI for this, or you can use a tunneling service
You should be comfortable with Strapi's custom controller and service patterns. If those are new territory, the Strapi docs cover both thoroughly.
Step 1: Install the Polar SDK
From your Strapi project root, add the Polar TypeScript SDK:
npm install @polar-sh/sdkThis installs @polar-sh/sdk, which provides typed methods for checkout sessions, subscriptions, customers, and webhook verification.
Step 2: Configure Environment Variables
Add your Polar credentials to the .env file at your Strapi project root. Never hardcode these values in source files.
# .env
POLAR_ACCESS_TOKEN=your_organization_access_token_here
POLAR_WEBHOOK_SECRET=your_webhook_secret_here
POLAR_ENVIRONMENT=productionUse Polar's sandbox environment by switching the API base URL to https://sandbox-api.polar.sh/v1 or by passing server: 'sandbox' to the official SDKs during development.
Strapi's built-in env() helper provides type casting and fallback values when you reference these variables in configuration files:
// Accessing env vars in Strapi config files
env('POLAR_ACCESS_TOKEN')
env('POLAR_ENVIRONMENT', 'sandbox')Keep your .env file out of version control. Strapi's environment configuration docs cover additional security practices for production deployments.
Step 3: Create a Polar Service in Strapi
Encapsulate all Polar SDK interactions in a dedicated Strapi service. This keeps your controllers clean and makes the Polar client reusable across multiple endpoints.
Create the file ./src/api/polar/services/polar.js:
// ./src/api/polar/services/polar.js
const { Polar } = require('@polar-sh/sdk');
module.exports = ({ strapi }) => {
const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN,
server: process.env.POLAR_ENVIRONMENT || 'sandbox',
});
return {
getClient() {
return polar;
},
async createCheckoutSession({ productId, customerEmail, successUrl }) {
const result = await polar.checkouts.create({
productId,
customerEmail,
successUrl,
});
return result;
},
async getSubscription(subscriptionId) {
const subscription = await polar.subscriptions.get(subscriptionId);
return subscription;
},
async listSubscriptions(filters = {}) {
const subscriptions = await polar.subscriptions.list(filters);
return subscriptions;
},
validateWebhookEvent(payload, signature) {
return polar.webhooks.validateEvent(
payload,
signature,
process.env.POLAR_WEBHOOK_SECRET
);
},
};
};This service initializes the Polar client once and exposes methods for creating checkout sessions, querying subscriptions, and validating incoming webhooks.
Step 4: Create a Subscription Content-Type
You need a place in Strapi to store subscription records that map Polar subscription data to your users. Use the Strapi CLI or the Admin Panel to create a new Collection Type called Subscription with these fields:
| Field | Type | Description |
|---|---|---|
polarSubscriptionId | Text (unique) | The subscription ID from Polar |
polarCustomerId | Text | The customer ID from Polar |
customerEmail | Subscriber's email address | |
status | Enumeration | Values such as active, canceled, past_due, trialing, and others as documented by Polar |
productId | Text | The Polar product this subscription is for |
After creating the content-type through the Admin Panel, Strapi generates the API files automatically. The Document Service API will handle all CRUD operations on this collection using documentId — the v5 identifier that replaces numeric id.
Step 5: Build the Webhook Handler
This is where the integration comes together. Polar sends webhook events when subscriptions change state. Your Strapi instance needs a custom endpoint to receive and process those events.
Create the route file at ./src/api/polar/routes/polar.js:
// ./src/api/polar/routes/polar.js
module.exports = {
routes: [
{
method: 'POST',
path: '/polar/webhook',
handler: 'polar.handleWebhook',
config: {
auth: false,
},
},
{
method: 'POST',
path: '/polar/checkout',
handler: 'polar.createCheckout',
config: {
auth: false,
},
},
],
};The auth: false setting on the webhook route is intentional—Polar can't authenticate with Strapi's API tokens. Signature verification (next step) handles security instead.
Now create the controller at ./src/api/polar/controllers/polar.js:
// ./src/api/polar/controllers/polar.js
module.exports = ({ strapi }) => ({
async handleWebhook(ctx) {
const signature = ctx.request.headers['webhook-signature'];
const polarService = strapi.service('api::polar.polar');
if (!signature) {
ctx.status = 400;
ctx.body = { error: 'Missing webhook-signature header' };
return;
}
let event;
try {
event = validateEvent(
ctx.request.body,
ctx.request.headers,
process.env['POLAR_WEBHOOK_SECRET'] ?? ''
);
} catch (error) {
strapi.log.error('Polar webhook signature verification failed:', error);
ctx.status = 401;
ctx.body = { error: 'Invalid signature' };
return;
}
// Respond immediately — Polar expects a fast acknowledgment
ctx.status = 200;
ctx.body = { received: true };
// Process the event asynchronously
setImmediate(async () => {
try {
await processWebhookEvent(strapi, event);
} catch (error) {
strapi.log.error('Webhook processing error:', error);
}
});
},
async createCheckout(ctx) {
const { productId, customerEmail, successUrl } = ctx.request.body;
const polarService = strapi.service('api::polar.polar');
try {
const session = await polarService.createCheckoutSession({
productId,
customerEmail,
successUrl,
});
ctx.body = { checkoutUrl: session.url };
} catch (error) {
strapi.log.error('Checkout creation failed:', error);
ctx.status = 500;
ctx.body = { error: 'Failed to create checkout session' };
}
},
});
async function processWebhookEvent(strapi, event) {
const { type, data } = event;
switch (type) {
case 'subscription.active':
await handleSubscriptionActive(strapi, data);
break;
case 'subscription.canceled':
await handleSubscriptionCanceled(strapi, data);
break;
case 'subscription.past_due':
await handleSubscriptionPastDue(strapi, data);
break;
case 'subscription.revoked':
await handleSubscriptionRevoked(strapi, data);
break;
case 'order.paid':
strapi.log.info(`Order paid: ${data.order_id}`);
break;
default:
strapi.log.info(`Unhandled Polar event: ${type}`);
}
}
async function handleSubscriptionActive(strapi, data) {
const existing = await strapi
.documents('api::subscription.subscription')
.findMany({
filters: { polarSubscriptionId: data.subscription_id },
});
if (existing.length > 0) {
await strapi.documents('api::subscription.subscription').update({
documentId: existing[0].documentId,
data: { status: 'active' },
});
return;
}
await strapi.documents('api::subscription.subscription').create({
data: {
polarSubscriptionId: data.subscription_id,
polarCustomerId: data.customer_id,
customerEmail: data.customer_email,
status: 'active',
productId: data.product_id,
},
});
}
async function handleSubscriptionCanceled(strapi, data) {
const existing = await strapi
.documents('api::subscription.subscription')
.findMany({
filters: { polarSubscriptionId: data.subscription_id },
});
if (existing.length > 0) {
await strapi.documents('api::subscription.subscription').update({
documentId: existing[0].documentId,
data: { status: 'canceled' },
});
}
}
async function handleSubscriptionPastDue(strapi, data) {
const existing = await strapi
.documents('api::subscription.subscription')
.findMany({
filters: { polarSubscriptionId: data.subscription_id },
});
if (existing.length > 0) {
await strapi.documents('api::subscription.subscription').update({
documentId: existing[0].documentId,
data: { status: 'past_due' },
});
}
}
async function handleSubscriptionRevoked(strapi, data) {
const existing = await strapi
.documents('api::subscription.subscription')
.findMany({
filters: { polarSubscriptionId: data.subscription_id },
});
if (existing.length > 0) {
await strapi.documents('api::subscription.subscription').update({
documentId: existing[0].documentId,
data: { status: 'revoked' },
});
}
}A few things worth noting here. The controller responds with a fast 2xx (often 202) immediately before processing—this quick acknowledgment is critical for reliable delivery. Polar retries webhooks up to 10 times with exponential backoff, and slow responses trigger unnecessary retries. The findMany + check pattern provides basic idempotency: if a subscription.active event arrives twice, the second call updates instead of creating a duplicate.
Step 6: Configure Polar Webhook Endpoint
With your Strapi server running, register the webhook URL in your Polar dashboard or programmatically through the Webhooks API.
Your endpoint URL follows the pattern:
https://your-strapi-domain.com/api/polar/webhookSubscribe to these events at minimum:
subscription.activesubscription.canceledsubscription.past_duesubscription.revokedorder.paid
For local development, Polar provides a CLI tool that relays webhooks to your local server:
npm install -g @polar-sh/cli
polar webhooks relay --port 3000Step 7: Test the Integration
Start your Strapi dev server:
npm run developTest the checkout endpoint by sending a POST request:
curl -X POST http://localhost:1337/api/polar/checkout \
-H "Content-Type: application/json" \
-d '{
"productId": "your_polar_product_id",
"customerEmail": "test@example.com",
"successUrl": "http://localhost:3000/success"
}'This should return a JSON response with a checkoutUrl. Open that URL in a browser to complete a test purchase (use Polar's sandbox environment for this). After completing checkout, the webhook fires a subscription.active event, and your Strapi Subscription collection should show a new record.
Verify the record through the REST API:
curl http://localhost:1337/api/subscriptionsProject Example: Subscription-Gated Article Platform
This example builds a working content paywall where free articles are publicly accessible and premium articles require an active Polar subscription. Strapi manages the content and subscription records; Polar handles billing.
Content-Type Setup
Create an Article Collection Type in Strapi with these fields:
| Field | Type | Description |
|---|---|---|
title | Text | Article headline |
slug | UID (linked to title) | URL-friendly identifier |
content | Rich Text | Full article body |
excerpt | Text (long) | Preview shown to non-subscribers |
accessTier | Enumeration | free or premium |
You already have the Subscription collection from Step 4. These two collections are the foundation—Articles hold the content, Subscriptions track who has paid.
Custom Service for Access Verification
Create a service that checks subscription status before serving premium content. This lives at ./src/api/article/services/article.js:
// ./src/api/article/services/article.js
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::article.article', ({ strapi }) => ({
async findOneWithAccess({ documentId, customerEmail }) {
const article = await strapi
.documents('api::article.article')
.findOne({ documentId });
if (!article) {
return null;
}
// Free articles are always fully accessible
if (article.accessTier === 'free') {
return { ...article, hasAccess: true };
}
// For premium articles, check subscription status
if (!customerEmail) {
return {
title: article.title,
excerpt: article.excerpt,
accessTier: article.accessTier,
hasAccess: false,
};
}
const activeSubscriptions = await strapi
.documents('api::subscription.subscription')
.findMany({
filters: {
customerEmail,
status: 'active',
},
});
if (activeSubscriptions.length > 0) {
return { ...article, hasAccess: true };
}
// Subscriber exists but doesn't have active subscription
return {
title: article.title,
excerpt: article.excerpt,
accessTier: article.accessTier,
hasAccess: false,
};
},
async findPublicList(params = {}) {
const articles = await strapi
.documents('api::article.article')
.findMany({
fields: ['title', 'slug', 'excerpt', 'accessTier'],
sort: 'createdAt:desc',
...params,
});
return articles;
},
}));The findOneWithAccess method is the gating logic. Premium articles return only the title and excerpt unless the requesting email maps to an active subscription.
Custom Controller for Gated Access
Override the default findOne behavior and add a public listing endpoint. Create ./src/api/article/controllers/article.js:
// ./src/api/article/controllers/article.js
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::article.article', ({ strapi }) => ({
async findOne(ctx) {
const { id: documentId } = ctx.params;
const customerEmail = ctx.query.email || null;
const article = await strapi
.service('api::article.article')
.findOneWithAccess({ documentId, customerEmail });
if (!article) {
return ctx.notFound('Article not found');
}
ctx.body = { data: article };
},
async find(ctx) {
const articles = await strapi
.service('api::article.article')
.findPublicList({
pagination: {
page: ctx.query.page || 1,
pageSize: ctx.query.pageSize || 10,
},
});
ctx.body = { data: articles };
},
}));Custom Routes
Configure the core router to expose these endpoints publicly. In Strapi v4, ./src/api/article/routes/article.js holds the core router (custom routes would go in a separate file like custom-article.js):
// ./src/api/article/routes/article.js
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::article.article', {
config: {
find: {
auth: false,
},
findOne: {
auth: false,
},
},
});Lifecycle Hook for New Premium Content Notifications
When editors publish a premium article, you might want to notify subscribers. Lifecycle hooks handle this cleanly. Create ./src/api/article/content-types/article/lifecycles.js:
// ./src/api/article/content-types/article/lifecycles.js
module.exports = {
async afterCreate(event) {
const { result } = event;
if (result.accessTier !== 'premium') {
return;
}
// Fetch all active subscribers
const activeSubscribers = await strapi
.documents('api::subscription.subscription')
.findMany({
filters: { status: 'active' },
});
strapi.log.info(
`New premium article "${result.title}" published. ` +
`${activeSubscribers.length} active subscribers to notify.`
);
// Integrate with your email service here
// e.g., send notification emails to activeSubscribers
},
};Frontend Integration
On the client side, the flow works like this:
- List articles by calling
GET /api/articles— returns titles, excerpts, and access tiers for all articles. - Read an article by calling
GET /api/articles/:documentId?email=user@example.com— returns full content if the email has an active subscription, or just the excerpt withhasAccess: false. - Start a subscription by calling
POST /api/polar/checkoutwith aproductIdandcustomerEmail— returns a Polar checkout URL. - After payment, Polar's webhook fires
subscription.active, Strapi creates the subscription record, and subsequent article requests for that email return full content.
Here's a minimal frontend example for the checkout flow:
// Frontend: initiate checkout
async function startSubscription(email) {
const response = await fetch('https://your-strapi-domain.com/api/polar/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
productId: 'your_polar_product_id',
customerEmail: email,
successUrl: `${window.location.origin}/welcome`,
}),
});
const { checkoutUrl } = await response.json();
window.location.href = checkoutUrl;
}
// Frontend: fetch article with access check
async function fetchArticle(documentId, email) {
const url = new URL(`https://your-strapi-domain.com/api/articles/${documentId}`);
if (email) url.searchParams.set('email', email);
const response = await fetch(url);
const { data } = await response.json();
if (!data.hasAccess) {
// Show excerpt and subscription prompt
return { preview: true, ...data };
}
// Show full article
return { preview: false, ...data };
}This architecture keeps billing concerns entirely on the backend. The frontend never touches Polar API keys or subscription validation logic—it just passes an email and reacts to the hasAccess flag.
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 Polar 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.