Build This Now
Build This Now
O que é o Código Claude?Instalar o Claude CodeInstalador Nativo do Claude CodeO Teu Primeiro Projeto com Claude Code
Subscription BillingMulti-Tenant SaaSAI Chat FeatureRate LimitingStripe WebhooksSemantic SearchRealtime UpdatesDrip Email Sequencesv2.1.122 Release NotesClaude Code Dynamic Workflows: Como Orquestrar 1.000 Subagentes Num Codebase RealMelhores Práticas do Claude CodeBoas Práticas para o Claude Opus 4.7Claude Code num VPSIntegração GitRevisão de Código com ClaudeWorktrees no Claude CodeControle Remoto do Claude CodeChannels do Claude CodeChannels, Routines, Teleport, DispatchTarefas Agendadas no Claude CodePermissões do Claude CodeModo Auto do Claude CodeAdicionar Pagamentos Stripe Com o Claude CodeFeedback LoopsFluxos de Trabalho com TodosTarefas no Claude CodeTemplates de ProjetoPreços e Consumo de Tokens no Claude CodePreços do Claude Code: O Que Vais Mesmo PagarClaude Code Ultra ReviewConstruir Uma App Next.js Com o Claude CodeSupabase DatabaseVercel DeepsecTest-Driven DevelopmentComo Construir um MVP de SaaS Com o Claude CodeAdicionar Autenticação Com o Claude Code (Supabase Auth)Adicionar Email Transacional Com o Claude Code (Resend + React Email)Construir Uma API Type-Safe Com o Claude Code (oRPC + Zod)File UploadsBackground Jobs (Inngest)Admin DashboardFull-Text SearchComércio Agêntico: Como Construir uma App Que Agentes de IA Podem Pagar1M ContextUser API KeysGitHub ActionsHeadless ModeMax Plan vs APIPrompt CachingRoles & PermissionsMarketplace PaymentsUsage-Based BillingQuanto Custa Construir um SaaS com Claude Code em 2026Parallel AI AgentsCoding Agent Injection
speedy_devvkoen_salo
Blog/Handbook/Workflow/Marketplace Payments

Marketplace Payments

Build a two-sided marketplace with Stripe Connect in Next.js 16: onboard sellers with Express accounts, take split payments with application fees, and sync Connect webhooks into Supabase.

Pare de configurar. Comece a construir.

Templates SaaS com orquestração de IA.

Veja o que construímos para empresas →
speedy_devvWritten by speedy_devvPublished Jul 14, 202611 min readHandbook hubWorkflow index

A two-sided marketplace has one hard part, and it is not the product listings. It is moving money from a buyer to a seller while your platform skims a cut, keeps the accounting clean, and never touches a seller's bank details. Stripe Connect handles that money movement. This guide wires it into a Next.js 16 app end to end: onboard sellers with Express accounts, take a split payment with an application fee, and sync every Connect webhook into Supabase so your database always mirrors Stripe.

The Money Flow, in Plain English

Before any code, get the shape of the transaction clear. In a Connect marketplace there are three parties: the buyer who pays, the seller who fulfills, and your platform in the middle.

The cleanest pattern for this is a destination charge. The buyer pays your platform. Stripe immediately and automatically transfers most of that money to the seller's connected account, and your platform keeps a slice called the application fee. One charge, one API call, and the split happens for you.

Two Stripe pieces make this work. First, each seller gets a connected account (an Express account), which is a Stripe account your platform provisions and the seller lightly owns. Second, when you create the payment you pass application_fee_amount (your cut) and transfer_data.destination (the seller's account id). Stripe does the rest.

Your job in the app is to keep a local mirror of two things: which sellers can accept money yet, and what money moved. That mirror lives in Supabase and stays current through webhooks.

Environment and Clients

You need a Stripe account with Connect enabled (turn it on in the Dashboard under Connect), plus Supabase for the database. Four environment variables drive everything. The service role key is server-only and never ships to the browser.

STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_ROLE_KEY=service_role_key_...
NEXT_PUBLIC_URL=http://localhost:3000

Create one Stripe client the whole app shares. Leaving the API version unset lets stripe-node use the version pinned to the installed package, which avoids a mismatch bug the first time you upgrade.

// lib/stripe.ts
import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

The webhook writes to Supabase from the server with no user session attached, so it uses a service-role client that bypasses row-level security. Keep this import out of any Client Component.

// lib/supabase-admin.ts
import { createClient } from "@supabase/supabase-js";

export const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
  { auth: { persistSession: false } },
);

