Connect Polar's developer-first monetization platform with Strapi v5 to manage subscriptions, process payments, and gate premium content. This guide covers SDK setup, webhook handling, and a complete paywall project example

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.
Pairing Polar's billing infrastructure with Strapi's content management capabilities creates a stack where payment logic and content delivery share a single backend.
createCoreController, createCoreService) reduce boilerplate and provide type safety across the integration.Before starting, confirm you have the following:
npx create-strapi@latest)You should be comfortable with Strapi's custom controller and service patterns. If those are new territory, the Strapi docs cover both thoroughly.
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.
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.
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.
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.
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.
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.paidFor local development, Polar provides a CLI tool that relays webhooks to your local server:
npm install -g @polar-sh/cli
polar webhooks relay --port 3000Start 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/subscriptionsThis 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.
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.
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.
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 };
},
}));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,
},
},
});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
},
};On the client side, the flow works like this:
GET /api/articles — returns titles, excerpts, and access tiers for all articles.GET /api/articles/:documentId?email=user@example.com — returns full content if the email has an active subscription, or just the excerpt with hasAccess: false.POST /api/polar/checkout with a productId and customerEmail — returns a Polar checkout URL.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.
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.
Yes. Polar provides a dedicated sandbox API at https://sandbox-api.polar.sh/v1 for testing without processing real payments. Configure the Polar SDK client with server: "sandbox" (or the equivalent SDK option) to route all requests to the sandbox environment, for example:
const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
server: "sandbox", // omit or use 'production' for live
});Generate a separate Organization Access Token for the sandbox environment. This lets you test the full checkout-to-webhook cycle, including subscription creation and cancellation, without any financial transactions.
Polar follows the Standard Webhooks specification, using HMAC-SHA256 for cryptographic signing. Each webhook request includes a webhook-signature header. The @polar-sh/sdk provides a validateEvent method that compares this signature against your webhook secret using timing-safe comparison to prevent timing attacks. In Strapi, you handle this inside a custom controller with auth: false on the route, since Polar authenticates via signature rather than API tokens.
Polar automatically retries webhook deliveries up to 10 times using exponential backoff. If a webhook endpoint has 10+ consecutive failed deliveries (non‑2xx responses), Polar automatically disables the endpoint. Once disabled, it will no longer receive events and must be manually re‑enabled from the Polar dashboard after the issue is fixed. To avoid this, respond with a 200 status immediately and process events asynchronously. Implementing idempotent handlers—checking for existing records before creating new ones—ensures that duplicate deliveries from retries don't corrupt your Strapi subscription data.
Yes. Strapi v5 replaced the Entity Service with the Document Service API. All data operations now use strapi.documents() and reference records by documentId rather than numeric id. The webhook handlers and service methods in this guide use Document Service patterns exclusively. If you encounter older tutorials referencing strapi.entityService, those are v4 patterns and won't work with Strapi v5's architecture.
Polar supports usage-based billing alongside subscriptions and one-time payments. You can track consumption metrics—like API calls or content downloads—within Strapi using a custom collection type, then report usage to Polar through its API. The webhook integration pattern remains the same: Polar sends events when billing thresholds are reached or invoices are generated, and your Strapi handlers update records accordingly. The service layer described in this guide adapts directly to usage-based flows.