Skip to main content
友田 陽大
Payments & billing
Stripe
Stripe Connect
決済
AWS
アーキテクチャ設計

Stripe Connect Marketplace Payments Production Guide: Safely Designing Account Types, Charge Models, and Webhook Idempotency

An implementation guide for building marketplace / platform payments in production with Stripe Connect. Explained with real code: account types (Standard/Express/Custom), the direct/destination/separate charge models, application fees, Account Links onboarding, Webhook signature verification and idempotency, and the security of server-side amount resolution.

Published
Reading time
23 min read
Author
友田 陽大
Share

"I want to connect sellers and buyers and receive a portion of the sales as a fee" — a marketplace's requirement can be said in one line, yet the moment you put payments into production, the things to decide explode at once. Is the account Standard, Express, or Custom? Is the charge direct, destination, or separate? Who is the fee deducted from? Who bears chargebacks? Whose responsibility is KYC? If a Webhook arrives in duplicate, do you double-settle?

This article is an implementation guide for assembling marketplace / platform payments at production quality with Stripe Connect. It's a different thing from a Checkout tutorial for one-off payments — the focus is narrowed to multi-party payments where "money moves between multiple parties." As the subject matter, I'll weave in design decisions from the marketplace payments — "recurring billing + transaction settlement + bank transfer" — I built for an invitation-based B2B subscription SaaS (lumber-distribution DX, winner of the Minister of Economy, Trade and Industry Award).

The rule of this article: The API specs, parameters, and event names are based on the Stripe official documentation (as of June 2026). Because the API is updated, always check the latest spec at the official documentation before going to production. The code is shaped into a form usable in real operations, but secret keys and Webhook signing keys are assumed to be in environment variables (hardcoding strictly forbidden).

Let me make this article's positioning clear at the start. Productionizing one-off payments (single charge) is handled by the Stripe Checkout production guide, and the idempotency and type safety of recurring billing (subscriptions) by subscription billing idempotency and type safety. This article handles what comes after — multi-party marketplace payments where "the platform moves funds not for its own customers, but between sellers and buyers." The three articles are complementary.


0. Mental model: a marketplace is "funds moving between parties"

Ordinary Stripe payments are two-party. Customer → you. That's it.

Marketplace (platform) payments become at minimum three-party.

  • The buyer (Customer): the one who pays the money
  • The seller (connected account): the one who provides goods/services and receives the sales
  • The platform (you): the one who connects the two and receives a fee (application fee)

The official definition is simple.

"Use Connect to build platforms, marketplaces, and other businesses that manage payments and move funds between multiple parties."