The Data Model

Three tables carry the marketplace: sellers maps a user to their Stripe account and tracks onboarding status, orders records each completed sale, and payouts tracks money reaching seller bank accounts. Every boolean on sellers is a copy of a Stripe flag that the webhook keeps fresh.

Row-level security is on for all three. Sellers can read their own rows and nobody else's. The webhook writes through the service-role key, which is exempt from these policies, so the policies only govern what a logged-in seller sees in the app.

create table sellers (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users(id),
  stripe_account_id text unique,
  charges_enabled boolean not null default false,
  payouts_enabled boolean not null default false,
  details_submitted boolean not null default false,
  created_at timestamptz not null default now()
);

create table orders (
  id uuid primary key default gen_random_uuid(),
  seller_id uuid not null references sellers(id),
  buyer_email text,
  stripe_session_id text unique,
  stripe_payment_intent text,
  amount_total integer not null,
  application_fee integer not null,
  currency text not null default 'usd',
  status text not null default 'pending',
  created_at timestamptz not null default now()
);

create table payouts (
  id uuid primary key default gen_random_uuid(),
  seller_id uuid not null references sellers(id),
  stripe_payout_id text unique,
  amount integer not null,
  currency text not null default 'usd',
  status text not null,
  arrival_date timestamptz,
  created_at timestamptz not null default now()
);

alter table sellers enable row level security;
alter table orders enable row level security;
alter table payouts enable row level security;

create policy "sellers read own row"
  on sellers for select
  using (auth.uid() = user_id);

create policy "sellers read own orders"
  on orders for select
  using (seller_id in (select id from sellers where user_id = auth.uid()));

create policy "sellers read own payouts"
  on payouts for select
  using (seller_id in (select id from sellers where user_id = auth.uid()));

All money columns are integers in the smallest currency unit (cents), because that is exactly how Stripe represents amounts. Never store money as a float.

Onboarding a Seller

A seller cannot receive money until Stripe has collected their identity and bank details. Stripe runs that collection for you through a hosted onboarding form. Your job is two calls: create an Express account, then create an account link that sends the seller to Stripe's form.

This route handler does both. It reuses an existing account if the seller already started onboarding, so a half-finished seller who comes back does not spawn a duplicate account.

// app/api/connect/onboard/route.ts
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { supabaseAdmin } from "@/lib/supabase-admin";

export async function POST(req: Request) {
  const { userId, email } = await req.json();

  const { data: existing } = await supabaseAdmin
    .from("sellers")
    .select("stripe_account_id")
    .eq("user_id", userId)
    .maybeSingle();

  let accountId = existing?.stripe_account_id;

  if (!accountId) {
    const account = await stripe.accounts.create({
      type: "express",
      email,
      capabilities: {
        card_payments: { requested: true },
        transfers: { requested: true },
      },
    });
    accountId = account.id;

    await supabaseAdmin.from("sellers").insert({
      user_id: userId,
      stripe_account_id: accountId,
    });
  }

  const link = await stripe.accountLinks.create({
    account: accountId,
    type: "account_onboarding",
    refresh_url: `${process.env.NEXT_PUBLIC_URL}/sell/onboard/refresh`,
    return_url: `${process.env.NEXT_PUBLIC_URL}/sell/onboard/return`,
  });

  return NextResponse.json({ url: link.url });
}

The two capabilities matter. card_payments lets the seller be charged through your platform, and transfers lets Stripe move money into their account. Without both, a destination charge to this seller fails.

Account links are single-use and expire after a few minutes. That is what refresh_url is for. If the link goes stale before the seller finishes, Stripe bounces them to your refresh route, which simply calls the onboard endpoint again and forwards them to a fresh link. The return_url is where Stripe sends them when they finish or quit.

Checking Onboarding Status

