# 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: 2026-06-24
- Author: 友田 陽大
- Tags: Stripe, Stripe Connect, 決済, AWS, アーキテクチャ設計
- URL: https://tomodahinata.com/en/blog/stripe-connect-marketplace-payments-idempotency-production-guide
- Category: Payments & billing
- Pillar guide: https://tomodahinata.com/en/blog/stripe-payments-production-guide-webhooks-idempotency-subscriptions

## Key points

- The design is decided by three questions — 'who takes the fee, who bears the risk, who holds the KYC' — which uniquely determine the account type and charge model
- Choose the account type from Standard/Express/Custom by the trade-off of responsibility and UX freedom; for many, Express is the sweet spot
- Charges are the three models direct/destination/separate. Use destination for 1 order = 1 seller, and separate only for splitting across multiple sellers (YAGNI)
- Reaching the Account Links return_url doesn't mean completion. Check requirements.currently_due server-side and hold fail-closed throughout
- Idempotency is ensured with the sender-side Idempotency-Key (content-addressed scheme) and the receiver-side conditional write, and missed events are plugged with an outbox + reconciliation

---

"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](/case-studies/lumber-industry-dx)).

> **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](https://docs.stripe.com/connect) 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](/blog/stripe-checkout-sessions-payments-production-guide-2026), and the idempotency and type safety of recurring billing (subscriptions) by [subscription billing idempotency and type safety](/blog/subscription-platform-billing-idempotency-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

| 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**.

```ts
// 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.

```ts
// 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."

```ts
// 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).

---

## 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

```ts
// 連結アカウントを作成し、必要な 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).

```ts
// 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)**.

```ts
// 「同じ注文・同じ操作 → 同じキー」になるよう、コンテンツアドレス方式で決定的に生成
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**.

```ts
// 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.

```ts
// 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**.

```json
{
  "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](/case-studies/lumber-industry-dx).

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](/blog/stripe-checkout-sessions-payments-production-guide-2026), and the idempotency and type safety of recurring billing in [subscription billing idempotency and type safety](/blog/subscription-platform-billing-idempotency-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](https://docs.stripe.com/connect) — what Connect is, connected accounts, moving funds
- [Connect charges](https://docs.stripe.com/connect/charges) — the difference among direct / destination / separate charges and transfers, and `application_fee_amount` / `transfer_data[destination]` / `on_behalf_of`
- [Account capabilities](https://docs.stripe.com/connect/account-capabilities) — `card_payments` / `transfers`, the `requirements` hash, `account.updated`
- [Hosted onboarding (Account Links)](https://docs.stripe.com/connect/hosted-onboarding) — `account_links`, `refresh_url` / `return_url` / `type=account_onboarding`
- [Connect Webhooks](https://docs.stripe.com/connect/webhooks) — the `account` field of connected-account events, scope, `connect=true`
- [Webhooks (signature verification)](https://docs.stripe.com/webhooks) — `Stripe-Signature`, `constructEvent`, the raw body, duplicates and out-of-order, returning 2xx fast
- [Idempotent requests](https://docs.stripe.com/api/idempotent_requests) — `Idempotency-Key`, 24-hour storage, the same result for the same key
