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
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:
That combination works whether you use one or both payment services depending on the use case.
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.
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:
npx create-strapi@latest stripe npm package @polar-sh/sdk (npm package) 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
1npx 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
1cd subscription-billing
2npm 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.
Subscription billing requires two content structures: one to define plan tiers and another to track active subscriptions per customer.
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.
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.
Stripe integration involves three pieces: mapping Stripe products to Strapi plan entries, building a checkout session route, and processing webhook events.
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:
1npm install stripeStrapi'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:
1// src/api/subscription/routes/custom-routes.js
2module.exports = {
3 routes: [
4 {
5 method: 'POST',
6 path: '/subscriptions/checkout/stripe',
7 handler: 'api::subscription.subscription.stripeCheckout',
8 config: {
9 auth: false,
10 },
11 },
12 ],
13};Now build the controller that creates a Stripe Checkout:
1// src/api/subscription/controllers/subscription.js
2const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
3const { factories } = require('@strapi/strapi');
4
5module.exports = factories.createCoreController('api::subscription.subscription', ({ strapi }) => ({
6 async stripeCheckout(ctx) {
7 const { planDocumentId, userId } = ctx.request.body;
8
9 const plan = await strapi.documents('api::plan.plan').findOne({
10 documentId: planDocumentId,
11 });
12
13 if (!plan || !plan.stripePriceId) {
14 return ctx.badRequest('Plan not found or missing Stripe price');
15 }
16
17 const session = await stripe.checkout.sessions.create({
18 line_items: [{ price: plan.stripePriceId, quantity: 1 }],
19 mode: 'subscription',
20 subscription_data: {
21 metadata: { userId, planDocumentId },
22 },
23 success_url: `${process.env.FRONTEND_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
24 cancel_url: `${process.env.FRONTEND_URL}/pricing`,
25 });
26
27 ctx.body = { url: session.url };
28 },
29}));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.
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:
strapi::body / koa-body to preserve the unparsed payload so you can access the raw body for verification. koa-body parses it, and attach it to ctx.request.rawBody.Create the middleware:
1// src/middlewares/raw-body.js
2module.exports = (config, { strapi }) => {
3 return async (ctx, next) => {
4 if (
5 ctx.request.path === '/api/webhooks/stripe' ||
6 ctx.request.path === '/api/webhooks/polar'
7 ) {
8 await new Promise((resolve, reject) => {
9 const chunks = [];
10 ctx.req.on('data', (chunk) => chunks.push(chunk));
11 ctx.req.on('end', () => {
12 ctx.request.rawBody = Buffer.concat(chunks);
13 resolve();
14 });
15 ctx.req.on('error', reject);
16 });
17 }
18 await next();
19 };
20};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.
1// config/middlewares.js
2module.exports = [
3 'strapi::errors',
4 'strapi::security',
5 'strapi::cors',
6 'strapi::poweredBy',
7 'strapi::logger',
8 'strapi::query',
9 'global::raw-body',
10 {
11 name: 'strapi::body',
12 config: {
13 includeUnparsed: true,
14 },
15 },
16 'strapi::session',
17 'strapi::favicon',
18 'strapi::public',
19];Now create the webhook route and controller:
1// src/api/webhook/routes/custom-routes.js
2module.exports = {
3 routes: [
4 {
5 method: 'POST',
6 path: '/webhooks/stripe',
7 handler: 'api::webhook.webhook.stripeWebhook',
8 config: { auth: false },
9 },
10 ],
11};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:
1// src/api/webhook/controllers/webhook.js
2const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
3const { factories } = require('@strapi/strapi');
4
5module.exports = factories.createCoreController('api::webhook.webhook', ({ strapi }) => ({
6 async stripeWebhook(ctx) {
7 const rawBody = ctx.request.rawBody;
8 const signature = ctx.request.headers['stripe-signature'];
9
10 let event;
11 try {
12 event = stripe.webhooks.constructEvent(
13 rawBody,
14 signature,
15 process.env.STRIPE_WEBHOOK_SECRET
16 );
17 } catch (err) {
18 ctx.status = 400;
19 return ctx.badRequest(`Webhook Error: ${err.message}`);
20 }
21
22 switch (event.type) {
23 case 'checkout.session.completed': {
24 const session = event.data.object;
25 const { userId, planDocumentId } = session.subscription_data?.metadata || session.metadata || {};
26 await strapi.documents('api::subscription.subscription').create({
27 data: {
28 customer: userId,
29 plan: planDocumentId,
30 provider: 'stripe',
31 providerSubscriptionId: session.subscription,
32 status: 'active',
33 },
34 });
35 break;
36 }
37
38 case 'customer.subscription.updated': {
39 const subscription = event.data.object;
40 const existing = await strapi.documents('api::subscription.subscription').findMany({
41 filters: { providerSubscriptionId: { $eq: subscription.id } },
42 });
43 if (existing.length > 0) {
44 await strapi.documents('api::subscription.subscription').update({
45 documentId: existing[0].documentId,
46 data: {
47 status: subscription.cancel_at_period_end ? 'canceled' : subscription.status,
48 currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(),
49 },
50 });
51 }
52 break;
53 }
54
55 case 'customer.subscription.deleted': {
56 const subscription = event.data.object;
57 const records = await strapi.documents('api::subscription.subscription').findMany({
58 filters: { providerSubscriptionId: { $eq: subscription.id } },
59 });
60 if (records.length > 0) {
61 await strapi.documents('api::subscription.subscription').update({
62 documentId: records[0].documentId,
63 data: { status: 'canceled' },
64 });
65 }
66 break;
67 }
68
69 case 'invoice.payment_failed': {
70 const invoice = event.data.object;
71 const subs = await strapi.documents('api::subscription.subscription').findMany({
72 filters: { providerSubscriptionId: { $eq: invoice.subscription } },
73 });
74 if (subs.length > 0) {
75 await strapi.documents('api::subscription.subscription').update({
76 documentId: subs[0].documentId,
77 data: { status: 'past_due' },
78 });
79 }
80 break;
81 }
82 }
83
84 ctx.status = 200;
85 ctx.body = { received: true };
86 },
87}));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.
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.
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:
1npm install @polar-sh/sdkNote: some older tutorials reference @polarsource/polar-js, which is incorrect. The official package is @polar-sh/sdk.
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:
1// src/api/subscription/routes/polar-routes.js
2module.exports = {
3 routes: [
4 {
5 method: 'POST',
6 path: '/subscriptions/checkout/polar',
7 handler: 'api::subscription.subscription.polarCheckout',
8 config: { auth: false },
9 },
10 ],
11};Add the controller method:
1// Add to src/api/subscription/controllers/subscription.js
2const { Polar } = require('@polar-sh/sdk');
3
4const polar = new Polar({
5 accessToken: process.env.POLAR_ACCESS_TOKEN,
6 server: process.env.POLAR_MODE || 'sandbox',
7});
8
9// Inside the createCoreController factory:
10async polarCheckout(ctx) {
11 const { planDocumentId, userId } = ctx.request.body;
12
13 const plan = await strapi.documents('api::plan.plan').findOne({
14 documentId: planDocumentId,
15 });
16
17 if (!plan || !plan.polarProductId) {
18 return ctx.badRequest('Plan not found or missing Polar product');
19 }
20
21 const checkout = await polar.checkouts.custom.create({
22 productId: plan.polarProductId,
23 successUrl: `${process.env.FRONTEND_URL}/success`,
24 externalCustomerId: userId,
25 });
26
27 ctx.body = { url: checkout.url };
28},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.
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.
1// src/api/webhook/routes/polar-routes.js
2module.exports = {
3 routes: [
4 {
5 method: 'POST',
6 path: '/webhooks/polar',
7 handler: 'api::webhook.webhook.polarWebhook',
8 config: { auth: false },
9 },
10 ],
11};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:
1// Add to src/api/webhook/controllers/webhook.js
2const { validateEvent, WebhookVerificationError } = require('@polar-sh/sdk/webhooks');
3
4async polarWebhook(ctx) {
5 let event;
6 try {
7 event = validateEvent(
8 ctx.request.rawBody,
9 ctx.request.headers,
10 process.env.POLAR_WEBHOOK_SECRET
11 );
12 } catch (error) {
13 if (error instanceof WebhookVerificationError) {
14 ctx.status = 403;
15 return;
16 }
17 throw error;
18 }
19
20 switch (event.type) {
21 case 'subscription.created': {
22 const sub = event.data;
23 await strapi.documents('api::subscription.subscription').create({
24 data: {
25 provider: 'polar',
26 providerSubscriptionId: sub.id,
27 status: sub.status,
28 currentPeriodEnd: sub.current_period_end,
29 },
30 });
31 break;
32 }
33
34 case 'subscription.updated': {
35 const sub = event.data;
36 const existing = await strapi.documents('api::subscription.subscription').findMany({
37 filters: { providerSubscriptionId: { $eq: sub.id } },
38 });
39 if (existing.length > 0) {
40 await strapi.documents('api::subscription.subscription').update({
41 documentId: existing[0].documentId,
42 data: {
43 status: sub.cancel_at_period_end ? 'canceled' : sub.status,
44 currentPeriodEnd: sub.current_period_end,
45 },
46 });
47 }
48 break;
49 }
50
51 case 'subscription.canceled': {
52 const sub = event.data;
53 const records = await strapi.documents('api::subscription.subscription').findMany({
54 filters: { providerSubscriptionId: { $eq: sub.id } },
55 });
56 if (records.length > 0) {
57 await strapi.documents('api::subscription.subscription').update({
58 documentId: records[0].documentId,
59 data: { status: 'canceled' },
60 });
61 }
62 break;
63 }
64 }
65
66 ctx.status = 202;
67 ctx.body = '';
68},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.
With webhook handlers writing subscription records to Strapi, the frontend can query subscription status and gate features accordingly.
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:
1GET /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.
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:
1// POST /api/subscriptions/manage
2async manageSubscription(ctx) {
3 const { subscriptionDocumentId, action } = ctx.request.body;
4
5 const subscription = await strapi.documents('api::subscription.subscription').findOne({
6 documentId: subscriptionDocumentId,
7 });
8
9 if (!subscription) return ctx.notFound('Subscription not found');
10
11 if (subscription.provider === 'stripe') {
12 if (action === 'cancel') {
13 await stripe.subscriptions.update(subscription.providerSubscriptionId, {
14 cancel_at_period_end: true,
15 });
16 }
17 } else if (subscription.provider === 'polar') {
18 if (action === 'cancel') {
19 await polar.subscriptions.revoke({ id: subscription.providerSubscriptionId });
20 }
21 }
22
23 ctx.body = { status: 'ok' };
24},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.
A complete subscription test covers six steps, from fetching plans to verifying premium feature access:
GET /api/:pluralApiId (Strapi REST API), e.g. GET /api/plans if the plan collection type's plural API ID is plans. POST /api/stripe/checkout (or /api/polar/checkout) with the plan identifier. 4242 4242 4242 4242 with any future expiry date for Stripe test mode and Polar sandbox mode. GET /api/subscriptions?filters[status][$eq]=active&populate=plan and unlocks premium features.Webhook tunneling during local development is where things typically stall. For Stripe, use the CLI forward command:
1stripe 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.
The core billing flow covers the essentials, but production apps typically need a few extensions:
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:
documentId identifiers keep frontend data access predictable across both payment providers. Start building with Strapi's headless CMS and follow the quick start guide to launch your first billing integration.
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.