✨ Strapi MCP is now Generally Available - let your agents manage your Strapi content ✨

Ecosystem19 min read

How to Build a Creator Monetization Platform with Strapi, Next.js, and Stripe

June 4, 2026Updated on July 10, 2026
Build a Creator Monetization Platform

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.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.

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-cms

The 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 --tailwind

Confirm 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 stripe

Inside the Next.js project, install both the server SDK and the client-side loader:

cd creator-platform-web
npm install stripe @stripe/stripe-js

Configure 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=true

And 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:

FieldTypeNotes
displayNameStringRequired
slugUIDTarget: displayName
bioTextLong
avatarMediaSingle
stripeAccountIdStringPrivate field
onboardingCompleteBooleanDefault: false
userRelationOne-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:

FieldTypeNotes
nameStringRequired
descriptionTextLong
priceCentsIntegerRequired
currencyStringDefault: usd
creatorRelationMany-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:

FieldTypeNotes
stripePaymentIntentIdStringUnique
amountCentsInteger
applicationFeeCentsInteger
buyerEmailEmail
statusEnumerationValues: pending, paid, failed, refunded
tierRelationMany-to-one with Tier
creatorRelationMany-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) { /* ... */ },
};

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 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.
  • If you add support for asynchronous payment methods, you would need to listen for 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.

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 onboardingComplete based on whether both details_submitted and charges_enabled are 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/handle

Copy 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:

  1. Create a test creator in the Strapi Admin Panel
  2. Trigger onboarding. In Stripe's test identity flow, use phone 0000000000 and SSN last four 0000
  3. Buy a tier with card 4242 4242 4242 4242 (any CVC, any future expiry)
  4. 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/handle

Or forward both simultaneously:

stripe listen \
  --forward-to localhost:1337/api/stripe-webhook/handle \
  --forward-connect-to localhost:1337/api/stripe-webhook/handle

Move from Test Mode to Live Keys

These are the key changes to make before flipping to live mode:

  • Swap sk_test_ and pk_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.completed and account.updated
  • Switch SQLite to Postgres for the Strapi backend, and configure config/database.js for a self-hosted Postgres instance
  • HTTP is permitted for return_url and refresh_url in 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 stripeAccountId from leaking through the REST API;
  • Users and Permissions roles scoped public access to read-only endpoints.

Paul BratslavskyDeveloper Advocate

Related Posts

Lessons learned vibecoiding with Lovable.dev
Beginner·45 min read

How To Build a Frontend with Lovable.dev for Your Strapi Backend

This is my first time using Lovable to build a frontend for a Strapi backend. Here's what I learned. AI tools like and...

·October 30, 2025
App·16 min read

How to Build a Hospital Record App with NextJs and Strapi

Introduction Hospital records help to provide information on the background health conditions of prospective patients....

·April 20, 2022
Build a Banking Dashboard with Strapi 5 & Next.js 16
Ecosystem·15 min read

How to Build a Banking Dashboard with Strapi 5 and Next.js 16

Every banking dashboard project starts with the same three problems: you need a frontend to render data, an admin...

·May 28, 2026