●PLAY — Google Play's target API level 36 requirement took effect yesterday, August 31. From today, new apps and updates must target Android 16●VISIBILITY — Apps still on API 35 stay listed but disappear for users on newer Android versions. No error is raised; new installs simply fade, which makes the change easy to miss●EXTENSION — If you missed the deadline, an extension through November 1, 2026 can be requested in Play Console — best filed alongside a concrete migration plan●APPLE — On the Apple side, the event lands September 9 and iOS 27 is reported to ship September 14. Testing generated apps on iOS 27 hardware before release week is time well spent●EXPO — Expo released expo-paste-input on August 28, a native module that brings image, GIF, and sticker paste to React Native TextInput●EAS — EAS Observe reached general availability on August 20, putting crash and performance monitoring on the same EAS platform as builds and updates●PLAY — Google Play's target API level 36 requirement took effect yesterday, August 31. From today, new apps and updates must target Android 16●VISIBILITY — Apps still on API 35 stay listed but disappear for users on newer Android versions. No error is raised; new installs simply fade, which makes the change easy to miss●EXTENSION — If you missed the deadline, an extension through November 1, 2026 can be requested in Play Console — best filed alongside a concrete migration plan●APPLE — On the Apple side, the event lands September 9 and iOS 27 is reported to ship September 14. Testing generated apps on iOS 27 hardware before release week is time well spent●EXPO — Expo released expo-paste-input on August 28, a native module that brings image, GIF, and sticker paste to React Native TextInput●EAS — EAS Observe reached general availability on August 20, putting crash and performance monitoring on the same EAS platform as builds and updates
My Own Revenue and Someone Else's Are Not the Same Thing
Every Stripe integration I had built before this one — all of them as an indie developer working alone — moved money into my own account. Whether it was a one-time purchase or a subscription, a failed charge only ever inconvenienced me.
A marketplace changes that. The buyer's money passes through your platform on its way to a seller. Get the implementation wrong and you are the one standing between two strangers, breaking a trust neither of them extended to you directly. I remember typing out the platform fee constant in a test environment and feeling my hand slow down.
Standard Stripe payments were never designed for that three-party flow. Collect, split, forward — that is what Stripe Connect exists to handle.
What follows is a build that generates the UI with Rork, runs the backend on Supabase Edge Functions, and hands the actual movement of funds to Stripe Connect. Alongside the working code, I want to spend real time on the failures that test cards never reproduce: duplicate Webhook deliveries, sellers stuck at charges_enabled: false, and the rounding problems that only surface on a partial refund.
1. Stripe Connect Fundamentals: Choosing the Right Account Type
Stripe Connect offers three account types, and your choice will shape your entire architecture.
Standard accounts give sellers their own full Stripe dashboard. They manage their account independently, and Stripe handles onboarding UI out of the box. Implementation is low-cost, but you have limited programmatic control over seller accounts.
Express accounts are the sweet spot for most marketplaces. Stripe hosts a streamlined onboarding flow and handles KYC (Know Your Customer) verification on your behalf, which dramatically reduces your compliance burden. They pair naturally with Destination Charges. For solo-developed marketplaces, Express is the right default.
Custom accounts offer complete UI control, but you take on KYC responsibility and the associated legal liability. This is not appropriate for early-stage products.
We'll use Express accounts with Destination Charges throughout this guide.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Developers stuck on Stripe Connect setup can implement the complete seller account registration, KYC, and onboarding flow from Rork today
✦Get copy-paste-ready code for marketplace fee distribution and automated seller payouts using Destination Charges
✦From Supabase integration and Webhook handling to a production launch checklist — everything you need to ship a monetizable marketplace as a solo developer
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
We'll use Supabase as the backend. Start by creating the tables you'll need.
Table Schema
-- Sellers table (linked to Stripe Connect accounts)create table sellers ( id uuid primary key default gen_random_uuid(), user_id uuid references auth.users(id) not null, stripe_account_id text unique, -- Stripe Connect account ID (acct_xxxx) onboarding_complete boolean default false, charges_enabled boolean default false, payouts_enabled boolean default false, created_at timestamptz default now());-- Products tablecreate table products ( id uuid primary key default gen_random_uuid(), seller_id uuid references sellers(id) not null, title text not null, description text, price integer not null, -- Price in smallest currency unit currency text default 'usd', image_url text, status text default 'active', -- active / sold / deleted created_at timestamptz default now());-- Orders tablecreate table orders ( id uuid primary key default gen_random_uuid(), product_id uuid references products(id) not null, buyer_user_id uuid references auth.users(id) not null, seller_id uuid references sellers(id) not null, amount integer not null, -- Total charge amount platform_fee integer not null, -- Platform's cut stripe_payment_intent_id text unique, stripe_transfer_id text, status text default 'pending', -- pending / paid / shipped / completed / refunded created_at timestamptz default now());
Row Level Security
-- Sellers can only access their own recordalter table sellers enable row level security;create policy "sellers_own_record" on sellers for all using (auth.uid() = user_id);-- Products are publicly readable; only seller can modifyalter table products enable row level security;create policy "products_read_all" on products for select using (status = 'active');create policy "products_write_own" on products for all using ( seller_id in (select id from sellers where user_id = auth.uid()) );-- Orders visible only to buyer and selleralter table orders enable row level security;create policy "orders_participant_only" on orders for select using ( buyer_user_id = auth.uid() or seller_id in (select id from sellers where user_id = auth.uid()) );
3. Creating a Stripe Connect Account and Onboarding
Here's the flow: seller taps "Register as Seller" → your backend creates a Stripe Express account → Stripe serves their KYC screen → seller completes verification → your app gets notified via Webhook.
4. Destination Charges: Collecting Payment and Splitting Fees
With Destination Charges, a single API call handles everything: collect from the buyer, route funds to the seller, and retain your platform fee automatically.
Without Webhooks, orders stay in "pending" indefinitely after successful payment. This is the glue that keeps your database consistent with Stripe's state.
// supabase/functions/stripe-webhook/index.tsimport { serve } from "https://deno.land/std@0.168.0/http/server.ts";import Stripe from "https://esm.sh/stripe@14.0.0";import { createClient } from "https://esm.sh/@supabase/supabase-js@2";const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { apiVersion: "2024-06-20", httpClient: Stripe.createFetchHttpClient(),});serve(async (req) => { const body = await req.text(); const signature = req.headers.get("stripe-signature")!; const webhookSecret = Deno.env.get("STRIPE_WEBHOOK_SECRET")!; let event: Stripe.Event; try { // Must use async version in Deno/Cloudflare Workers environments event = await stripe.webhooks.constructEventAsync( body, signature, webhookSecret, undefined, Stripe.createSubtleCryptoProvider() ); } catch (err) { console.error("Webhook verification failed:", err); return new Response("Invalid signature", { status: 400 }); } const supabase = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! ); switch (event.type) { case "payment_intent.succeeded": { const pi = event.data.object as Stripe.PaymentIntent; await supabase .from("orders") .update({ status: "paid" }) .eq("stripe_payment_intent_id", pi.id); const { data: order } = await supabase .from("orders") .select("product_id") .eq("stripe_payment_intent_id", pi.id) .single(); if (order) { await supabase .from("products") .update({ status: "sold" }) .eq("id", order.product_id); } break; } case "payment_intent.payment_failed": { const pi = event.data.object as Stripe.PaymentIntent; await supabase .from("orders") .update({ status: "failed" }) .eq("stripe_payment_intent_id", pi.id); break; } case "account.updated": { const account = event.data.object as Stripe.Account; await supabase .from("sellers") .update({ charges_enabled: account.charges_enabled, payouts_enabled: account.payouts_enabled, onboarding_complete: account.details_submitted, }) .eq("stripe_account_id", account.id); break; } default: console.log(`Unhandled event: ${event.type}`); } return new Response(JSON.stringify({ received: true }), { headers: { "Content-Type": "application/json" }, });});
6. Seller Dashboard: Balance and Payout History
Give sellers visibility into their earnings with a simple dashboard endpoint.
Refunds on Destination Charges require reversing the transfer to the seller. Forget this step and the money stays with the seller even after the refund is issued.
// supabase/functions/process-refund/index.ts (admin-only endpoint)import { serve } from "https://deno.land/std@0.168.0/http/server.ts";import Stripe from "https://esm.sh/stripe@14.0.0";import { createClient } from "https://esm.sh/@supabase/supabase-js@2";const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { apiVersion: "2024-06-20",});serve(async (req) => { const { orderId, reason } = await req.json(); const supabase = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! ); const { data: order } = await supabase .from("orders") .select("*") .eq("id", orderId) .single(); if (!order || order.status !== "paid") { return new Response("Order not eligible for refund", { status: 400 }); } const refund = await stripe.refunds.create({ payment_intent: order.stripe_payment_intent_id, reason: reason ?? "requested_by_customer", reverse_transfer: true, // Pull funds back from the seller refund_application_fee: true, // Refund the platform fee too }); await supabase .from("orders") .update({ status: "refunded" }) .eq("id", orderId); await supabase .from("products") .update({ status: "active" }) .eq("id", order.product_id); return new Response( JSON.stringify({ refundId: refund.id, status: refund.status }), { headers: { "Content-Type": "application/json" } } );});
8. Three Failure Modes That Never Showed Up in Test Mode
Everything above passes cleanly in test mode. The three problems below are the ones I hit anyway — none of them reproduce no matter how many times you run card 4242.
Failure 1: Stripe delivers the same event twice
Webhook delivery is at-least-once. If your endpoint is slow to return 200, or the connection drops mid-response, Stripe resends the same payment_intent.succeeded. The handler shown earlier is not idempotent, so the second delivery overwrites the product status again and — if you aggregate revenue per event — double-counts the sale.
The fix is to claim the event in the database before doing any work.
create table processed_webhook_events ( event_id text primary key, event_type text not null, processed_at timestamptz not null default now());
// Insert immediately after signature verification in stripe-webhook/index.tsconst { error: dupError } = await supabase .from("processed_webhook_events") .insert({ event_id: event.id, event_type: event.type });if (dupError) { // Primary key collision means we already handled it. Return 200 to stop retries. if (dupError.code === "23505") { return new Response(JSON.stringify({ received: true, duplicate: true }), { headers: { "Content-Type": "application/json" }, }); } // Any other database error: return 500 so Stripe retries the delivery. return new Response("DB error", { status: 500 });}
Order matters here. If you record the event after processing it, a crash mid-handler leaves you with work that was done but never logged. Claiming the event first and returning 500 on failure lets Stripe's retry mechanism cover you in both directions — no duplicates, no silent drops.
Failure 2: One missed account.updated leaves a seller permanently unable to sell
If charges_enabled is only ever updated by Webhook, a single undelivered event leaves an approved seller marked as pending in your app — with no way for them to tell. Your payment API rejects their listings, and the buyer sees nothing more useful than "this seller cannot accept payments."
Treat the Webhook as the fast path and an explicit sync as the reliable one.
// supabase/functions/sync-seller-status/index.ts// Call on return from onboarding, and whenever the seller dashboard loads.const { data: seller } = await supabase .from("sellers") .select("stripe_account_id") .eq("user_id", user.id) .single();const account = await stripe.accounts.retrieve(seller.stripe_account_id);await supabase .from("sellers") .update({ charges_enabled: account.charges_enabled, payouts_enabled: account.payouts_enabled, onboarding_complete: account.details_submitted, }) .eq("stripe_account_id", account.id);return new Response( JSON.stringify({ chargesEnabled: account.charges_enabled, // Show the seller exactly what is still missing currentlyDue: account.requirements?.currently_due ?? [], pastDue: account.requirements?.past_due ?? [], disabledReason: account.requirements?.disabled_reason ?? null, }), { headers: { "Content-Type": "application/json" } });
Surfacing requirements.currently_due in the UI changes your support load measurably — and as an indie developer, support load is the resource you have least of. A screen that says only "verification in progress" gives the seller no way to tell whether anything is happening. Listing the outstanding requirement names — even raw — lets a good share of them resolve it without contacting you.
Failure 3: Rounding stays invisible until the first partial refund
application_fee_amount must be an integer or Stripe returns invalid_request_error. Because JPY has no decimal subunit, a 10% fee has to be rounded with something like Math.floor(amount * 0.10). The code earlier in this guide already does that.
Partial refunds are where it bites. refund_application_fee: true returns the platform fee in full. Refund ¥1,000 of a ¥3,000 order and your entire ¥300 fee goes back with it — every partial refund quietly costs you money.
To prorate, refund the fee explicitly instead.
// Partial refund: prorate the platform fee to match the refunded shareconst refundAmount = 1000; // amount to refundconst orderAmount = order.amount; // 3000const orderFee = order.platform_fee; // 300// Round so the platform absorbs the remainder, never the sellerconst feeRefund = Math.floor((orderFee * refundAmount) / orderAmount);const refund = await stripe.refunds.create({ payment_intent: order.stripe_payment_intent_id, amount: refundAmount, reason: "requested_by_customer", reverse_transfer: true, refund_application_fee: false, // do not return the whole fee});// Refund the prorated fee through a separate API callif (feeRefund > 0) { const charge = await stripe.charges.retrieve(refund.charge as string); await stripe.applicationFees.createRefund( charge.application_fee as string, { amount: feeRefund } );}
Math.floor is deliberate: the rounding remainder lands on the platform rather than the seller. The amount is trivial, but "the numbers don't add up" is a trust problem, not an accounting one. When a split calculation is ambiguous, round against yourself and you will spend far less time explaining it later.
9. Pre-Launch Compliance Checklist
Don't skip this. Marketplaces are subject to tighter scrutiny than standard payment processors.
Stripe Dashboard Configuration — In your Connect settings, fill in your platform's business description, support email, and branding. This text appears on the seller onboarding screen. Accept the Stripe Connect service agreement (required for live mode).
Testing Checklist
Run through each of these in test mode before going live: complete the buyer purchase flow using card 4242 4242 4242 4242, verify that Webhooks are received and orders update correctly (use stripe listen --forward-to locally), test the refund flow end-to-end, and confirm that account.updated events with charges_enabled: true correctly update your sellers table.
Security Checklist
Confirm that RLS is enabled on all tables, that your secret keys are stored in environment variables (never in source code), that Webhook signature verification is active, and that admin-only endpoints (like refunds) have authentication that prevents sellers and buyers from calling them directly.
Legal Considerations
Platform businesses that temporarily hold funds may be subject to money transmission regulations, which vary by country. In the US, this depends on your state and business structure. Before launching, consult a legal professional to understand your obligations. Stripe's documentation on their regulatory compliance framework is a good starting point.
10. Questions Worth Settling Before You Build
How does the app learn that a seller has been approved?
For individuals, uploading a government-issued ID through Stripe's hosted onboarding typically results in approval within 1–3 business days; businesses can take longer. Your signal is charges_enabled on the account.updated Webhook — but as covered above, relying on that alone leaves you with no recovery path when a delivery is missed. Take the Webhook as a fast notification and re-check with accounts.retrieve whenever the seller opens the app. Building both paths up front is far cheaper than retrofitting one later.
What platform fee percentage should you set?
Resale and physical goods marketplaces typically charge 8–15%; service and digital platforms tend to run 15–25%. The number people forget to account for is processing: Stripe takes its own cut first (2.9% + 30¢ in the US), so a 10% platform fee nets you roughly 6–7%. That gap hurts most on low-priced listings, which is why a minimum fee floor — say $0.50 per transaction — is worth considering alongside the percentage.
11. Advanced Patterns for Production Marketplaces
Once the core payment flow is working, these patterns will make your marketplace significantly more robust and seller-friendly.
Escrow-Style Delayed Payouts
For physical goods or service marketplaces, you may want to hold funds until the buyer confirms receipt. Use capture_method: "manual" to authorize without immediately capturing:
// Create PaymentIntent with manual capture (7-day authorization window)const paymentIntent = await stripe.paymentIntents.create({ amount, currency: "usd", transfer_data: { destination: sellerAccountId }, application_fee_amount: platformFee, capture_method: "manual", metadata: { product_id: productId },});// When buyer confirms receipt — capture the authorized fundsawait stripe.paymentIntents.capture(paymentIntent.id);// If there's a dispute before capture — cancel without chargeawait stripe.paymentIntents.cancel(paymentIntent.id);
This gives buyers meaningful protection without requiring a custom escrow service.
Tiered Commission Rates
Reward your best sellers with lower fees. Make the platform fee rate a function of the seller's sales history:
Tiered fees incentivize high-volume sellers to stay on your platform rather than seeking cheaper alternatives.
Seller Onboarding Reminders
Sellers who start but don't finish KYC are a significant source of lost supply. A scheduled Edge Function can generate fresh onboarding links and send reminders:
// Identify sellers who started onboarding 24+ hours ago but haven't completed itconst { data: pendingSellers } = await supabase .from("sellers") .select("user_id, stripe_account_id, created_at") .eq("onboarding_complete", false) .not("stripe_account_id", "is", null) .lt("created_at", new Date(Date.now() - 86_400_000).toISOString());for (const seller of pendingSellers ?? []) { // AccountLinks expire; always generate a fresh one const link = await stripe.accountLinks.create({ account: seller.stripe_account_id, refresh_url: `${APP_URL}/seller/onboarding?reauth=true`, return_url: `${APP_URL}/seller/onboarding?success=true`, type: "account_onboarding", }); await sendReminderEmail(seller.user_id, link.url);}
Audit Logging for Compliance
Every marketplace that handles real money should maintain an append-only audit log:
// Call this on every order status transitionawait supabase.from("order_audit_log").insert({ order_id: orderId, old_status: previousStatus, new_status: newStatus, source: "webhook", stripe_event_id: event.id, recorded_at: new Date().toISOString(),});
When a buyer disputes a charge, this log is the difference between resolving the case in minutes versus hours.
12. Monitoring and Operations
Running a live marketplace requires ongoing visibility into payment health and the ability to respond quickly when things go wrong.
Stripe Dashboard Alerts — Enable Radar rules to flag unusual patterns (large transactions from new accounts, repeated failed attempts from the same IP). Subscribe to account.application.deauthorized events to know immediately when a seller disconnects your platform. Monitor your Webhook event delivery health under Developers → Webhooks in the Stripe dashboard.
Metrics That Matter
Track these figures weekly. Gross Merchandise Value (GMV) is total payment volume before fees — the headline number for any marketplace. Take Rate is your actual net revenue as a percentage of GMV; it fluctuates with tiered pricing and refunds. Seller Activation Rate measures what proportion of registered sellers complete KYC and make at least one sale. Authorization Rate is the percentage of payment attempts that succeed — a rate below 90% suggests friction in your checkout UX or risk signals in your transactions.
Stripe Outage Protocol
When Stripe experiences degradation (monitor status.stripe.com), Webhook delivery may be delayed. Never confirm orders to buyers based solely on client-side success callbacks. Always wait for the payment_intent.succeeded Webhook before updating order status, triggering fulfillment, or sending confirmation emails. Building this delay tolerance into your UX from the start will save you painful retrofits later.
Where to Start Tomorrow
You now have seller onboarding, Destination Charges with automatic fee splitting, idempotent Webhook handling, and prorated fees on partial refunds.
None of it has to ship at once. The path that worked for me was to get one seller, one listing, and no refunds working end to end in test mode first — proving a payment could travel the full distance — and only then add the idempotency table and the sync path. The three failure modes above surface under real traffic, not in test mode, which is exactly why they are worth adding right after your first successful charge rather than in the last week before launch.
If you do one thing next, create the processed_webhook_events table and add a single insert immediately after signature verification in your existing handler. That one line closes the double-counting failure, which is the hardest of the three to notice once it starts.
One caution worth stating plainly: holding funds on behalf of other people invites regulatory requirements that vary by country and by scale. Talking to a professional while the architecture is still on paper is much cheaper than rebuilding it afterward.
Thank you for reading this far. Parts of this are still evolving on my side, so if you find something in your own implementation that contradicts what is here, I would genuinely like to hear about it.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.