Almost all design decisions are determined by the next three questions.

  1. Who takes the fee (how do you skim off the platform's cut)
  2. Who bears the risk (the bearer of chargebacks, refunds, negative balances, fraud)
  3. Who holds the responsibility for KYC (identity verification) (who does the seller's vetting / compliance)

The answers to these three uniquely determine the account type (Section 1) and the charge model (Section 2). Conversely, start implementing while leaving this vague, and when it later comes to "change how the fee is deducted" or "shift the risk bearer," it becomes a rebuild from the ground up. Per the KISS principle, answer these three questions before moving your hands.


1. Connected account types: choosing among Standard / Express / Custom

A connected account is a Stripe account representing a seller. The type changes "who onboards, who holds the dashboard, and who bears the risk."

1.1 Decision table: choosing the type

AspectStandardExpressCustom
Relationship with StripeThe seller has their own Stripe accountA lightweight account under platform managementA fully custom account under platform management
OnboardingStripe-hosted (the seller completes it themselves)Stripe-hosted (Account Links)The platform builds it via API or Account Links
KYC/identity verificationStripe and the seller leadThe platform and Stripe shareThe platform leads (heavy responsibility)
DashboardThe seller's full Stripe dashboardExpress dashboard (limited)None (the platform's own UI)
Fraud / chargeback responsibilityToward the sellerToward the platformThe platform bears it
Control of UXLow (Stripe brand)MediumHigh (fully own brand)
Implementation costLowMediumHigh
Suited formSaaS payment integration / existing operators joiningFood delivery / gig-worker settlementHighly built-out marketplaces

1.2 Selection guidelines

  • Standard: when the seller is already established as a business and can operate Stripe themselves. The platform's responsibility is the lightest. The first candidate if the platform doesn't want to bear the burden of KYC.
  • Express: for gig workers and individual sellers, "don't make them conscious of Stripe, but still pass identity verification." Entrust KYC to Stripe with Stripe-hosted onboarding, while controlling the UX to some extent. The sweet spot for many marketplaces.
  • Custom: when you want to build out a fully own-brand experience. In exchange, the platform bears the responsibility for KYC, compliance, fraud, and chargebacks. The heaviest in both implementation and operation. Choose by "do you have the structure to do it," not "do you want to" (YAGNI).

A design-decision tip: "the freedom to grasp the UX in-house" and "the weight of the responsibility you shoulder" are a trade-off. Custom has maximum freedom, but a seller's missed identity verification directly becomes the platform's legal and financial risk. In the lumber-distribution DX, because it was invitation-based (the operator vets the joining sellers), I chose the type by balancing the identity-verification load and brand unification. "Custom for now" is a textbook technical debt. First consider whether Express can meet the requirements.


2. Charge models: choosing among direct / destination / separate

Once you've decided the type, next is how to flow the money. Stripe Connect has three charge models, and "on whom the charge is created," "who is the merchant of record," and "who bears the risk" change. This is the heart of marketplace payments.

2.1 Decision table: choosing the charge model

AspectDirect chargesDestination chargesSeparate charges & transfers
Where the charge is createdOn the connected accountOn the platformOn the platform
Merchant of recordThe connected accountThe platformThe platform
Bearer of Stripe feesSelectable (connected or platform)The platformThe platform
Source of refund / chargeback deductionsThe connected account balanceThe platform balanceThe platform balance
How the fee is skimmedapplication_fee_amountapplication_fee_amount + transfer_data[destination]A separate Transfer API
Distributing to multiple sellersNot possibleNot possiblePossible
Implementation complexityLowMediumHigh
Representative useA seller sells under their own brand (great fit with Standard)A 1 order = 1 seller marketplaceSplitting 1 order across multiple sellers

2.2 Direct charges: creating a charge on the connected account

The charge is created on the connected account, and the connected account is the merchant. You specify "as which connected account to execute" with the Stripe-Account header. The platform skims the fee with application_fee_amount.

Refunds / chargebacks are deducted from the connected account's balance, and you can choose the bearer of Stripe fees — connected or platform.

// Direct charge:連結アカウント上に PaymentIntent を作成し、手数料を抜く
import Stripe from "stripe";

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

const paymentIntent = await stripe.paymentIntents.create(
  {
    amount: 10_000,                 // 金額は必ずサーバ側で解決(第3章)
    currency: "jpy",
    application_fee_amount: 1_000,  // プラットフォームの取り分(10%)
  },
  {
    stripeAccount: connectedAccountId, // ← Stripe-Account ヘッダ。連結アカウントとして実行
    idempotencyKey: orderIdempotencyKey, // 二重課金防止(第4章)
  },
);

It's a model that fits well with Standard accounts. Suited for the case where the seller wants to see their sales on their own brand and their own Stripe dashboard.

2.3 Destination charges: creating a charge on the platform and auto-transferring

The charge is created on the platform, and the platform is the merchant. Specify the connected account ID in transfer_data[destination], and Stripe automatically moves the funds to the connected account. application_fee_amount is the platform's cut.

// Destination charge:プラットフォーム上で課金し、連結アカウントへ自動送金
const paymentIntent = await stripe.paymentIntents.create(
  {
    amount: 10_000,
    currency: "jpy",
    application_fee_amount: 1_000,        // プラットフォームの取り分
    transfer_data: {
      destination: connectedAccountId,    // 残額の送金先(売り手)
    },
    // on_behalf_of を付けると、連結アカウントの国・手数料体系・
    // 明細表記(statement descriptor)が適用される
    on_behalf_of: connectedAccountId,
  },
  { idempotencyKey: orderIdempotencyKey },
);

Note here that you don't attach the Stripe-Account header. Since the charge is created on the platform, the stripeAccount option is unnecessary.

on_behalf_of is important. Specify it and "which connected account's transaction it substantially is" is conveyed to Stripe, and the connected account's country, fees, and statement descriptor (the name appearing on the customer's statement) are applied. In a 1 order = 1 seller marketplace, attaching it raises the consistency of settlement, tax, and customer support.

