DEV Community

Cover image for Stripe Closed My Connect Account. Here's What Actually Fixed It in 24 Hours.
Lovanaut
Lovanaut

Posted on

Stripe Closed My Connect Account. Here's What Actually Fixed It in 24 Hours.

I'm building Lovai, a creator marketplace where users can sell parts of a post as paid content — block by block. To support payouts, I implemented Stripe Connect Express with hosted onboarding, destination charges, webhook-based purchase fulfillment, and Supabase RLS for access control.

During review, Stripe temporarily closed the account and flagged the business as potential aggregation. Stripe later told me that, in my case, the Connect application had not been fully submitted at the time of review. After I clarified the business model and completed the application, the account was re-reviewed and approved the next day.

This post explains what happened, what fixed it, and how the payment flow works end to end.

Jump to:


Accepting payments vs. routing payouts: a different beast

There are plenty of tutorials on adding Stripe to your SaaS for subscription billing. Building a system where your users sell their own content and receive payouts is a fundamentally different problem.

On Lovai, creators can mark parts of their posts as paid. When someone buys, the creator gets paid directly. To make this work, I needed Stripe Connect.

If you're just collecting payments for yourself, a standard Stripe account is fine. The moment you route money to other people, you're dealing with compliance reviews, legal requirements, and fund flow design.


Why I chose Stripe Connect Express over Standard or Custom

Stripe Connect offers multiple account types.

Note: Stripe now describes Standard / Express / Custom account types as deprecated for newer integrations, while existing integrations continue to work. For new builds, check controller properties or newer migration paths.

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',
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. Privacy Policy
  2. Terms of Service
  3. 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 prohibited business categories. They noted that review criteria are confidential and couldn't share further details.

How I responded

I sent a detailed breakdown of Lovai's business model:

1. Clarified the service category

  • Educational Services: sharing technical workflows
  • Digital Goods: code snippets, prompts, AI recipes

2. Demonstrated content moderation

  • Terms of service explicitly ban adult content, copyright infringement, and get-rich-quick schemes
  • Moderation systems are in place

3. Referenced comparable services

  • Named platforms with similar business models that use Stripe — to show that the model fit an established pattern Stripe already supports

What actually triggered the review

I also proposed an alternative: Lovai as the sole seller, with monthly bank transfers to creators instead of Connect.

Stripe's response revealed the actual issue. In my case, the Connect application hadn't been fully submitted at the time of review, so they interpreted it as "intending to process third-party payments without Connect."

They further explained that a marketplace operating without Connect would constitute aggregation — a prohibited activity. The alternative I'd proposed would have been the actual violation.

Once my Connect application was confirmed as submitted, they re-reviewed the account. The next day, Stripe approved the business on re-review.

This was my specific experience. The definition of "aggregation" and review criteria are at Stripe's discretion and may vary by service.

Three things I'd do differently

  • Submit the Connect application early. Stripe may interpret a gap between account creation and Connect application submission as intent to process third-party payments without Connect — which can qualify as aggregation. In my case, that gap was what triggered the flag.
  • Prepare your explanation before you need it. When a service spans multiple categories (education, digital goods, creator economy), Stripe's review team needs to map it to an established pattern. Organize your service category, comparable platforms, and content policies so the reviewer can classify quickly. The harder it is to categorize, the more likely it gets flagged.
  • Don't accept rejection as final. A detailed, structured explanation can get you a re-review. The key is making it easy for the reviewer to say "yes" — show that your model fits a pattern Stripe already supports, not that you're doing something novel.

The complete payment flow

sequenceDiagram
    participant Buyer
    participant Lovai as Lovai (Server)
    participant Stripe
    participant Creator as Creator's Stripe Account

    Buyer->>Lovai: Purchase paid content
    Lovai->>Lovai: Validation (already purchased? own post?)
    Lovai->>Stripe: Create Checkout Session (with transfer_data)
    Stripe-->>Buyer: Redirect to payment page
    Buyer->>Stripe: Enter card details & pay
    Stripe->>Lovai: Webhook (checkout.session.completed)
    Lovai->>Lovai: Verify signature → update purchase record → log earnings
    Stripe->>Creator: Auto-transfer (amount minus fees)
