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
checkout.session.completed and account.updated webhooks 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.
Before you start, confirm you have the following installed and configured:
This section scaffolds both projects, installs Stripe packages, and configures environment variables.
Run the Strapi project scaffolding command from your terminal:
1npx 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.
Create a new Next.js project with TypeScript, the App Router, and Tailwind CSS:
1npx 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.
Inside the Strapi project, install the Stripe Node.js SDK:
1cd creator-platform-cms
2npm install stripeInside the Next.js project, install both the server SDK and the client-side loader:
1cd creator-platform-web
2npm install stripe @stripe/stripe-jsAdd these keys to .env in the Strapi project:
1STRIPE_SECRET_KEY=sk_test_...
2STRIPE_WEBHOOK_SECRET=whsec_...
3STRIPE_CONNECT_RETURN_URL=http://localhost:3000/dashboard/payouts?return=true
4STRIPE_CONNECT_REFRESH_URL=http://localhost:3000/dashboard/payouts?refresh=trueAnd in the Next.js project's .env.local:
1NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
2NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...Never commit these keys to version control. For production key management strategies, see the middlewares configuration docs.
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.
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.
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.
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 |
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.
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
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:
1module.exports = {
2 routes: [
3 {
4 method: 'POST',
5 path: '/stripe-connect/onboard',
6 handler: 'api::stripe-connect.stripe-connect.onboard',
7 config: {
8 auth: false,
9 },
10 },
11 {
12 method: 'GET',
13 path: '/stripe-connect/status/:creatorId',
14 handler: 'api::stripe-connect.stripe-connect.status',
15 config: {
16 auth: false,
17 },
18 },
19 ],
20};The controller skeleton goes in ./src/api/stripe-connect/controllers/stripe-connect.js:
1module.exports = {
2 async onboard(ctx) { /* ... */ },
3 async status(ctx) { /* ... */ },
4};The onboard controller creates a Stripe Express account, persists the ID on the creator document, and returns a hosted onboarding URL:
1const Stripe = require('stripe');
2const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
3
4module.exports = {
5 async onboard(ctx) {
6 const { documentId, email } = ctx.request.body;
7
8 const account = await stripe.accounts.create({
9 type: 'express',
10 email,
11 capabilities: {
12 transfers: { requested: true },
13 card_payments: { requested: true },
14 },
15 });
16
17 await strapi.documents('api::creator.creator').update({
18 documentId,
19 data: { stripeAccountId: account.id },
20 });
21
22 const accountLink = await stripe.accountLinks.create({
23 account: account.id,
24 refresh_url: process.env.STRIPE_CONNECT_REFRESH_URL,
25 return_url: process.env.STRIPE_CONNECT_RETURN_URL,
26 type: 'account_onboarding',
27 });
28
29 ctx.body = { url: accountLink.url };
30 },
31};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.
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:
1'use client';
2import { useEffect, useState } from 'react';
3
4export default function ConnectReturnPage() {
5 const [status, setStatus] = useState<string>('checking');
6 const creatorId = 'YOUR_CREATOR_DOCUMENT_ID'; // from auth context in production
7
8 useEffect(() => {
9 fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/stripe-connect/status/${creatorId}`)
10 .then(res => res.json())
11 .then(data => {
12 setStatus(data.onboardingComplete ? 'complete' : 'incomplete');
13 });
14 }, []);
15
16 if (status === 'checking') return <p>Verifying your account...</p>;
17 if (status === 'complete') return <p>Payouts are active. You can now receive payments.</p>;
18 return <p>Onboarding is not finished yet. Please complete the remaining steps.</p>;
19}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.
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.
1async status(ctx) {
2 const { creatorId } = ctx.params;
3
4 const creator = await strapi.documents('api::creator.creator').findOne({
5 documentId: creatorId,
6 fields: ['stripeAccountId'],
7 });
8
9 const account = await stripe.accounts.retrieve(creator.stripeAccountId);
10
11 if (account.details_submitted && account.charges_enabled) {
12 await strapi.documents('api::creator.creator').update({
13 documentId: creatorId,
14 data: { onboardingComplete: true },
15 });
16 }
17
18 ctx.body = {
19 detailsSubmitted: account.details_submitted,
20 chargesEnabled: account.charges_enabled,
21 onboardingComplete: account.details_submitted && account.charges_enabled,
22 };
23},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.
Generate another route-only API named checkout. The POST route at /checkout/session accepts { tierId, buyerEmail }:
1// ./src/api/checkout/routes/checkout.js
2module.exports = {
3 routes: [
4 {
5 method: 'POST',
6 path: '/checkout/session',
7 handler: 'api::checkout.checkout.createSession',
8 config: { auth: false },
9 },
10 ],
11};The controller pulls the tier and its related creator using the Document Service's populate parameter:
1const tier = await strapi.documents('api::tier.tier').findOne({
2 documentId: tierId,
3 populate: { creator: true },
4});Validate that tier.creator.onboardingComplete === true before proceeding. If the creator hasn't finished onboarding, return a 400.
A small pure function keeps fee logic isolated and testable:
1function calculateFee(priceCents) {
2 return Math.round(priceCents * 0.10); // 10% platform take rate
3}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.
The full checkout controller creates a Stripe Checkout Session with destination charge routing:
1const Stripe = require('stripe');
2const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
3
4async function createSession(ctx) {
5 const { tierId, buyerEmail } = ctx.request.body;
6
7 const tier = await strapi.documents('api::tier.tier').findOne({
8 documentId: tierId,
9 populate: { creator: true },
10 });
11
12 if (!tier.creator.onboardingComplete) {
13 ctx.response.status = 400;
14 ctx.body = { error: 'Creator has not completed onboarding' };
15 return;
16 }
17
18 const applicationFeeCents = calculateFee(tier.priceCents);
19
20 const session = await stripe.checkout.sessions.create({
21 mode: 'payment',
22 line_items: [
23 {
24 price_data: {
25 currency: tier.currency,
26 product_data: { name: tier.name },
27 unit_amount: tier.priceCents,
28 },
29 quantity: 1,
30 },
31 ],
32 payment_intent_data: {
33 application_fee_amount: applicationFeeCents,
34 transfer_data: {
35 destination: tier.creator.stripeAccountId,
36 },
37 metadata: {
38 tierId: tier.documentId,
39 creatorId: tier.creator.documentId,
40 },
41 },
42 metadata: {
43 tierId: tier.documentId,
44 creatorId: tier.creator.documentId,
45 },
46 success_url: `${process.env.NEXT_PUBLIC_STRAPI_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
47 cancel_url: `${process.env.NEXT_PUBLIC_STRAPI_URL}/cancel`,
48 customer_email: buyerEmail,
49 });
50
51 ctx.body = { url: session.url };
52}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.
The buy button lives in a Client Component. No need for @stripe/stripe-js's redirectToCheckout since the session already returns a hosted URL:
1'use client';
2
3export function BuyButton({ tierId }: { tierId: string }) {
4 async function handleClick() {
5 const res = await fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/checkout/session`, {
6 method: 'POST',
7 headers: { 'Content-Type': 'application/json' },
8 body: JSON.stringify({ tierId, buyerEmail: 'buyer@example.com' }),
9 });
10 const data = await res.json();
11 window.location.assign(data.url);
12 }
13
14 return <button onClick={handleClick}>Buy</button>;
15}Two webhook events matter for this platform: checkout.session.completed (write a Purchase) and account.updated (flip a creator to onboardingComplete).
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:
1// ./src/api/stripe-webhook/middlewares/raw-body.js
2'use strict';
3
4module.exports = (config, { strapi }) => {
5 return async (ctx, next) => {
6 if (ctx.request.path === '/api/stripe-webhook/handle') {
7 await new Promise((resolve, reject) => {
8 let data = '';
9 ctx.req.on('data', (chunk) => {
10 data += chunk;
11 });
12 ctx.req.on('end', () => {
13 ctx.request.rawBody = data;
14 resolve();
15 });
16 ctx.req.on('error', reject);
17 });
18 }
19 await next();
20 };
21};The route file disables auth and attaches the middleware:
1// ./src/api/stripe-webhook/routes/stripe-webhook.js
2module.exports = {
3 routes: [
4 {
5 method: 'POST',
6 path: '/stripe-webhook/handle',
7 handler: 'api::stripe-webhook.stripe-webhook.handle',
8 config: {
9 auth: false,
10 middlewares: ['api::stripe-webhook.raw-body'],
11 },
12 },
13 ],
14};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.
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:
1// ./src/api/stripe-webhook/controllers/stripe-webhook.js
2const Stripe = require('stripe');
3const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
4
5module.exports = {
6 async handle(ctx) {
7 const signature = ctx.request.headers['stripe-signature'];
8 let event;
9
10 try {
11 event = stripe.webhooks.constructEvent(
12 ctx.request.rawBody,
13 signature,
14 process.env.STRIPE_WEBHOOK_SECRET
15 );
16 } catch (err) {
17 ctx.response.status = 400;
18 ctx.body = `Webhook Error: ${err.message}`;
19 return;
20 }
21
22 if (event.type === 'checkout.session.completed') {
23 const session = event.data.object;
24 const { tierId, creatorId } = session.metadata;
25
26 await strapi.documents('api::purchase.purchase').create({
27 data: {
28 stripePaymentIntentId: session.payment_intent,
29 amountCents: session.amount_total,
30 applicationFeeCents: Math.round(session.amount_total * 0.10),
31 buyerEmail: session.customer_email,
32 status: 'paid',
33 tier: tierId,
34 creator: creatorId,
35 },
36 });
37 }
38
39 if (event.type === 'account.updated') {
40 const account = event.data.object;
41
42 const creators = await strapi.documents('api::creator.creator').findMany({
43 filters: { stripeAccountId: account.id },
44 });
45
46 if (creators.length > 0) {
47 await strapi.documents('api::creator.creator').update({
48 documentId: creators[0].documentId,
49 data: {
50 onboardingComplete: account.details_submitted && account.charges_enabled,
51 },
52 });
53 }
54 }
55
56 ctx.response.status = 200;
57 ctx.body = { received: true };
58 },
59};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.
checkout.session.completedThe checkout.session.completed handler extracts tierId and creatorId from the session's metadata and writes a Purchase record through the Document Service.
status field can be set to paid when handling checkout.session.completed for immediate payment methods like cards, but you should confirm this from the session's payment_status rather than assuming checkout.session.completed always means payment has succeeded. checkout.session.async_payment_succeeded or payment_intent.succeeded instead, 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.
account.updatedThe 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:
stripeAccountId onboardingComplete based on whether both details_submitted and charges_enabled are true 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.
Three pages drive the storefront: a creators index, a creator profile with tier cards, and an onboarding launcher for creators.
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:
1// app/page.tsx
2export default async function HomePage() {
3 const res = await fetch(
4 `${process.env.NEXT_PUBLIC_STRAPI_URL}/api/creators?populate[tiers]=true&populate[avatar]=true`,
5 { cache: 'no-store' }
6 );
7 const { data } = await res.json();
8
9 return (
10 <main>
11 {data.map((creator: any) => (
12 <a key={creator.documentId} href={`/${creator.slug}`}>
13 <h2>{creator.displayName}</h2>
14 <p>{creator.tiers?.length ?? 0} tiers</p>
15 </a>
16 ))}
17 </main>
18 );
19}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.
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:
1// app/[slug]/page.tsx
2import { TierCard } from './tier-card';
3
4export default async function CreatorPage({
5 params,
6}: {
7 params: Promise<{ slug: string }>
8}) {
9 const { slug } = await params;
10
11 const res = await fetch(
12 `${process.env.NEXT_PUBLIC_STRAPI_URL}/api/creators?filters[slug][$eq]=${slug}&populate[tiers]=true&populate[avatar]=true`
13 );
14 const { data } = await res.json();
15
16 if (!data || data.length === 0) {
17 return (
18 <main>
19 <h1>Creator not found</h1>
20 <p>No creator exists with the slug "{slug}".</p>
21 </main>
22 );
23 }
24
25 const creator = data[0];
26
27 return (
28 <main>
29 <h1>{creator.displayName}</h1>
30 <p>{creator.bio}</p>
31 <div className="grid grid-cols-3 gap-4">
32 {creator.tiers?.map((tier: any) => (
33 <TierCard key={tier.documentId} tier={tier} />
34 ))}
35 </div>
36 </main>
37 );
38}The TierCard component lives in app/[slug]/tier-card.tsx as a Client Component, since it needs an onClick handler for checkout.
TierCard is a Client Component that formats the price and triggers checkout:
1// app/[slug]/tier-card.tsx
2'use client';
3
4export function TierCard({ tier }: { tier: any }) {
5 const price = (tier.priceCents / 100).toLocaleString(undefined, {
6 style: 'currency',
7 currency: tier.currency,
8 });
9
10 async function handleBuy() {
11 const res = await fetch(
12 `${process.env.NEXT_PUBLIC_STRAPI_URL}/api/checkout/session`,
13 {
14 method: 'POST',
15 headers: { 'Content-Type': 'application/json' },
16 body: JSON.stringify({ tierId: tier.documentId, buyerEmail: 'buyer@example.com' }),
17 }
18 );
19 const data = await res.json();
20 window.location.assign(data.url);
21 }
22
23 return (
24 <div className="border rounded p-4">
25 <h3>{tier.name}</h3>
26 <p>{tier.description}</p>
27 <p className="text-xl font-bold">{price}</p>
28 <button onClick={handleBuy} className="bg-blue-600 text-white px-4 py-2 rounded mt-2">
29 Buy
30 </button>
31 </div>
32 );
33}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:
1'use client';
2import { useState, useEffect } from 'react';
3
4export default function PayoutsPage() {
5 const [status, setStatus] = useState<string>('loading');
6 const creatorId = 'YOUR_CREATOR_DOCUMENT_ID'; // from auth context in production
7
8 useEffect(() => {
9 fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/stripe-connect/status/${creatorId}`)
10 .then(res => res.json())
11 .then(data => setStatus(data.onboardingComplete ? 'active' : 'incomplete'));
12 }, []);
13
14 async function startOnboarding() {
15 const res = await fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/stripe-connect/onboard`, {
16 method: 'POST',
17 headers: { 'Content-Type': 'application/json' },
18 body: JSON.stringify({ documentId: creatorId, email: 'creator@example.com' }),
19 });
20 const data = await res.json();
21 window.location.assign(data.url);
22 }
23
24 if (status === 'active') return <p>Payouts active</p>;
25 return <button onClick={startOnboarding}>Finish payout setup</button>;
26}This section covers end-to-end testing with the Stripe CLI, common issues you may encounter, and the changes needed before going live.
Forward webhooks locally with the Stripe CLI:
1stripe 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:
0000000000 and SSN last four 0000 4242 4242 4242 4242 (any CVC, any future expiry) paidWebhook 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:
1stripe listen --forward-connect-to localhost:1337/api/stripe-webhook/handleOr forward both simultaneously:
1stripe listen \
2 --forward-to localhost:1337/api/stripe-webhook/handle \
3 --forward-connect-to localhost:1337/api/stripe-webhook/handleThese are the key changes to make before flipping to live mode:
sk_test_ and pk_test_ keys for live equivalents in your production environment checkout.session.completed and account.updated config/database.js for a self-hosted Postgres instance return_url and refresh_url in test mode (localhost), but live mode requires HTTPS. 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.
strapi.documents().create() call; creator.displayName; stripeAccountId from leaking through the REST API; npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.