When the seller lands back on your return_url, they may or may not have completed everything. Do not trust the redirect alone. Ask Stripe for the account's real status.

This is a dynamic route handler, so it follows the Next.js 16 rule: params is a Promise and you await it before reading the value.

// app/api/connect/[accountId]/status/route.ts
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ accountId: string }> },
) {
  const { accountId } = await params;
  const account = await stripe.accounts.retrieve(accountId);

  return NextResponse.json({
    chargesEnabled: account.charges_enabled,
    payoutsEnabled: account.payouts_enabled,
    detailsSubmitted: account.details_submitted,
  });
}

charges_enabled is the flag you actually gate on. When it is true, the seller can appear in your marketplace and take payments. payouts_enabled being true means Stripe can pay their bank account. A seller can often charge before payouts clear, so track both separately.

You could poll this route from the return page, but the durable source of truth is the webhook in the next section. The status route is a fast check for a single page. The webhook is what keeps your database honest over time.

Taking a Split Payment

Now the actual sale. A buyer wants to purchase from one seller. You create a Checkout Session as a destination charge: the money settles on your platform, Stripe forwards it to the seller, and your application fee stays with you.

Guard the sale first. If the seller has not finished onboarding, do not let the checkout start, because the charge would fail at Stripe anyway and leave the buyer confused.

// app/api/checkout/route.ts
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { supabaseAdmin } from "@/lib/supabase-admin";

const PLATFORM_FEE_PERCENT = 10;

export async function POST(req: Request) {
  const { sellerId, priceCents, productName } = await req.json();

  const { data: seller } = await supabaseAdmin
    .from("sellers")
    .select("stripe_account_id, charges_enabled")
    .eq("id", sellerId)
    .single();

  if (!seller?.charges_enabled || !seller.stripe_account_id) {
    return NextResponse.json(
      { error: "This seller cannot accept payments yet." },
      { status: 400 },
    );
  }

  const applicationFee = Math.round(
    priceCents * (PLATFORM_FEE_PERCENT / 100),
  );

  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [
      {
        price_data: {
          currency: "usd",
          product_data: { name: productName },
          unit_amount: priceCents,
        },
        quantity: 1,
      },
    ],
    payment_intent_data: {
      application_fee_amount: applicationFee,
      transfer_data: { destination: seller.stripe_account_id },
    },
    success_url: `${process.env.NEXT_PUBLIC_URL}/orders/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/orders/cancelled`,
    metadata: {
      seller_id: sellerId,
      application_fee: String(applicationFee),
    },
  });

  return NextResponse.json({ url: session.url });
}

Two fields do the split. application_fee_amount is your cut in cents, and transfer_data.destination names the seller's connected account. On a 5000 cent sale with a 10 percent fee, Stripe transfers the full 5000 to the seller, then pulls back your 500 application fee. The Stripe processing fee comes out of your platform balance, so your real margin is the fee minus Stripe's cut.

The metadata on the session is not decoration. It carries seller_id and the fee amount into the webhook, so when the payment confirms you can write a complete order row without a second lookup. The {CHECKOUT_SESSION_ID} token in success_url is a literal placeholder Stripe fills in on redirect.

Syncing Connect Webhooks Into Supabase

Everything so far is optimistic. The webhook is what makes it true. Stripe sends events for the whole lifecycle, and one route absorbs them all, verifies the signature, and writes the result into Supabase.

App Router route handlers give you the raw request body directly, which is exactly what signature verification needs. Read it with req.text() and pass it untouched to constructEvent. Do not parse it as JSON first, or the signature check fails.

