Managing subscription billing across multiple providers gets tangled fast. Plan metadata lives in one place, payment logic in another, and subscription status in a third. When a webhook fires at 2 AM and your database disagrees with Stripe about whether a customer is active, you're debugging across three systems with no single source of truth.
This tutorial walks through building a subscription billing app with Strapi 5 as a headless CMS, Stripe for direct payment processing, and Polar as a merchant-of-record alternative. You'll model subscription plans, build checkout flows, handle webhooks from both providers, and sync subscription state back to your CMS.
In Brief
- Model subscription plans and customer data in Strapi 5, then expose them through the REST API to power a billing frontend.
- Integrate Stripe for direct payment processing and Polar as a merchant-of-record alternative that handles tax compliance and billing infrastructure.
- Build custom Strapi routes and controllers to create checkout sessions, verify webhook signatures, and sync subscription state back to your CMS.
- Handle the full subscription lifecycle: upgrades, downgrades, cancellations, and failed payment recovery across both providers.
Why Pair Strapi with Stripe and Polar for Subscription Billing
A subscription billing stack needs three layers: plan content management, payment processing, and compliance infrastructure. Strapi 5 covers the first layer with a structured API for pricing tiers, feature lists, marketing copy, and billing intervals. Content editors update plan metadata through the Admin Panel without touching code, and the frontend queries a single API to render a pricing page.
Stripe Billing works well for teams that want full control over the merchant relationship. You own the customer billing experience, but you also own tax compliance, chargeback liability, and invoice generation.
Polar takes a different approach. As a merchant of record, Polar sits between you and the customer as the legal seller. It handles billing and compliance infrastructure automatically. The tradeoff is a higher per-transaction fee versus the engineering cost of building compliance infrastructure yourself.
The three services fill complementary roles in the billing stack:
- Stripe gives you payment rails.
- Polar adds a compliance layer.
- Strapi gives you a single source of truth for plan metadata.
That combination works whether you use one or both payment services depending on the use case.
Prerequisites and Project Setup
The billing app runs on Strapi 5 with two payment providers, so there are a few moving parts to configure before the first line of billing code.
Tech Stack and Versions
The billing app relies on Strapi 5 as the content backend, with Stripe and Polar handling payment processing on separate tracks. Both payment providers need SDK packages installed in the Strapi project, and each requires its own account with API credentials configured. Make sure the following are in place before starting:
- Node.js v20, v22, or v24 (Long-Term Support) are supported. Odd-numbered releases like v23 or v25 aren't compatible.
- Strapi 5 (latest), scaffolded via
npx create-strapi@latest - Stripe account and the
stripenpm package - Polar account (sandbox) and
@polar-sh/sdk(npm package) - A frontend of your choice. Next.js 16 works well, but the Strapi and payment logic covered here is framework-agnostic. .
Scaffold the Strapi Project
Run the Strapi project generator from your terminal. The command creates a new directory with the full Strapi 5 boilerplate, including the Admin Panel, default middleware stack, and a local SQLite database:
bash
npx create-strapi@latest subscription-billingThe CLI will prompt you for a few configuration choices. SQLite is the default database for local development, which is fine for this tutorial. Once installation completes, start the dev server:
bash
cd subscription-billing
npm run developRegister your first admin account at http://localhost:1337/admin. Two things worth noting about Strapi 5 if you're coming from v4: the REST API uses a flattened response format with no .data.attributes wrapper, and content entries use documentId instead of numeric id as their primary identifier.
Sign up for the Logbook, Strapi's Monthly newsletter
Model Subscription Plans in Strapi
Subscription billing requires two content structures: one to define plan tiers and another to track active subscriptions per customer.
Create the Plan Collection Type
Open the Content-Type Builder in the Admin Panel and create a new Collection Type called Plan with these fields:
| Field | Type | Notes |
|---|---|---|
name | Text | e.g., "Starter", "Pro", "Enterprise" |
slug | UID (attached to name) | Auto-generated URL-friendly identifier |
price | Number (decimal) | Monthly price |
billingInterval | Enumeration: monthly, yearly | Billing cadence |
features | JSON or Repeatable Component | List of included features |
stripeProductId | Text | Maps to Stripe Product |
stripePriceId | Text | Maps to Stripe Price |
polarProductId | Text | Maps to Polar Product |
isActive | Boolean (default: true) | Controls visibility |
One note on enumerations: values should always have an alphabetical character preceding any number. A number-first enum value could otherwise cause the server to crash without notice when the GraphQL plugin is installed.
Create the Subscription Collection Type
The Subscription type is the central record that links a user to a plan and tracks which payment provider owns the billing relationship. Each entry stores the provider's subscription ID alongside a normalized status field, so your application logic can check whether a user is active without calling Stripe or Polar directly:
| Field | Type | Notes |
|---|---|---|
customer | Relation → User | Links to your app's user |
plan | Relation → Plan | Which plan they're on |
provider | Enumeration: stripe, polar | Which payment provider |
providerSubscriptionId | Text | Stripe or Polar subscription ID |
status | Enumeration: active, trialing, past_due, canceled, paused | Current state |
currentPeriodEnd | Datetime | When the current billing period expires |
Storing subscription state in Strapi decouples your app logic from any single payment provider. Your frontend queries one API regardless of whether a customer pays through Stripe or Polar. Content editors can inspect subscription data in the Admin Panel without accessing Stripe's dashboard.
Before moving on, configure API permissions. Go to Settings → Users and Permissions → Roles → Public and enable find and findOne for the Plan type. This lets your frontend fetch plans without authentication.
Integrate Stripe for Direct Payment Processing
Stripe integration involves three pieces: mapping Stripe products to Strapi plan entries, building a checkout session route, and processing webhook events.
Set Up Stripe Products and Prices
Create products and recurring prices in the Stripe Dashboard (or via the Stripe API). Each product represents a plan tier; each price represents a billing interval. Copy the price_id values (e.g., price_1HKiSf2eZvKYlo2CxjF9qwbr) back into the corresponding Strapi Plan entries' stripePriceId field.
Install the Stripe SDK in your Strapi project:
npm install stripeBuild a Checkout Session Route in Strapi
Strapi's custom routes let you define endpoints outside the default CRUD operations generated for each Content-Type. The checkout route below accepts a POST request from the frontend with a plan identifier, then delegates to a controller that creates a Stripe checkout session.
Setting auth: false makes the endpoint publicly accessible, which is necessary if unauthenticated users need to start a checkout flow from a pricing page:
// src/api/subscription/routes/custom-routes.js
module.exports = {
routes: [
{
method: 'POST',
path: '/subscriptions/checkout/stripe',
handler: 'api::subscription.subscription.stripeCheckout',
config: {
auth: false,
},
},
],
};Now build the controller that creates a Stripe Checkout:
// src/api/subscription/controllers/subscription.js
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const { factories } = require('@strapi/strapi');
module.exports = factories.createCoreController('api::subscription.subscription', ({ strapi }) => ({
async stripeCheckout(ctx) {
const { planDocumentId, userId } = ctx.request.body;
const plan = await strapi.documents('api::plan.plan').findOne({
documentId: planDocumentId,
});
if (!plan || !plan.stripePriceId) {
return ctx.badRequest('Plan not found or missing Stripe price');
}
const session = await stripe.checkout.sessions.create({
line_items: [{ price: plan.stripePriceId, quantity: 1 }],
mode: 'subscription',
subscription_data: {
metadata: { userId, planDocumentId },
},
success_url: `${process.env.FRONTEND_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL}/pricing`,
});
ctx.body = { url: session.url };
},
}));The controller receives a planDocumentId from the frontend, looks up the plan via the Document Service to get the stripePriceId, creates a checkout session in subscription mode, and returns the hosted checkout URL. The subscription_data.metadata carries your internal identifiers through to webhook events.
Handle Stripe Webhooks
Webhook signature verification is the step that trips up most developers in Strapi. stripe.webhooks.constructEvent() needs the raw request body. Strapi's built-in koa-body middleware parses the body into a JavaScript object before your controller runs, which breaks Hash-based Message Authentication Code (HMAC) verification.
There are two parts to the fix:
- Configure
strapi::body/koa-bodyto preserve the unparsed payload so you can access the raw body for verification. - Add a custom middleware that captures the raw buffer before
koa-bodyparses it, and attach it toctx.request.rawBody.
Create the middleware:
// src/middlewares/raw-body.js
module.exports = (config, { strapi }) => {
return async (ctx, next) => {
if (
ctx.request.path === '/api/webhooks/stripe' ||
ctx.request.path === '/api/webhooks/polar'
) {
await new Promise((resolve, reject) => {
const chunks = [];
ctx.req.on('data', (chunk) => chunks.push(chunk));
ctx.req.on('end', () => {
ctx.request.rawBody = Buffer.concat(chunks);
resolve();
});
ctx.req.on('error', reject);
});
}
await next();
};
};Register it before strapi::body in your middleware configuration. Ordering matters here. The Node.js request stream can only be read once, so raw body capture must complete before koa-body attempts to parse it.
// config/middlewares.js
module.exports = [
'strapi::errors',
'strapi::security',
'strapi::cors',
'strapi::poweredBy',
'strapi::logger',
'strapi::query',
'global::raw-body',
{
name: 'strapi::body',
config: {
includeUnparsed: true,
},
},
'strapi::session',
'strapi::favicon',
'strapi::public',
];Now create the webhook route and controller:
// src/api/webhook/routes/custom-routes.js
module.exports = {
routes: [
{
method: 'POST',
path: '/webhooks/stripe',
handler: 'api::webhook.webhook.stripeWebhook',
config: { auth: false },
},
],
};With the route pointing to stripeWebhook, create the controller that verifies the webhook signature and processes each event type. The switch block handles four key Stripe events: successful checkout, subscription updates, deletions, and failed payments:
// src/api/webhook/controllers/webhook.js
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const { factories } = require('@strapi/strapi');
module.exports = factories.createCoreController('api::webhook.webhook', ({ strapi }) => ({
async stripeWebhook(ctx) {
const rawBody = ctx.request.rawBody;
const signature = ctx.request.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
ctx.status = 400;
return ctx.badRequest(`Webhook Error: ${err.message}`);
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
const { userId, planDocumentId } = session.subscription_data?.metadata || session.metadata || {};
await strapi.documents('api::subscription.subscription').create({
data: {
customer: userId,
plan: planDocumentId,
provider: 'stripe',
providerSubscriptionId: session.subscription,
status: 'active',
},
});
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object;
const existing = await strapi.documents('api::subscription.subscription').findMany({
filters: { providerSubscriptionId: { $eq: subscription.id } },
});
if (existing.length > 0) {
await strapi.documents('api::subscription.subscription').update({
documentId: existing[0].documentId,
data: {
status: subscription.cancel_at_period_end ? 'canceled' : subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(),
},
});
}
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
const records = await strapi.documents('api::subscription.subscription').findMany({
filters: { providerSubscriptionId: { $eq: subscription.id } },
});
if (records.length > 0) {
await strapi.documents('api::subscription.subscription').update({
documentId: records[0].documentId,
data: { status: 'canceled' },
});
}
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
const subs = await strapi.documents('api::subscription.subscription').findMany({
filters: { providerSubscriptionId: { $eq: invoice.subscription } },
});
if (subs.length > 0) {
await strapi.documents('api::subscription.subscription').update({
documentId: subs[0].documentId,
data: { status: 'past_due' },
});
}
break;
}
}
ctx.status = 200;
ctx.body = { received: true };
},
}));Design every webhook handler to be idempotent. Stripe uses at-least-once delivery and documents that duplicate events may occur, and Polar's webhook guidance likewise recommends idempotent processing to handle potential duplicate events.
The providerSubscriptionId lookup-before-write pattern above helps with this: updating an already-canceled subscription to canceled again is a no-op.
Integrate Polar as a Merchant of Record
Polar handles tax compliance, invoicing, and billing infrastructure as the legal seller on each transaction. The integration pattern mirrors Stripe: map products, build a checkout route, and process webhooks.
Configure Polar Products in Sandbox
Create a Polar organization, then generate an access token under your organization settings. Use the sandbox setup consistently while you're testing.
Create subscription products in Polar's sandbox dashboard (or via the API), then copy each product_id into the corresponding Strapi Plan entry's polarProductId field.
Install the SDK:
npm install @polar-sh/sdkNote: some older tutorials reference @polarsource/polar-js, which is incorrect. The official package is @polar-sh/sdk.
Build a Polar Checkout Route
The Polar checkout route follows the same pattern as Stripe: a POST endpoint that accepts a plan identifier and returns a hosted checkout URL. Define the route in a separate file so Stripe and Polar routes stay organized:
// src/api/subscription/routes/polar-routes.js
module.exports = {
routes: [
{
method: 'POST',
path: '/subscriptions/checkout/polar',
handler: 'api::subscription.subscription.polarCheckout',
config: { auth: false },
},
],
};Add the controller method:
// Add to src/api/subscription/controllers/subscription.js
const { Polar } = require('@polar-sh/sdk');
const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN,
server: process.env.POLAR_MODE || 'sandbox',
});
// Inside the createCoreController factory:
async polarCheckout(ctx) {
const { planDocumentId, userId } = ctx.request.body;
const plan = await strapi.documents('api::plan.plan').findOne({
documentId: planDocumentId,
});
if (!plan || !plan.polarProductId) {
return ctx.badRequest('Plan not found or missing Polar product');
}
const checkout = await polar.checkouts.custom.create({
productId: plan.polarProductId,
successUrl: `${process.env.FRONTEND_URL}/success`,
externalCustomerId: userId,
});
ctx.body = { url: checkout.url };
},The key difference from Stripe: Polar checkout handles tax calculation, invoicing, and compliance automatically, so the legal and financial obligations land on Polar, not your company.
Handle Polar Webhooks
Set up a webhook endpoint in the Polar dashboard under Settings → Webhooks → Add Endpoint. Subscribe to subscription.created, subscription.updated, and subscription.canceled. Polar follows the webhook format documented in its docs, sending webhook-id, webhook-timestamp, and webhook-signature headers.
// src/api/webhook/routes/polar-routes.js
module.exports = {
routes: [
{
method: 'POST',
path: '/webhooks/polar',
handler: 'api::webhook.webhook.polarWebhook',
config: { auth: false },
},
],
};The controller uses the SDK's validateEvent function to verify the webhook signature, then handles creation, update, and cancellation events by writing subscription state back to Strapi:
// Add to src/api/webhook/controllers/webhook.js
const { validateEvent, WebhookVerificationError } = require('@polar-sh/sdk/webhooks');
async polarWebhook(ctx) {
let event;
try {
event = validateEvent(
ctx.request.rawBody,
ctx.request.headers,
process.env.POLAR_WEBHOOK_SECRET
);
} catch (error) {
if (error instanceof WebhookVerificationError) {
ctx.status = 403;
return;
}
throw error;
}
switch (event.type) {
case 'subscription.created': {
const sub = event.data;
await strapi.documents('api::subscription.subscription').create({
data: {
provider: 'polar',
providerSubscriptionId: sub.id,
status: sub.status,
currentPeriodEnd: sub.current_period_end,
},
});
break;
}
case 'subscription.updated': {
const sub = event.data;
const existing = await strapi.documents('api::subscription.subscription').findMany({
filters: { providerSubscriptionId: { $eq: sub.id } },
});
if (existing.length > 0) {
await strapi.documents('api::subscription.subscription').update({
documentId: existing[0].documentId,
data: {
status: sub.cancel_at_period_end ? 'canceled' : sub.status,
currentPeriodEnd: sub.current_period_end,
},
});
}
break;
}
case 'subscription.canceled': {
const sub = event.data;
const records = await strapi.documents('api::subscription.subscription').findMany({
filters: { providerSubscriptionId: { $eq: sub.id } },
});
if (records.length > 0) {
await strapi.documents('api::subscription.subscription').update({
documentId: records[0].documentId,
data: { status: 'canceled' },
});
}
break;
}
}
ctx.status = 202;
ctx.body = '';
},Note that Polar webhook payloads use snake_case field names, for example current_period_end, while SDK responses use camelCase. Keep this in mind when accessing fields directly from the raw event payload.
Sync Subscription State and Protect Content
With webhook handlers writing subscription records to Strapi, the frontend can query subscription status and gate features accordingly.
Query Active Subscriptions from the Frontend
Once webhook handlers are creating and updating Subscription records in Strapi, the frontend needs a way to check whether a given user has an active plan. Strapi's REST filters support nested relational queries, so you can filter by both the customer ID and subscription status in a single request:
GET /api/subscriptions?filters[customer][id][$eq]=7&filters[status][$eq]=active&populate=planThe populate=plan parameter includes the Plan data in the response, so you get the plan name, features, and pricing tier in a single request. Use this to gate premium content or features on the client side.
Build a Subscription Management Endpoint
Beyond checking status, users need a way to cancel or modify their subscriptions. Because subscription records in Strapi store the provider field, a single management endpoint can look up which payment service owns the subscription and route the action accordingly.
The controller below reads the subscriptionDocumentId and an action string from the request body, then calls the appropriate Stripe or Polar SDK method:
// POST /api/subscriptions/manage
async manageSubscription(ctx) {
const { subscriptionDocumentId, action } = ctx.request.body;
const subscription = await strapi.documents('api::subscription.subscription').findOne({
documentId: subscriptionDocumentId,
});
if (!subscription) return ctx.notFound('Subscription not found');
if (subscription.provider === 'stripe') {
if (action === 'cancel') {
await stripe.subscriptions.update(subscription.providerSubscriptionId, {
cancel_at_period_end: true,
});
}
} else if (subscription.provider === 'polar') {
if (action === 'cancel') {
await polar.subscriptions.revoke({ id: subscription.providerSubscriptionId });
}
}
ctx.body = { status: 'ok' };
},For Stripe cancellations, setting cancel_at_period_end: true lets the customer keep access until the billing period ends. The subscription status update flows back through your webhook handler when Stripe fires customer.subscription.updated.
Both providers offer hosted customer portals that offload subscription management UI entirely. Stripe's customer portal lets customers update payment methods, switch plans, and cancel. Polar provides customer-facing subscription portal methods through its SDK. If you'd rather not build subscription management screens, point users to these portals instead.
Test the Full Billing Flow
A complete subscription test covers six steps, from fetching plans to verifying premium feature access:
- Fetch plans from
GET /api/:pluralApiId(Strapi REST API), e.g.GET /api/plansif theplancollection type's plural API ID isplans. - User selects a plan and clicks subscribe on your frontend.
- Frontend calls a backend checkout endpoint such as
POST /api/stripe/checkout(or/api/polar/checkout) with the plan identifier. - User completes payment on the hosted checkout page. Use test card
4242 4242 4242 4242with any future expiry date for Stripe test mode and Polar sandbox mode. - Webhook fires, and Strapi creates the Subscription record.
- Frontend queries
GET /api/subscriptions?filters[status][$eq]=active&populate=planand unlocks premium features.
Webhook tunneling during local development is where things typically stall. For Stripe, use the CLI forward command:
stripe listen --forward-to localhost:1337/api/webhooks/stripeFor Polar, the sandbox environment can tunnel webhooks to your local machine for testing.
The Stripe command prints a webhook signing secret to your terminal. Copy it into your .env file as STRIPE_WEBHOOK_SECRET. For Polar, set POLAR_WEBHOOK_SECRET to the secret you configured for the webhook endpoint.
Where to Go from Strapi-Powered Subscription Billing
The core billing flow covers the essentials, but production apps typically need a few extensions:
- Add usage-based billing with Polar's metering API if you need API-call or storage-based pricing models.
- Implement Stripe's customer portal for self-service plan management, which reduces the subscription management code you need to maintain.
- Deploy Strapi to Strapi Cloud or a self-hosted environment. If you're using pnpm, be aware of CLI compatibility notes for Strapi Cloud deployments.
- Add email notifications on subscription events using Strapi's lifecycle hooks or an external service like SendGrid, triggered from your webhook controllers.
How Strapi Powers This
This tutorial built a multi-provider subscription billing system with plan management, checkout flows, webhook handling, and subscription state syncing across Stripe and Polar. Strapi 5 enabled this approach through:
- The Content-Type Builder models Plan and Subscription types with provider-specific fields, no schema files required.
- Strapi 5's flat response format and
documentIdidentifiers keep frontend data access predictable across both payment providers. - Custom routes and controllers handle checkout session creation and webhook processing within the CMS layer.
- The Document Service API provides a consistent interface for creating, querying, and updating subscription records from webhook handlers.
- REST API query parameters let the frontend check subscription status and gate premium content with a single request.
Start building with Strapi's headless CMS and follow the quick start guide to launch your first billing integration.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.