This model is the royal road of a 1 order = 1 seller marketplace. Refunds / chargebacks are deducted from the platform balance, but the platform can recover funds from the connected account by reversing the transfer.

2.4 Separate charges and transfers: distributing 1 order across multiple sellers

The charge is created on the platform, and with a separate Transfer API you transfer to connected accounts. The greatest feature is being able to transfer from one charge to multiple connected accounts (e.g. an order where products from multiple sellers are mixed in the cart).

With transfer_group, you can group "multiple transfers tied to the same order."

// 1. プラットフォーム上で課金(顧客から全額を受け取る)
const paymentIntent = await stripe.paymentIntents.create(
  {
    amount: 30_000,
    currency: "jpy",
    transfer_group: orderId, // 同一注文の送金をグルーピング
  },
  { idempotencyKey: `charge:${orderId}` },
);

// 2. 複数の売り手へ「別々の Transfer」で按分送金
//    (プラットフォームの取り分は送金しないことで自然に手数料化される)
for (const line of order.lines) {
  await stripe.transfers.create(
    {
      amount: line.sellerPayout, // 売り手の取り分(サーバ側で算出)
      currency: "jpy",
      destination: line.connectedAccountId,
      transfer_group: orderId,
      source_transaction: paymentIntent.latest_charge as string, // この charge を原資にする
    },
    { idempotencyKey: `transfer:${orderId}:${line.connectedAccountId}` },
  );
}

Specify the original charge in source_transaction, and the transfer happens after that charge settles, so you avoid transfer failures from insufficient balance. The fee naturally remains with the platform as "the residual amount not transferred."

It's the most flexible but the most complex. If 1 order = 1 seller suffices, you should choose destination charges, and use separate only once distribution to multiple sellers truly becomes necessary (YAGNI).

A design-decision tip: always be conscious of the "source of deduction" for refunds / chargebacks. Direct is the connected account balance, destination/separate is the platform balance. Deducted from the platform balance = the platform temporarily fronts it, so you need settlement logic to recover from the seller via a Transfer reversal. In the lumber-distribution DX, I designed this settlement to be reliably reflected with a transactional outbox + a reconciliation Lambda (EventBridge scheduled) (details in Section 5).


A connected account can't take payments just by being created. You need to enable capabilities and complete identity verification (KYC).

3.1 Capabilities: what kind of account it is

A capability represents a function like "receive card payments" or "receive transfers from the platform." The official definition is this.

"A capability represents the functionality you can request for a connected account, such as receiving card payments or receiving transfers from a platform account."

The two main capabilities are the following.

  • card_payments: receive card payments (and ACH)
  • transfers: receive transfers (payouts) from the platform
// 連結アカウントを作成し、必要な capability をリクエスト
const account = await stripe.accounts.create(
  {
    type: "express", // "standard" | "express" | "custom"
    country: "JP",
    capabilities: {
      card_payments: { requested: true },
      transfers: { requested: true },
    },
  },
  { idempotencyKey: `account:${sellerId}` },
);

A capability's state is represented by active / inactive / pending and the like, and the function can't be used unless active. As a caution, when you enable both card_payments and transfers, if either one becomes inactive, both are disabled.

3.2 The requirements hash: reading what's missing

Each capability has a requirements hash, where you can know the shortfall of information needed for identity verification.

