Splitting payments between a platform and individual creators requires integrating Stripe Connect onboarding, destination charges with configurable fee splits, and signature-verified webhooks. A headless CMS backend coordinates these pieces by tracking creators, tiers, and purchases as structured content.
Small mistakes can break the funds flow without warning: a missing raw body capture fails webhook signature verification, a misrouted event skips the Purchase write, or an undefined variable in the checkout controller returns a 500 before the session reaches Stripe.
This tutorial builds all four pieces from scratch using Strapi 5, Next.js 16, and the Stripe API. By the end, you'll have a working creator monetization platform where creators sign up, list paid tiers, and receive payouts net of a configurable platform fee.
In Brief
- Use Strapi 5 as a headless CMS backend to model creators, tiers, and purchases.
- Create Stripe Connect Express accounts and verify onboarding before enabling creator payouts.
- Build destination-charge checkout sessions with a configurable platform fee split.
- Handle
checkout.session.completedandaccount.updatedwebhooks with raw-body signature verification.
The stack: Strapi 5 headless CMS backend, Next.js 16 App Router frontend, Stripe Connect Express for funds flow.
Stripe Connect Express supports application_fee_amount on destination charges.
Prerequisites
Before you start, confirm you have the following installed and configured:
- Node.js v20, v22, or v24
- A Stripe account with Connect enabled in test mode
- The Stripe CLI (for local webhook forwarding)
- Familiarity with Strapi 5 and the Next.js App Router
Set Up the Strapi and Next.js Project
This section scaffolds both projects, installs Stripe packages, and configures environment variables.
Initialize the Strapi 5 Backend
Run the Strapi project scaffolding command from your terminal:
npx create-strapi@latest creator-platform-cmsThe interactive installer will prompt you for database and language preferences. It also asks whether you want TypeScript or JavaScript and whether to start from a blank project or an example template. Choose JavaScript and a blank project for this tutorial. SQLite works for local development; we'll note the Postgres switch for production at the end.
Initialize the Next.js Frontend
Create a new Next.js project with TypeScript, the App Router, and Tailwind CSS:
npx create-next-app@latest creator-platform-web --typescript --app --tailwindConfirm the App Router is selected when prompted. The resulting project uses the app/ directory for routing, with page.tsx files defining each route. All frontend code in this tutorial goes under app/ and its subdirectories.
Install Stripe Dependencies
Inside the Strapi project, install the Stripe Node.js SDK:
cd creator-platform-cms
npm install stripeInside the Next.js project, install both the server SDK and the client-side loader:
cd creator-platform-web
npm install stripe @stripe/stripe-jsConfigure Environment Variables
Add these keys to .env in the Strapi project:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_CONNECT_RETURN_URL=http://localhost:3000/dashboard/payouts?return=true
STRIPE_CONNECT_REFRESH_URL=http://localhost:3000/dashboard/payouts?refresh=trueAnd in the Next.js project's .env.local:
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...Never commit these keys to version control. For production key management strategies, see the middlewares configuration docs.
Model Content-Types in Strapi
Three Collection Types cover the data model. A Creator owns one or more Tiers, and a Purchase record is created when a buyer pays for a specific Tier. The relationships flow in one direction: Creator → Tier → Purchase. You can create all three through the Content-Type Builder in the Admin Panel, or by running npm run strapi generate and selecting "content-type" for each.
Creator Content-Type
The Creator type stores each creator's profile information and their Stripe Connect account state. The following fields define the schema:
| Field | Type | Notes |
|---|---|---|
displayName | String | Required |
slug | UID | Target: displayName |
bio | Text | Long |
avatar | Media | Single |
stripeAccountId | String | Private field |
onboardingComplete | Boolean | Default: false |
user | Relation | One-to-one with plugin::users-permissions.user |
Mark stripeAccountId as Private under the field's Advanced Settings tab so it never leaks through the REST API.
Tier Content-Type
Each Tier represents a purchasable product that belongs to a single Creator. The following fields define the schema:
| Field | Type | Notes |
|---|---|---|
name | String | Required |
description | Text | Long |
priceCents | Integer | Required |
currency | String | Default: usd |
creator | Relation | Many-to-one with Creator |
Storing price in cents avoids float math at checkout. A tier priced at $10.00 is stored as 1000.
Purchase Content-Type
A Purchase records a completed transaction between a buyer and a creator. The following fields define the schema:
| Field | Type | Notes |
|---|---|---|
stripePaymentIntentId | String | Unique |
amountCents | Integer | |
applicationFeeCents | Integer | |
buyerEmail | ||
status | Enumeration | Values: pending, paid, failed, refunded |
tier | Relation | Many-to-one with Tier |
creator | Relation | Many-to-one with Creator |
Configure Permissions
In Settings → Users & Permissions → Roles → Public: enable find and findOne for Creator and Tier only. Purchases remain private and are written exclusively by the webhook controller using the Document Service API. For finer-grained access control, refer to Strapi's authorization guide.
Implement Stripe Connect Onboarding
Stripe Connect onboarding in Strapi runs through three pieces: a custom route, a controller that creates an Express account and returns a hosted onboarding URL, and a write-back that saves the account ID to the creator document
Create a Custom Route and Controller
Use the Strapi CLI to scaffold the pieces you need for your API. Name it stripe-connect. This creates the file structure without a content-types/ folder, which is exactly what you need for a custom endpoint. Define two routes in ./src/api/stripe-connect/routes/stripe-connect.js:
module.exports = {
routes: [
{
method: 'POST',
path: '/stripe-connect/onboard',
handler: 'api::stripe-connect.stripe-connect.onboard',
config: {
auth: false,
},
},
{
method: 'GET',
path: '/stripe-connect/status/:creatorId',
handler: 'api::stripe-connect.stripe-connect.status',
config: {
auth: false,
},
},
],
};The controller skeleton goes in ./src/api/stripe-connect/controllers/stripe-connect.js:
module.exports = {
async onboard(ctx) { /* ... */ },
async status(ctx) { /* ... */ },
};Generate an Account Link
The onboard controller creates a Stripe Express account, persists the ID on the creator document, and returns a hosted onboarding URL:
const Stripe = require('stripe');
const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
module.exports = {
async onboard(ctx) {
const { documentId, email } = ctx.request.body;
const account = await stripe.accounts.create({
type: 'express',
email,
capabilities: {
transfers: { requested: true },
card_payments: { requested: true },
},
});
await strapi.documents('api::creator.creator').update({
documentId,
data: { stripeAccountId: account.id },
});
const accountLink = await stripe.accountLinks.create({
account: account.id,
refresh_url: process.env.STRIPE_CONNECT_REFRESH_URL,
return_url: process.env.STRIPE_CONNECT_RETURN_URL,
type: 'account_onboarding',
});
ctx.body = { url: accountLink.url };
},
};Strapi 5 uses documentId as the primary record identifier in API calls, replacing the numeric id used in v4. See the migration guide for details.
Update the Creator Document on Return
The Account Link redirects the creator back to the platform after they finish, or abandon, the Stripe-hosted flow. Stripe redirect behavior means landing on the return URL does not mean onboarding succeeded.
The redirect fires even if the creator closed the Stripe form early, or if Stripe's internal verification is still pending. The platform must verify by retrieving the account via stripe.accounts.retrieve() and checking fields like charges_enabled and details_submitted. On the frontend, the return page should call the status endpoint immediately after the redirect:
'use client';
import { useEffect, useState } from 'react';
export default function ConnectReturnPage() {
const [status, setStatus] = useState<string>('checking');
const creatorId = 'YOUR_CREATOR_DOCUMENT_ID'; // from auth context in production
useEffect(() => {
fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/stripe-connect/status/${creatorId}`)
.then(res => res.json())
.then(data => {
setStatus(data.onboardingComplete ? 'complete' : 'incomplete');
});
}, []);
if (status === 'checking') return <p>Verifying your account...</p>;
if (status === 'complete') return <p>Payouts are active. You can now receive payments.</p>;
return <p>Onboarding is not finished yet. Please complete the remaining steps.</p>;
}If the link expires or the creator navigates back, Stripe redirects to the refresh_url instead. Your frontend should re-call the onboard endpoint to generate a fresh Account Link at that point.
Verify Onboarding Status
The status controller retrieves the account directly from Stripe and checks two fields. Both the account.updated webhook (covered in the webhooks section) and this endpoint update the onboardingComplete flag, but they serve different purposes.
- In production, you should listen for the webhook because it fires asynchronously when Stripe updates a connected account's verification state.
- This endpoint exists so the frontend can give immediate feedback during the redirect flow, when the webhook may not have arrived yet.
async status(ctx) {
const { creatorId } = ctx.params;
const creator = await strapi.documents('api::creator.creator').findOne({
documentId: creatorId,
fields: ['stripeAccountId'],
});
const account = await stripe.accounts.retrieve(creator.stripeAccountId);
if (account.details_submitted && account.charges_enabled) {
await strapi.documents('api::creator.creator').update({
documentId: creatorId,
data: { onboardingComplete: true },
});
}
ctx.body = {
detailsSubmitted: account.details_submitted,
chargesEnabled: account.charges_enabled,
onboardingComplete: account.details_submitted && account.charges_enabled,
};
},Build the Checkout Flow with Destination Charges
Destination charges put the platform in control of the charge while routing funds to the creator's connected account. The platform retains an application_fee_amount, and the remainder lands in the creator's pending balance.
Add a Checkout Route in Strapi
Generate another route-only API named checkout. The POST route at /checkout/session accepts { tierId, buyerEmail }:
// ./src/api/checkout/routes/checkout.js
module.exports = {
routes: [
{
method: 'POST',
path: '/checkout/session',
handler: 'api::checkout.checkout.createSession',
config: { auth: false },
},
],
};The controller pulls the tier and its related creator using the Document Service's populate parameter:
const tier = await strapi.documents('api::tier.tier').findOne({
documentId: tierId,
populate: { creator: true },
});Validate that tier.creator.onboardingComplete === true before proceeding. If the creator hasn't finished onboarding, return a 400.
Calculate the Platform Fee
A small pure function keeps fee logic isolated and testable:
function calculateFee(priceCents) {
return Math.round(priceCents * 0.10); // 10% platform take rate
}Cents math matters here. Zero-decimal currencies like JPY change the calculation, so flag this with a TODO if you plan to expand to multi-currency support.
To make the fee rate configurable, you could store the percentage in an environment variable (PLATFORM_FEE_RATE=0.10) or in a Strapi Single Type called "Platform Settings" with an integer field for the rate in basis points. Either approach keeps the fee out of code and changeable without a redeploy.
Create the Checkout Session with transfer_data.destination
The full checkout controller creates a Stripe Checkout Session with destination charge routing:
const Stripe = require('stripe');
const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
async function createSession(ctx) {
const { tierId, buyerEmail } = ctx.request.body;
const tier = await strapi.documents('api::tier.tier').findOne({
documentId: tierId,
populate: { creator: true },
});
if (!tier.creator.onboardingComplete) {
ctx.response.status = 400;
ctx.body = { error: 'Creator has not completed onboarding' };
return;
}
const applicationFeeCents = calculateFee(tier.priceCents);
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [
{
price_data: {
currency: tier.currency,
product_data: { name: tier.name },
unit_amount: tier.priceCents,
},
quantity: 1,
},
],
payment_intent_data: {
application_fee_amount: applicationFeeCents,
transfer_data: {
destination: tier.creator.stripeAccountId,
},
metadata: {
tierId: tier.documentId,
creatorId: tier.creator.documentId,
},
},
metadata: {
tierId: tier.documentId,
creatorId: tier.creator.documentId,
},
success_url: `${process.env.NEXT_PUBLIC_STRAPI_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_STRAPI_URL}/cancel`,
customer_email: buyerEmail,
});
ctx.body = { url: session.url };
}When Stripe processes this session, the charge is created on the platform account. The destination transfer then moves the full amount into the creator's connected account pending balance, while the application_fee_amount is separately transferred to the platform. Processing fees are deducted from the platform's balance, not the creator's.
Note: metadata at the session level and inside payment_intent_data.metadata are separate objects on separate Stripe objects. Setting it in one does not propagate to the other. Both are set here so the webhook handler can access tierId and creatorId from the session metadata directly.
Wrap the stripe.checkout.sessions.create call in a try/catch in production. If the Stripe API throws, for example, because the connected account has been restricted or the currency is unsupported, the controller should return a 500 with a generic error message. Leaking raw Stripe error details to the client exposes internal account state and API structure.
Redirect from Next.js
The buy button lives in a Client Component. No need for @stripe/stripe-js's redirectToCheckout since the session already returns a hosted URL:
'use client';
export function BuyButton({ tierId }: { tierId: string }) {
async function handleClick() {
const res = await fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/checkout/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tierId, buyerEmail: 'buyer@example.com' }),
});
const data = await res.json();
window.location.assign(data.url);
}
return <button onClick={handleClick}>Buy</button>;
}Handle Stripe Webhooks in Strapi
Two webhook events matter for this platform: checkout.session.completed (write a Purchase) and account.updated (flip a creator to onboardingComplete).
Create a Raw-Body Webhook Route
Stripe signature verification requires the unparsed request body. Strapi's Koa-based body parser converts JSON to a JavaScript object by default, which breaks the HMAC check. A custom middleware captures the raw stream before parsing occurs. Generate an API named stripe-webhook, then create the middleware file:
// ./src/api/stripe-webhook/middlewares/raw-body.js
'use strict';
module.exports = (config, { strapi }) => {
return async (ctx, next) => {
if (ctx.request.path === '/api/stripe-webhook/handle') {
await new Promise((resolve, reject) => {
let data = '';
ctx.req.on('data', (chunk) => {
data += chunk;
});
ctx.req.on('end', () => {
ctx.request.rawBody = data;
resolve();
});
ctx.req.on('error', reject);
});
}
await next();
};
};The route file disables auth and attaches the middleware:
// ./src/api/stripe-webhook/routes/stripe-webhook.js
module.exports = {
routes: [
{
method: 'POST',
path: '/stripe-webhook/handle',
handler: 'api::stripe-webhook.stripe-webhook.handle',
config: {
auth: false,
middlewares: ['api::stripe-webhook.raw-body'],
},
},
],
};This approach uses stream capture because some Strapi community reports describe cases where using includeUnparsed: true on strapi::body still leaves Symbol.for('unparsedBody') as undefined, making raw-body access unreliable in those setups.
Verify the Stripe Signature
The full webhook controller lives in ./src/api/stripe-webhook/controllers/stripe-webhook.js. The handle method first verifies the Stripe signature, then routes events to their respective handlers:
// ./src/api/stripe-webhook/controllers/stripe-webhook.js
const Stripe = require('stripe');
const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
module.exports = {
async handle(ctx) {
const signature = ctx.request.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
ctx.request.rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
ctx.response.status = 400;
ctx.body = `Webhook Error: ${err.message}`;
return;
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
const { tierId, creatorId } = session.metadata;
await strapi.documents('api::purchase.purchase').create({
data: {
stripePaymentIntentId: session.payment_intent,
amountCents: session.amount_total,
applicationFeeCents: Math.round(session.amount_total * 0.10),
buyerEmail: session.customer_email,
status: 'paid',
tier: tierId,
creator: creatorId,
},
});
}
if (event.type === 'account.updated') {
const account = event.data.object;
const creators = await strapi.documents('api::creator.creator').findMany({
filters: { stripeAccountId: account.id },
});
if (creators.length > 0) {
await strapi.documents('api::creator.creator').update({
documentId: creators[0].documentId,
data: {
onboardingComplete: account.details_submitted && account.charges_enabled,
},
});
}
}
ctx.response.status = 200;
ctx.body = { received: true };
},
};The signature verification try/catch block is the first gate. If the HMAC check fails, the controller returns 400 immediately and processes nothing. Requests that pass Stripe's webhook signature verification can be treated as authentic Stripe webhooks, so the route can use auth: false when signature verification is implemented correctly.
Persist checkout.session.completed
The checkout.session.completed handler extracts tierId and creatorId from the session's metadata and writes a Purchase record through the Document Service.
- The
statusfield can be set topaidwhen handlingcheckout.session.completedfor immediate payment methods like cards, but you should confirm this from the session'spayment_statusrather than assumingcheckout.session.completedalways means payment has succeeded. - If you add support for asynchronous payment methods, you would need to listen for
checkout.session.async_payment_succeededorpayment_intent.succeededinstead, as those payment methods can confirm after a delay.
Note: application_fee_amount is a property of the PaymentIntent, not the Checkout Session. If you need the exact fee from Stripe, retrieve the PaymentIntent via stripe.paymentIntents.retrieve(session.payment_intent). For simplicity, the handler recalculates it here using the same formula.
Handle account.updated
The account.updated handler fires whenever Stripe changes something on a connected account's verification status. This can happen multiple times as the creator provides additional identity documents, corrects banking details, or responds to Stripe's review requests.
The handler:
- looks up the creator by
stripeAccountId - sets
onboardingCompletebased on whether bothdetails_submittedandcharges_enabledare true - flips the flag back if Stripe later restricts the account
Because Stripe retries any webhook that does not receive a 2xx response within a short window, the handler should return 200 as soon as the database write completes. Avoid calling external APIs or running slow queries inside the handler; defer those to a background job if needed. See Stripe's webhook retry documentation for the full retry schedule.
Build the Next.js Frontend
Three pages drive the storefront: a creators index, a creator profile with tier cards, and an onboarding launcher for creators.
Fetch Creators with the Strapi REST API
In a Server Component, fetch creators with their tiers and avatars populated. Strapi's REST API does not populate relations by default, so the populate parameter is required:
// app/page.tsx
export default async function HomePage() {
const res = await fetch(
`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/creators?populate[tiers]=true&populate[avatar]=true`,
{ cache: 'no-store' }
);
const { data } = await res.json();
return (
<main>
{data.map((creator: any) => (
<a key={creator.documentId} href={`/${creator.slug}`}>
<h2>{creator.displayName}</h2>
<p>{creator.tiers?.length ?? 0} tiers</p>
</a>
))}
</main>
);
}The cache: 'no-store' option tells Next.js to fetch fresh data on every request rather than caching the response. During development, this keeps the storefront in sync with any creators or tiers you add through the Strapi Admin Panel. For production, you may want to switch to time-based revalidation with next: { revalidate: 60 }.
Strapi 5 returns a flat response format with documentId and no attributes wrapper. Fields sit directly on the object.
Render the Creator Profile Page
The Next.js 16 upgrade guide notes that params is async. Synchronous access was temporarily supported in Next.js 15 and is now fully removed in Next.js 16, so every read must be awaited:
// app/[slug]/page.tsx
import { TierCard } from './tier-card';
export default async function CreatorPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params;
const res = await fetch(
`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/creators?filters[slug][$eq]=${slug}&populate[tiers]=true&populate[avatar]=true`
);
const { data } = await res.json();
if (!data || data.length === 0) {
return (
<main>
<h1>Creator not found</h1>
<p>No creator exists with the slug "{slug}".</p>
</main>
);
}
const creator = data[0];
return (
<main>
<h1>{creator.displayName}</h1>
<p>{creator.bio}</p>
<div className="grid grid-cols-3 gap-4">
{creator.tiers?.map((tier: any) => (
<TierCard key={tier.documentId} tier={tier} />
))}
</div>
</main>
);
}The TierCard component lives in app/[slug]/tier-card.tsx as a Client Component, since it needs an onClick handler for checkout.
Render the Tier Cards and Buy Button
TierCard is a Client Component that formats the price and triggers checkout:
// app/[slug]/tier-card.tsx
'use client';
export function TierCard({ tier }: { tier: any }) {
const price = (tier.priceCents / 100).toLocaleString(undefined, {
style: 'currency',
currency: tier.currency,
});
async function handleBuy() {
const res = await fetch(
`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/checkout/session`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tierId: tier.documentId, buyerEmail: 'buyer@example.com' }),
}
);
const data = await res.json();
window.location.assign(data.url);
}
return (
<div className="border rounded p-4">
<h3>{tier.name}</h3>
<p>{tier.description}</p>
<p className="text-xl font-bold">{price}</p>
<button onClick={handleBuy} className="bg-blue-600 text-white px-4 py-2 rounded mt-2">
Buy
</button>
</div>
);
}Build a Stripe Connect Onboarding Page for Creators
An authenticated page at /dashboard/payouts calls the onboard endpoint, then redirects to Stripe's Express onboarding URL. On return, it calls the status endpoint and displays the result:
'use client';
import { useState, useEffect } from 'react';
export default function PayoutsPage() {
const [status, setStatus] = useState<string>('loading');
const creatorId = 'YOUR_CREATOR_DOCUMENT_ID'; // from auth context in production
useEffect(() => {
fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/stripe-connect/status/${creatorId}`)
.then(res => res.json())
.then(data => setStatus(data.onboardingComplete ? 'active' : 'incomplete'));
}, []);
async function startOnboarding() {
const res = await fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/stripe-connect/onboard`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ documentId: creatorId, email: 'creator@example.com' }),
});
const data = await res.json();
window.location.assign(data.url);
}
if (status === 'active') return <p>Payouts active</p>;
return <button onClick={startOnboarding}>Finish payout setup</button>;
}Test and Deploy
This section covers end-to-end testing with the Stripe CLI, common issues you may encounter, and the changes needed before going live.
Test the Full Flow End-to-End
Forward webhooks locally with the Stripe CLI:
stripe listen --forward-to localhost:1337/api/stripe-webhook/handleCopy the whsec_... signing secret from the CLI output into your Strapi .env as STRIPE_WEBHOOK_SECRET. This value is specific to local forwarding and differs from any secret configured in the Stripe Dashboard.
Run through the flow:
- Create a test creator in the Strapi Admin Panel
- Trigger onboarding. In Stripe's test identity flow, use phone
0000000000and SSN last four0000 - Buy a tier with card
4242 4242 4242 4242(any CVC, any future expiry) - Confirm the Purchase row appears in the Admin Panel with status
paid
Troubleshooting
Webhook signature failures. If constructEvent throws "No signatures found matching the expected signature", the raw body is not being captured correctly. Confirm that ctx.request.rawBody is a string, not a parsed JavaScript object. Also, verify that STRIPE_WEBHOOK_SECRET in your .env matches the whsec_... value printed by stripe listen, not a secret from the Stripe Dashboard (those are different signing secrets).
Symbol.for('unparsedBody') returning undefined. If you tried Approach 1 (setting includeUnparsed: true on strapi::body in config/middlewares.js), be aware of GitHub issue #23626: the unparsed body is undefined on controller-defined routes. Switch to the stream capture middleware described in this tutorial.
Creator checkout returning 400 ("Creator has not completed onboarding"). There is a race condition between the redirect and the account.updated webhook. If a creator completes Stripe's onboarding form and is redirected back to your platform, the account.updated webhook that sets onboardingComplete: true may not have arrived yet.
The creator document is updated based on account status changes to help close this gap. In production, add a short polling loop or a "checking status" loading state on the return page.
Connect events not forwarding locally. The stripe listen --forward-to command forwards all standard snapshot webhook events for your Stripe account. For Connect events like account.updated, you may need --forward-connect-to instead:
stripe listen --forward-connect-to localhost:1337/api/stripe-webhook/handleOr forward both simultaneously:
stripe listen \
--forward-to localhost:1337/api/stripe-webhook/handle \
--forward-connect-to localhost:1337/api/stripe-webhook/handleMove from Test Mode to Live Keys
These are the key changes to make before flipping to live mode:
- Swap
sk_test_andpk_test_keys for live equivalents in your production environment - Register the production webhook endpoint in Stripe Dashboard → Developers → Webhooks, selecting the event types your integration needs, such as
checkout.session.completedandaccount.updated - Switch SQLite to Postgres for the Strapi backend, and configure
config/database.jsfor a self-hosted Postgres instance - HTTP is permitted for
return_urlandrefresh_urlin test mode (localhost), but live mode requires HTTPS. - For a full deployment walkthrough, see the deployment docs
Secure the Creator Monetization Platform
You now have a working creator monetization platform with Stripe Connect onboarding, fee-split checkout via destination charges, signed webhook ingestion, and a Next.js storefront.
Before going live, add rate limiting on checkout, replace auth: false with JWT authentication scoped to the profile owner, and add idempotency checks in the webhook controller. For next steps, consider recurring subscriptions through Stripe Billing or notifications through the Strapi Email plugin.
How Strapi Powered This
- The Content-Type Builder modeled Creator, Tier, and Purchase schemas without a migration file;
- Custom routes handled onboarding and checkout as first-class API endpoints;
- The Document Service created Purchase records from webhooks with a single
strapi.documents().create()call; - Strapi 5's flat response format simplified frontend access to
creator.displayName; - Private fields prevented
stripeAccountIdfrom leaking through the REST API; - Users and Permissions roles scoped public access to read-only endpoints.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.