Enter fullscreen mode Exit fullscreen mode

When creating the Checkout Session, you specify the creator's Stripe account in transfer_data. This creates a destination charge: 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 } };
}
Enter fullscreen mode Exit fullscreen mode

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 } };
}
Enter fullscreen mode Exit fullscreen mode

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 });
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Never skip signature verification. Without it, anyone can send fake webhook requests and manipulate purchase records. Similarly, SUPABASE_SERVICE_ROLE_KEY is 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
);
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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',
  });
}
Enter fullscreen mode Exit fullscreen mode

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 (Stripe pricing)
Platform fee Based on your rate (set via application_fee_amount)
Creator payout (net) Sale price - Stripe fee - platform fee

On Stripe fees: The 3.6% rate applies to domestic card payments in Japan. Stripe also sets minimum transaction amounts that vary by currency — check the Stripe docs for current limits. Fees and minimums differ by payment method (cards, convenience store payments, bank transfers). Always verify actual amounts in your Stripe Dashboard.


Purchase records secured with Supabase RLS

Table definition

create type public.purchase_status as enum (
  'pending',
  'completed',
  'refunded'
);

create table public.post_purchases (
  id uuid primary key default gen_random_uuid(),
  post_id uuid not null references public.posts(id) on delete restrict,
  buyer_id uuid not null references public.profiles(id) on delete cascade,
  price_yen integer not null,
  stripe_payment_intent_id text,
  stripe_checkout_session_id text,
  status public.purchase_status default 'pending' not null,
  created_at timestamptz default now() not null,
  completed_at timestamptz,
  unique (post_id, buyer_id)
);
Enter fullscreen mode Exit fullscreen mode

Design decisions:

  • unique (post_id, buyer_id) — Prevents double-purchasing.
  • on delete restrict — Prevents creators from deleting purchased posts. Buyers paid for that content; it shouldn't vanish. Creators can change a post's status instead.

RLS: users can read, only the server can write

create policy "post_purchases_select_own"
  on public.post_purchases for select
  using (buyer_id = (select auth.uid()));

create policy "post_purchases_select_author"
  on public.post_purchases for select
  using (
    exists (
      select 1 from public.posts p
      where p.id = post_purchases.post_id
        and p.author_id = (select auth.uid())
    )
  );
Enter fullscreen mode Exit fullscreen mode

INSERT, UPDATE, and DELETE are not permitted for any user. All mutations go through the webhook via the Service Role client.

If UPDATE were open, a user could flip their purchase from pending to completed without paying. I covered this in detail in my article on how Lovai uses Supabase RLS to protect paid content at the database layer.

Access check with Security Definer

create or replace function public.has_purchased(
  p_post_id uuid,
  p_user_id uuid
)
returns boolean
language sql
security definer
stable
set search_path = ''
as $$
  select exists (
    select 1 from public.post_purchases
    where post_id = p_post_id
      and buyer_id = p_user_id
      and status = 'completed'
  );
$$;
Enter fullscreen mode Exit fullscreen mode

set search_path = '' plus fully qualified table names (public.post_purchases) prevents schema injection attacks. Always apply both when creating Security Definer functions.


What I'd tell someone building a creator marketplace

Phase What to get right
Before Stripe Legal pages live. Seller info matches Stripe registration exactly.
Connect application Submit it at the same time as account creation. A gap between the two was what triggered my review issue.
If flagged Prepare a structured explanation: service category, comparable platforms, content policies. Make it easy for the reviewer.
Implementation transfer_data.destination is everything. Without it, creators don't get paid.
Security Webhook signature verification is mandatory. Users must never have write access to purchase records. Three-layer idempotency. Security Definer functions need set search_path = ''.

If you're building something where users earn money through your platform, the payment architecture is the one thing you can't get wrong. I hope this saves you some of the headaches I went through.

What's the worst surprise you've hit during a Stripe integration? I'd love to hear about it in the comments.


Related: How Lovai uses Supabase RLS to protect paid content at the database layer

Try Lovai: AI recipes and dev workflows, shared block by block

Top comments (0)