FieldMeaning
past_dueInformation past its submission deadline (needed urgently)
currently_dueInformation needed now to maintain the function
eventually_dueInformation that will eventually be needed before the deadline
disabled_reasonThe reason the function was disabled
current_deadlineThe deadline for submitting information

If currently_due is not empty, it's a state of "the seller still can't take payments." Look at this value and prompt the seller in the UI for "what to enter next."

Building the KYC input form yourself is a thorny path. Use Account Links and you can generate a link to a Stripe-hosted identity-verification form, leaning the burden of KYC onto Stripe (DRY: don't build the identity-verification flow yourself).

// Account Link を作成して、売り手をオンボーディングへ誘導
const accountLink = await stripe.accountLinks.create({
  account: connectedAccountId,
  refresh_url: "https://example.com/connect/refresh", // リンク失効・使用済み時の戻り先
  return_url: "https://example.com/connect/return",   // 完了 or 中断時の戻り先
  type: "account_onboarding",                         // or "account_update"
  collection_options: { fields: "eventually_due" },   // 先回りで全項目を集める
});

// accountLink.url へリダイレクト(このURLは単回使用・短時間で失効)

There are two pitfalls here.

  1. The link is single-use and expires in a short time. If accessed in an expired state, it redirects to refresh_url. Make the refresh_url handler just "re-create an Account Link with the same parameters and redirect to the new URL."
  2. Even returning to return_url doesn't mean completion. It returns even on "Save and do this later." Always make the completion judgment server-side, by looking at stripe.accounts.retrieve()'s requirements (whether currently_due is empty), or by receiving the account.updated Webhook.

The fail-closed principle: don't be optimistic that "returned to return_url = payment-capable." Let a seller with incomplete identity verification open payments, and it leads to terms violations, fund holds, and in the worst case account freezing. "Keep the payment function closed until you can confirm (fail-closed)" is the correct default.


4. Idempotency: preventing double charges / double transfers by structure

Marketplace payments are a mass of "move money" operations. With network retries, a user's double-tap, or duplicate Webhooks, if the same operation runs twice, it's a double charge / double transfer. This can't be prevented by "being careful." Prevent it by structure — that is idempotency.

4.1 The request side: Idempotency-Key

Attach an idempotency key to every write (POST) request to Stripe. The official spec is clear.

  • The header name is Idempotency-Key. Only valid for POST (GET/DELETE are idempotent by definition, so unnecessary).
  • Resend with the same key, and the result of the first request (the status code and body) is returned as-is. Even a 500 error is reproduced.
  • The key is stored for at least 24 hours. The recommendation is a string with sufficient entropy, like a V4 UUID.
  • If the parameters differ from the first request, it errors (preventing misuse).

In stripe-node, you pass idempotencyKey in the method's second argument (the request options).

// 「同じ注文・同じ操作 → 同じキー」になるよう、コンテンツアドレス方式で決定的に生成
import { createHash } from "node:crypto";

function idempotencyKeyFor(operation: string, payload: object): string {
  // 注文ID等の自然キーがあるならそれを使う。なければ内容ハッシュで決定的に。
  const digest = createHash("sha256")
    .update(`${operation}:${JSON.stringify(payload)}`)
    .digest("hex");
  return `${operation}:${digest}`;
}

await stripe.paymentIntents.create(
  { amount, currency: "jpy", transfer_data: { destination } },
  { idempotencyKey: idempotencyKeyFor("checkout", { orderId, amount, destination }) },
);

Don't generate a random UUID every time. That way Stripe can't identify "the same operation," and a retry double-charges. The point is to generate it with a content-addressed scheme where "the same operation gets the same key" (a natural key like the order ID, or a hash of the content). In the lumber-distribution DX, I idempotency-keyed all requests to Stripe with exactly this content-addressed scheme.

4.2 The receiving side: deduplicating Webhooks

The idempotency key is about "what you send." The Webhooks arriving from Stripe also come in duplicate and out of order — this too the official docs state clearly.

Handle duplicates by recording processed event IDs and skipping, or by identifying with the data.object ID + event.type. Order is not guaranteed.

That is, the Webhook handler must be built so that "even if the same event comes twice, it causes a side effect only once." The standard I use is to reject duplicates with a conditional write (compare-and-set). In the lumber-distribution DX, I deduplicated with a DynamoDB attribute_not_exists conditional write + a 30-day TTL.

// DynamoDB の条件付き書き込みでイベントIDを一度だけ記録(重複は弾く)
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { PutItemCommand, ConditionalCheckFailedException } from "@aws-sdk/client-dynamodb";

const ddb = new DynamoDBClient({});

/** すでに処理済みなら false(=スキップせよ)。初回なら true(=処理せよ)。 */
async function claimEvent(eventId: string): Promise<boolean> {
  const ttl = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60; // 30日TTL
  try {
    await ddb.send(
      new PutItemCommand({
        TableName: "stripe_processed_events",
        Item: { event_id: { S: eventId }, ttl: { N: String(ttl) } },
        ConditionExpression: "attribute_not_exists(event_id)", // 既存なら失敗
      }),
    );
    return true; // 初回 → このプロセスが処理する権利を得た
  } catch (err) {
    if (err instanceof ConditionalCheckFailedException) return false; // 重複 → スキップ
    throw err; // それ以外のエラーは握りつぶさない(fail-closed)
  }
}

Not swallowing errors other than ConditionalCheckFailedException is the crux. "Regard it as processed and proceed even though the DB is down" is fail-open, and a breeding ground for production accidents. When you can't judge, fall to the safe side (fail-closed) — this actually paid off in the security audit of Section 6 too.


5. Webhooks: signature verification and a "split into three" architecture

Webhooks are the "nervous system" of marketplace payments. Identity-verification progress (account.updated), payment success (payment_intent.succeeded), payouts (payout.paid / payout.failed), disputes (charge.dispute.created) — events involving money and risk all pass through here. That's exactly why signature verification and idempotency are absolute requirements.

5.1 Signature verification: constructEvent and the raw body

A Webhook endpoint is exposed to the internet. Anyone can POST, so unless you verify "did Stripe really send this" with the signature, an attacker can throw in a fake "payment success."

  • The request has a Stripe-Signature header (t=<timestamp>,v1=<signature>,...).
  • Use stripe.webhooks.constructEvent(rawBody, signature, endpointSecret) for verification.
  • Always pass the raw request body. Verification fails with a JSON-parsed body.
  • The signing key is in whsec_ format. Manage it with an environment variable (committing strictly forbidden).
  • Verify only v1 (SHA-256 HMAC) and ignore v0 etc. (preventing downgrade attacks). The timestamp's allowed skew (default 5 minutes) also prevents replay attacks.
// Next.js Route Handler 例:生ボディで署名検証 → 冪等処理 → すぐ 2xx
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!; // whsec_...

export async function POST(req: Request): Promise<Response> {
  const signature = req.headers.get("stripe-signature");
  if (!signature) return new Response("missing signature", { status: 400 });

  const rawBody = await req.text(); // ← 生ボディ。JSON.parse してはいけない

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(rawBody, signature, endpointSecret);
  } catch {
    // 署名検証失敗 = Stripe 以外からの送信 or 改ざん → 安全側で拒否(fail-closed)
    return new Response("invalid signature", { status: 400 });
  }

  // 重複排除(第4章)。すでに処理済みなら副作用を起こさず 200 を返す
  if (!(await claimEvent(event.id))) {
    return new Response("ok (duplicate, ignored)", { status: 200 });
  }

  // 重い処理は「2xx を返した後」に非同期で。ここではキューに積むだけ
  await enqueueForProcessing(event);

  return new Response("ok", { status: 200 }); // タイムアウトを避け、すぐ 2xx
}