// app/api/stripe/webhook/route.ts
import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { stripe } from "@/lib/stripe";
import { supabaseAdmin } from "@/lib/supabase-admin";

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get("stripe-signature");

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature!,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch (err) {
    console.error("Webhook signature verification failed", err);
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  switch (event.type) {
    case "account.updated": {
      const account = event.data.object as Stripe.Account;
      await supabaseAdmin
        .from("sellers")
        .update({
          charges_enabled: account.charges_enabled,
          payouts_enabled: account.payouts_enabled,
          details_submitted: account.details_submitted,
        })
        .eq("stripe_account_id", account.id);
      break;
    }

    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      await supabaseAdmin.from("orders").upsert(
        {
          seller_id: session.metadata?.seller_id,
          buyer_email: session.customer_details?.email,
          stripe_session_id: session.id,
          stripe_payment_intent: session.payment_intent as string,
          amount_total: session.amount_total ?? 0,
          application_fee: Number(session.metadata?.application_fee ?? 0),
          currency: session.currency ?? "usd",
          status: "paid",
        },
        { onConflict: "stripe_session_id" },
      );
      break;
    }

    case "payout.paid":
    case "payout.failed": {
      const payout = event.data.object as Stripe.Payout;
      const { data: seller } = await supabaseAdmin
        .from("sellers")
        .select("id")
        .eq("stripe_account_id", event.account)
        .single();

      if (seller) {
        await supabaseAdmin.from("payouts").upsert(
          {
            seller_id: seller.id,
            stripe_payout_id: payout.id,
            amount: payout.amount,
            currency: payout.currency,
            status: payout.status,
            arrival_date: new Date(payout.arrival_date * 1000).toISOString(),
          },
          { onConflict: "stripe_payout_id" },
        );
      }
      break;
    }

    default:
      break;
  }

  return NextResponse.json({ received: true });
}

Three cases carry the marketplace. account.updated fires every time a seller's onboarding state changes, so your sellers flags stay in sync without you polling. checkout.session.completed confirms a real payment and writes the order, reading the seller and fee straight from the metadata you stamped earlier. The two payout events track money landing in seller bank accounts.

Notice the upserts. Stripe can deliver the same event more than once, and your handler has to be idempotent. Keying on stripe_session_id and stripe_payout_id means a duplicate delivery updates the existing row instead of inserting a second one.

One detail is easy to miss. account.updated, payout.paid, and payout.failed are connected-account events. They originate on a seller's account, not your platform account, and Stripe tags them with event.account (the connected account id). Your webhook endpoint has to be registered to listen for events on connected accounts, or these never arrive. You set that when you create the endpoint in the Dashboard by enabling events from connected accounts.

Handling Payouts

Good news: for Express accounts, payouts are automatic by default. Once a seller is onboarded and their balance clears, Stripe pays their bank on a rolling schedule. You do not trigger anything.

Your responsibility is visibility, not mechanics. The payout.paid and payout.failed handlers above give each seller an honest ledger inside your app. A seller can open their dashboard and see money arriving without logging into Stripe, and a failed payout (usually a bad bank detail) surfaces immediately instead of silently.

If you want sellers to see the deeper Stripe view, generate a one-time login link to the Express Dashboard. This is a single call you can drop behind a button.

// app/api/connect/[accountId]/dashboard/route.ts
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ accountId: string }> },
) {
  const { accountId } = await params;
  const loginLink = await stripe.accounts.createLoginLink(accountId);
  return NextResponse.json({ url: loginLink.url });
}

That link drops the seller into a Stripe-hosted dashboard showing their balance, payout history, and transaction detail, with no dashboard for you to build.

Testing the Whole Loop Locally

You do not need real cards or real bank accounts to test this. Stripe's CLI forwards live events to your local server, and test mode accepts placeholder identity data.

Install the CLI, log in, then start forwarding. The listen command prints a webhook signing secret. Copy it into STRIPE_WEBHOOK_SECRET and restart your dev server.

stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook

By default stripe listen forwards both your platform events and connected-account events to that one URL, so a single endpoint covers the whole flow in development. Now walk the path. Hit your onboard route, complete the Express form with Stripe's test values (it never asks for real identity data in test mode), and watch account.updated land and flip charges_enabled to true in Supabase.

Then run a checkout with the classic test card, 4242 4242 4242 4242 and any future expiry, and confirm a row appears in orders. You can also fire specific events without clicking through the UI.

stripe trigger checkout.session.completed
stripe trigger account.updated

If a row does not show up, the CLI window prints the exact event payload and your handler's HTTP response side by side, which is usually enough to spot a bad metadata key or a signature mismatch.

Quality Gates Before You Ship

Two commands run before this goes anywhere near production. Zero type errors and a clean build are the bar, not a suggestion.

