I'm building .
I went with Express for one reason: Stripe handles identity verification and onboarding for you.
When a creator opens a Stripe account, they need to verify their identity and register a bank account. Building those screens yourself is a massive time sink. With Express, Stripe provides the entire onboarding flow — a huge win for solo developers.
const account = await stripe.accounts.create({
type: 'express',
country: 'JP',
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
business_type: 'individual',
});
Legal requirements before setting up Stripe Connect
This gets overlooked surprisingly often. In practice, Stripe's review team expected these pages to be live on my site before approval:
- Privacy Policy
- Terms of Service
E-commerce disclosure page — In Japan, this is required under the Act on Specified Commercial Transactions (特定商取引法). It's prescriptive: you must publish your seller name, address, return policy, and pricing. Other countries have analogous requirements.
This isn't Connect-specific. It's a prerequisite for accepting any payments. Get these pages live before you start the Connect application.
Heads up: Stripe's review team flagged the title of my disclosure page. The exact wording needed to match the legally prescribed format. They also required my seller name and responsible person to match my Stripe account registration exactly. Small details, but they'll hold up your review.
Why Stripe flagged my platform as aggregation
When the "Your account has been closed" email arrived, I froze. I had just finished wiring up the Supabase RLS policies. The platform was ready. And then Stripe shut the account down.
I'd previously integrated Stripe Connect for another project (Sapolova, a creator support platform), and that review went smoothly. The business model was simpler, and the review was straightforward.
Lovai was different. Stripe closed my account.
What Stripe told me
The email said Lovai's business fell under "aggregation" — one of their : Stripe processes the charge on the platform account, routes funds to the creator's connected account, returns the application fee to the platform, and debits Stripe processing fees from the platform balance.
Stripe Connect Express: onboarding implementation
This is the flow for creators to set up their Stripe Connect account.
export async function createConnectAccount() {
const userId = await getAuthUserId();
// Check for existing account (prevent duplicates)
const { data: existingAccount } = await serviceClient
.from('creator_stripe_accounts')
.select('stripe_account_id, details_submitted')
.eq('user_id', userId)
.maybeSingle();
let accountId: string;
if (existingAccount) {
accountId = existingAccount.stripe_account_id;
} else {
const account = await stripe.accounts.create({
type: 'express',
country: 'JP',
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
business_type: 'individual',
metadata: { lovai_user_id: userId },
});
accountId = account.id;
await serviceClient.from('creator_stripe_accounts').insert({
user_id: userId,
stripe_account_id: accountId,
charges_enabled: false,
payouts_enabled: false,
details_submitted: false,
});
}
const accountLink = await stripe.accountLinks.create({
account: accountId,
refresh_url: `${baseUrl}/settings/payments?refresh=true`,
return_url: `${baseUrl}/settings/payments?success=true`,
type: 'account_onboarding',
});
return { data: { url: accountLink.url } };
}
Storing the Lovai user ID in metadata lets you identify which user owns which Stripe account. The charges_enabled, payouts_enabled, and details_submitted statuses are synced from the Stripe API to your local DB.
Checkout with destination charges (transfer_data)
This handles what happens when a buyer purchases paid content. Lovai uses Stripe's hosted Checkout — redirecting to their payment page.
export async function createPurchaseCheckoutSession(postId: string) {
const userId = await getAuthUserId();
const post = await getPost(postId);
if (!post) return { error: { code: 'NOT_FOUND' } };
if (!post.has_premium || !post.price_yen) return { error: { code: 'NOT_PREMIUM' } };
if (post.author_id === userId) return { error: { code: 'OWN_POST' } };
const existingPurchase = await getExistingPurchase(postId, userId);
if (existingPurchase?.status === 'completed') return { error: { code: 'ALREADY_PURCHASED' } };
const creatorAccount = await getCreatorStripeAccount(post.author_id);
if (!creatorAccount?.charges_enabled) return { error: { code: 'CREATOR_NOT_READY' } };
const priceAmount = post.price_yen;
const applicationFee = calculatePlatformFee(priceAmount);
const session = await stripe.checkout.sessions.create({
mode: 'payment',
payment_method_types: ['card'],
line_items: [{
price_data: {
currency: 'jpy',
product_data: {
name: post.title,
description: 'Premium content purchase',
},
unit_amount: priceAmount,
},
quantity: 1,
}],
payment_intent_data: {
application_fee_amount: applicationFee,
transfer_data: {
destination: creatorAccount.stripe_account_id,
},
},
success_url: `${baseUrl}/post/${postId}?purchase=success`,
cancel_url: `${baseUrl}/post/${postId}?purchase=cancelled`,
metadata: {
post_id: postId,
buyer_id: userId,
author_id: post.author_id,
price_yen: String(priceAmount),
},
});
await serviceClient.from('post_purchases').insert({
post_id: postId,
buyer_id: userId,
price_yen: priceAmount,
stripe_checkout_session_id: session.id,
status: 'pending',
});
return { data: { url: session.url } };
}
Three parameters that matter:
| Parameter | What it does | What breaks without it |
|---|---|---|
transfer_data.destination | Routes funds to the creator's Stripe account | Payment succeeds but creator never gets paid |
application_fee_amount | Your platform fee — Stripe deducts this and sends it to you | You earn nothing from the transaction |
metadata | Identifies which post was purchased and by whom | Webhook can't update the right purchase record |
The CREATOR_NOT_READY check matters. Specifying transfer_data when the creator's Stripe account isn't active causes an API error. Lovai caches charges_enabled locally but re-checks via the Stripe API before creating each checkout session. Cache alone would miss cases where Stripe deactivated an account.
Webhook signature verification and idempotency
The webhook receives payment completion events from Stripe and updates purchase records.
Signature verification (non-negotiable)
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('stripe-signature');
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature!, webhookSecret);
} catch (err) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
// ...
}
Never skip signature verification. Without it, anyone can send fake webhook requests and manipulate purchase records. Similarly,
SUPABASE_SERVICE_ROLE_KEYis server-only — never include it in client-side code.
Three-layer idempotency
Stripe webhooks can deliver the same event multiple times. This implementation prevents duplicate processing with three layers: event deduplication, purchase status check, and earnings duplicate prevention.
Layer 1: Event deduplication via unique constraint
create table public.stripe_webhook_events (
id bigint generated always as identity primary key,
event_id text not null unique,
event_type text not null,
processed_at timestamptz default now() not null
);
If two webhooks for the same event arrive simultaneously, only the first insert succeeds. The second hits the unique constraint violation — no race condition.
Layer 2: Purchase status check
if (existingPurchase?.status === 'completed') {
return NextResponse.json({ received: true });
}
Layer 3: Earnings duplicate prevention
async function createCreatorEarning(params) {
const { purchaseId, creatorId, postId, grossAmount } = params;
const { data: existing } = await supabase
.from('creator_earnings')
.select('id')
.eq('purchase_id', purchaseId)
.maybeSingle();
if (existing) return;
const platformFee = calculatePlatformFee(grossAmount);
const stripeFee = calculateStripeFee(grossAmount);
const netAmount = grossAmount - platformFee - stripeFee;
await supabase.from('creator_earnings').insert({
creator_id: creatorId,
purchase_id: purchaseId,
post_id: postId,
gross_amount: grossAmount,
platform_fee: platformFee,
stripe_fee: stripeFee,
net_amount: netAmount,
status: 'pending',
});
}
Storing gross_amount, platform_fee, stripe_fee, and net_amount separately means you can trace exactly which formula produced each record when you adjust fees later.
For simplicity, this shows sequential operations. In production, if the DB update fails after the event is logged, you get an inconsistent state. Ideally, wrap event logging, purchase update, and earnings creation in a single transaction. If that's not feasible, use a recoverable job queue.
Earnings calculation
For a 500 JPY (~$3.50 USD) purchase:
| Item | Amount |
|---|---|
| Sale price (gross) | 500 JPY |
| Stripe processing fee | 3.6% for domestic cards in Japan ( for current limits. Fees and minimums differ by payment method (cards, convenience store payments, bank transfers). Always verify actual amounts in your Stripe Dashboard.
|
SOCIAL SHARE CARD GENERATOR