Per the official guidance, design it to return a 2xx before the heavy logic. If Webhook processing is slow, Stripe judges it a timeout and resends, which generates more duplicates. Separating the "receive and stack" handler from the "consume the queue and process" worker (SRP) is the production standard.

5.2 A Connect Webhook pitfall: the account field and scope

Unlike ordinary Webhooks, a Connect Webhook indicates which connected account the event occurred on with the account field.

{
  "id": "evt_...",
  "object": "event",
  "type": "payment_intent.succeeded",
  "account": "acct_...",   // ← どの連結アカウントのイベントか
  "livemode": true,
  "data": { "object": { } }
}

To receive a connected account's events, register the Webhook endpoint in "connected account" scope (connect=true via API, or set "Events from" to "Connected accounts" in the dashboard).

The way the scope splits is counterintuitive, so let me organize it.

  • "Your own account" scope: the platform's own events. destination charges / separate charges and transfers (= indirect payments) come here (because the charge is on the platform).
  • "Connected account" scope: the connected account's own events. direct charges (= payments on the connected account) and account.updated come here.

The scope to which a Webhook arrives changes by charge model — not knowing this and assuming "a destination charge's payment_intent.succeeded should come to the connected scope" makes you miss events. Always design the charge model you chose in Section 2 together with the Webhook registration scope.