npx tsc --noEmit
npm run build

The type check is where a wrong Stripe object cast or a missing await on params surfaces. The build is where a server-only import leaking into a Client Component blows up. Connect code is especially unforgiving here, because a bug that ships is a bug that moves money.

Claude Code can build every route in this guide from a plain-English description, and it gets the Next.js 16 details right (awaited params, raw-body webhooks, service-role writes) when you point it at the framework docs. What it does not do on its own is enforce the loop. That is what the $29 Code Kit adds on top of Claude Code: a harness that plans the feature, builds it, evaluates the output, tests it against a real browser, and runs the quality gates so nothing exits with a type error, a lint failure, or a broken build. It is a one-time $29 purchase with no subscription. Claude Code itself runs on your own paid Anthropic plan. The Kit is the pipeline wrapped around it, which matters most on payment code, where the cost of shipping a bug is not a redeploy but a refund.

Wire the account, gate on charges_enabled, split with an application fee, and let the webhook keep Supabase honest. That is a working marketplace. Everything after is listings, search, and taste.

Posted by @speedy_devv

Continue in Workflow

  • Comércio Agêntico: Como Construir uma App Que Agentes de IA Podem Pagar
    Um guia em português simples sobre comércio agêntico em 2026: o que fazem o x402, o ACP e o Machine Payments Protocol, mais um passo a passo de fim de semana para lançar uma API paga que agentes de IA podem comprar.
  • Melhores Práticas do Claude Code
    Cinco hábitos separam os engenheiros que entregam com Claude Code: PRDs, regras modulares em CLAUDE.md, slash commands personalizados, resets com /clear e uma mentalidade de evolução do sistema.
  • Modo Auto do Claude Code
    Um segundo modelo Sonnet revê cada chamada de ferramenta do Claude Code antes de ser executada. O que o modo auto bloqueia, o que permite e as regras de permissão que cria nas tuas definições.
  • Channels, Routines, Teleport, Dispatch
    As quatro funcionalidades de Claude Code que a Anthropic lançou em março e abril de 2026 e que transformam a CLI numa camada de coordenação orientada a eventos entre telemóvel, web e desktop.
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • How to Build an Admin Dashboard With Claude Code
    Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.

More from Handbook

  • Fundamentos do agente
    Cinco maneiras de criar agentes especializados no Código Claude: Sub-agentes de tarefas, .claude/agents YAML, comandos de barra personalizados, personas CLAUDE.md e prompts de perspetiva.
  • Engenharia de Harness para Agentes
    O harness é cada camada ao redor do seu agente de IA, exceto o modelo em si. Aprenda os cinco pontos de controle, o paradoxo das restrições, e por que o design do harness determina o desempenho do agente mais do que o modelo.
  • Padrões de Agentes
    Orchestrator, fan-out, cadeia de validação, routing especializado, refinamento progressivo e watchdog. Seis formas de orquestração para ligar sub-agentes no Claude Code.
  • Boas Práticas para Equipas de Agentes
    Padrões testados em produção para Equipas de Agentes Claude Code. Prompts de criação ricos em contexto, tarefas bem dimensionadas, posse de ficheiros, modo delegado, e correções das versões v2.1.33-v2.1.45.

Pare de configurar. Comece a construir.

Templates SaaS com orquestração de IA.

Veja o que construímos para empresas →

Roles & Permissions

Build a reusable role based access control layer in Next.js 16: an RBAC schema, a can() guard on oRPC procedures, per-action Supabase RLS policies, and a role editor UI so every new feature ships access-controlled.

Usage-Based Billing

Meter and charge for usage with Stripe: report events to Stripe Billing Meters, gate features on plan limits, and reconcile metered invoices back into Supabase via webhooks in a Next.js 16 app.

On this page

The Money Flow, in Plain English
Environment and Clients
The Data Model
Onboarding a Seller
Checking Onboarding Status
Taking a Split Payment
Syncing Connect Webhooks Into Supabase
Handling Payouts
Testing the Whole Loop Locally
Quality Gates Before You Ship

Pare de configurar. Comece a construir.

Templates SaaS com orquestração de IA.

Veja o que construímos para empresas →