"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.
- Who takes the fee (how do you skim off the platform's cut)
- Who bears the risk (the bearer of chargebacks, refunds, negative balances, fraud)
- 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
| Aspect | Standard | Express | Custom |
|---|---|---|---|
| Relationship with Stripe | The seller has their own Stripe account | A lightweight account under platform management | A fully custom account under platform management |
| Onboarding | Stripe-hosted (the seller completes it themselves) | Stripe-hosted (Account Links) | The platform builds it via API or Account Links |
| KYC/identity verification | Stripe and the seller lead | The platform and Stripe share | The platform leads (heavy responsibility) |
| Dashboard | The seller's full Stripe dashboard | Express dashboard (limited) | None (the platform's own UI) |
| Fraud / chargeback responsibility | Toward the seller | Toward the platform | The platform bears it |
| Control of UX | Low (Stripe brand) | Medium | High (fully own brand) |
| Implementation cost | Low | Medium | High |
| Suited form | SaaS payment integration / existing operators joining | Food delivery / gig-worker settlement | Highly 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
| Aspect | Direct charges | Destination charges | Separate charges & transfers |
|---|---|---|---|
| Where the charge is created | On the connected account | On the platform | On the platform |
| Merchant of record | The connected account | The platform | The platform |
| Bearer of Stripe fees | Selectable (connected or platform) | The platform | The platform |
| Source of refund / chargeback deductions | The connected account balance | The platform balance | The platform balance |
| How the fee is skimmed | application_fee_amount | application_fee_amount + transfer_data[destination] | A separate Transfer API |
| Distributing to multiple sellers | Not possible | Not possible | Possible |
| Implementation complexity | Low | Medium | High |
| Representative use | A seller sells under their own brand (great fit with Standard) | A 1 order = 1 seller marketplace | Splitting 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
Transferreversal. 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).
3. Onboarding and KYC: passing identity verification with Account Links
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.
| Field | Meaning |
|---|---|
past_due | Information past its submission deadline (needed urgently) |
currently_due | Information needed now to maintain the function |
eventually_due | Information that will eventually be needed before the deadline |
disabled_reason | The reason the function was disabled |
current_deadline | The 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."
3.3 Account Links: Stripe-hosted onboarding
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.
- The link is single-use and expires in a short time. If accessed in an expired state, it redirects to
refresh_url. Make therefresh_urlhandler just "re-create an Account Link with the same parameters and redirect to the new URL." - Even returning to
return_urldoesn't mean completion. It returns even on "Save and do this later." Always make the completion judgment server-side, by looking atstripe.accounts.retrieve()'srequirements(whethercurrently_dueis empty), or by receiving theaccount.updatedWebhook.
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.objectID +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-Signatureheader (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 ignorev0etc. (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.updatedcome 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.
- 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).
- A separate process reads the outbox and reflects it to Stripe (safe to re-run because it has an idempotency key).
- 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 (
TransferAPI +transfer_group+source_transaction)
Onboarding
- Request
capabilities(card_payments/transfers) → KYC with Account Links (type=account_onboarding) → confirm server-side whetherrequirements.currently_dueis empty.return_urldoesn't mean completion.
Idempotency / reliability
- Sending:
Idempotency-Keyin 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)
- Stripe Connect overview — what Connect is, connected accounts, moving funds
- Connect charges — the difference among direct / destination / separate charges and transfers, and
application_fee_amount/transfer_data[destination]/on_behalf_of - Account capabilities —
card_payments/transfers, therequirementshash,account.updated - Hosted onboarding (Account Links) —
account_links,refresh_url/return_url/type=account_onboarding - Connect Webhooks — the
accountfield of connected-account events, scope,connect=true - Webhooks (signature verification) —
Stripe-Signature,constructEvent, the raw body, duplicates and out-of-order, returning 2xx fast - Idempotent requests —
Idempotency-Key, 24-hour storage, the same result for the same key