5.3 The "split into three" Webhook design

In the lumber-distribution DX, I separated Webhooks into 3 Lambdas. The reason is SRP.

  • Identity-verification system (account.updated, account.application.deauthorized, etc.): update the seller's payment eligibility
  • Payment system (payment_intent.succeeded, charge.refunded, charge.dispute.created, etc.): update orders / settlement
  • Payout system (payout.paid, payout.failed, etc.): update the seller's payout status

Cram all events into one giant handler and a defect in one drags the other in, and the deploy blast radius widens too. Split by concern and each handler is small, easy to test, and can scale independently (ETC).

5.4 Reliably reflect billing adjustments with "outbox + reconciliation"

A Webhook is a "process if it arrives" mechanism, but there's always a possibility that it doesn't arrive / fails to process. In money settlement, missed events are not allowed. In the lumber-distribution DX, I ensured billing adjustments with a transactional outbox + a reconciliation Lambda (EventBridge scheduled).

The idea is this.

  1. Within the business DB transaction, write "the settlement operation that should be done" to an outbox table (the same transaction as the business update = no missed events).
  2. A separate process reads the outbox and reflects it to Stripe (safe to re-run because it has an idempotency key).
  3. The scheduled reconciliation Lambda matches "Stripe's state" against "your DB," detecting and rectifying discrepancies.

By making the Webhook the "primary" and reconciliation the "insurance," you can create a state of "even if the Webhook doesn't come, it eventually always reconciles" (reliability: eliminating single points of failure). A payment system is disqualified by "roughly correct," and needs a design that actively goes to take eventual consistency.


6. Security: amounts on the server, verification mandatory, fail-closed when in doubt

Marketplace payments handle "other people's money." A gap in security directly becomes a monetary accident and a loss of trust. Let me list the principles I always hold to.

6.1 Resolve amounts on the server side (eliminate tampering)

Don't trust the amount sent from the client. The price, fee, and seller's cut are all recalculated server-side. The client sends only "product ID / quantity," and the amount is derived by the server from an authoritative price table. Don't do this and a 1-yen payment rewritten to amount=1 gets through.

In the lumber-distribution DX, I enforced this thoroughly and further verified the format of Stripe IDs with DB CHECK constraints. By constraining prefixes like cus_ (customer), sub_ (subscription), and acct_ (connected account) with CHECK constraints, you prevent at the data layer the kind of accident where "a connected account ID slips into a column that should hold a customer ID." Making invalid states unrepresentable with types and constraints — defense-in-depth that doesn't rely on app validation alone.

6.2 Webhook signature verification is mandatory and fail-closed

As in Section 5, a Webhook without signature verification is "an endpoint anyone can write to." Always reject a verification failure with 4xx, and use the raw body. And — this is from actual experience — "when verification or duplicate-judgment errors out, fall to the safe side (fail-closed)" is decisively important.

In the lumber-distribution DX's security audit, there was a history of rectifying the Webhook idempotency's fail-open to fail-closed. The implementation "if the duplicate-judgment DB access errors, just continue processing for now (fail-open)" works at a glance, but it's a hole that allows double processing during a failure. I fixed this to "if you can't judge, don't process (fail-closed)." The default for security and reliability is always fail-closed.

6.3 Tenant isolation and PII

  • Tenant isolation: a marketplace is essentially multi-tenant (multiple sellers). Guarantee "seller A's data can't be seen by seller B" not only with app logic but also at the data layer (row-level permissions).
  • Minimizing PII: identity-verification information and bank accounts are sensitive information. Don't hold them yourself; lean them onto Stripe (with Express/Standard, entrust KYC to Stripe-hosted) — the best design for lowering leakage risk. Don't leave PII in logs either.

The importance of third-party verification: in the lumber-distribution DX, with a third-party penetration test (15 real roles), I demonstrated 0 missing-authorization findings across all 221 endpoints. "I think it's safe myself" and "a third party couldn't break it" are different things. If you handle payments, always put the step of proving the comprehensiveness of authorization with an external pen test into your plan.


7. Summary: a cheat sheet

A quick-reference table for when you put marketplace payments into production.

First, answer the three questions

  • Who takes the fee / who bears the risk (chargebacks, KYC) / how far you grasp the UX in-house.

Account type

  • The seller is independent and you want them to bear responsibility → Standard
  • Entrust KYC to Stripe, grasp the UX a little → Express (the optimal answer for many cases)
  • Fully own brand, with the structure to bear responsibility → Custom

Charge model

  • Sell on the connected account (great fit with Standard) → direct charges (Stripe-Account + application_fee_amount)
  • 1 order = 1 seller → destination charges (application_fee_amount + transfer_data[destination] + on_behalf_of)
  • Split 1 order across multiple sellers → separate charges and transfers (Transfer API + transfer_group + source_transaction)

Onboarding

  • Request capabilities (card_payments / transfers) → KYC with Account Links (type=account_onboarding) → confirm server-side whether requirements.currently_due is empty. return_url doesn't mean completion.

Idempotency / reliability

  • Sending: Idempotency-Key in a content-addressed scheme (generating a random UUID every time is strictly forbidden).
  • Receiving: signature verification with stripe.webhooks.constructEvent (raw body) → deduplicate event IDs with a conditional write → return 2xx immediately → heavy processing asynchronously.
  • Missed-event countermeasure: actively go to take eventual consistency with an outbox + reconciliation.

Security

  • Resolve amounts server-side. Format-verify Stripe IDs with CHECK constraints. Webhook signature verification is mandatory. Fail-closed when in doubt. Tenant isolation at the data layer. Lean PII onto Stripe.

Marketplace payments look like "just skimming a fee," but they're the work of consistently designing account design, fund flows, identity verification, idempotency, and risk bearing. For an invitation-based B2B subscription SaaS (lumber-distribution DX), I assembled "recurring billing + transaction settlement + bank transfer" via Stripe Connect to be idempotent, fail-closed, and eventually consistent, demonstrated 0 missing-authorization findings across all 221 endpoints with a third-party pen test, and put it on a product that won the Minister of Economy, Trade and Industry Award.

I build this with one person × generative AI (Claude Code), fast and cheap, but in a way that always passes through human verification gates. "For your marketplace, from whom and how do you take fees, and how do you move funds while holding down risk?" — from that design through implementation and audit, I can accompany you end to end. Even from the requirements-organizing stage, feel free to consult me.

By the way, productionizing one-off payments is summarized in the Stripe Checkout production guide, and the idempotency and type safety of recurring billing in subscription billing idempotency and type safety. Together with this article, you can cover the three aspects of payments (one-off, recurring, multi-party).


Reference (official documentation)

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading