# Stripe Connect マーケットプレイス決済 本番ガイド：アカウント種別・課金モデル・Webhook冪等化を安全に設計する

> Stripe Connectでマーケットプレイス/プラットフォーム決済を本番構築する実装ガイド。アカウント種別(Standard/Express/Custom)、direct/destination/separateの課金モデル、application fee、Account Linksオンボーディング、Webhook署名検証と冪等化、サーバ側金額解決のセキュリティを実コードで解説します。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: Stripe, Stripe Connect, 決済, AWS, アーキテクチャ設計
- URL: https://tomodahinata.com/blog/stripe-connect-marketplace-payments-idempotency-production-guide

## 要点

- 設計は『誰が手数料を取るか・誰がリスクを負うか・誰が KYC を持つか』の3問が決め、アカウント種別と課金モデルを一意に定める
- アカウント種別は Standard/Express/Custom から責任と UX 自由度のトレードオフで選び、多くは Express がスイートスポット
- 課金は direct/destination/separate の3モデル。1注文1売り手は destination、複数売り手按分のみ separate を使う（YAGNI）
- Account Links の return_url 到達は完了を意味しない。requirements.currently_due をサーバ側で確認し fail-closed を貫く
- 冪等性は送信側の Idempotency-Key（コンテンツアドレス方式）と受信側の条件付き書き込みで担保し、取りこぼしはアウトボックス＋整合化で塞ぐ

---

「売り手と買い手をつないで、売上の一部を手数料として受け取りたい」——マーケットプレイスの要件は一言で言えるのに、決済を本番に載せた瞬間、判断すべきことが一気に増えます。**アカウントは Standard か Express か Custom か。課金は direct か destination か separate か。手数料は誰から引くのか。チャージバックは誰が負うのか。KYC は誰の責任か。Webhook が重複して届いたら二重精算しないか。**

この記事は、**Stripe Connect** でマーケットプレイス／プラットフォーム決済を**本番品質**で組むための実装ガイドです。単発決済の Checkout チュートリアルとは別物で、焦点は**「お金が複数の当事者の間を動く」多者間決済**に絞ります。題材として、私が招待制 B2B サブスクリプション SaaS（木材流通DX、[経済産業大臣賞を受賞](/case-studies/lumber-industry-dx)）で構築した「継続課金＋取引精算＋銀行振込」のマーケットプレイス決済の設計判断も交えます。

> **この記事のルール**：API 仕様・パラメータ・イベント名は **Stripe 公式ドキュメント（2026年6月時点）** に基づきます。API は更新されるため、本番投入前に必ず[公式ドキュメント](https://docs.stripe.com/connect)で最新仕様を確認してください。コードは実運用で使える形に整えていますが、**シークレットキー・Webhook 署名鍵は環境変数前提**です（ハードコード厳禁）。

この記事の立ち位置を最初に明確にしておきます。**単発決済（1回払い）の本番化は[Stripe Checkout 本番ガイド](/blog/stripe-checkout-sessions-payments-production-guide-2026)、継続課金（サブスクリプション）の冪等性と型安全は[サブスク課金の冪等性と型安全](/blog/subscription-platform-billing-idempotency-type-safety)** が担当します。**本記事はその先、「プラットフォームが自社の顧客ではなく、売り手と買い手の間で資金を動かす」多者間のマーケットプレイス決済**を扱います。3 記事は補完関係です。

---

## 0. メンタルモデル：マーケットプレイスは「資金が当事者間を動く」

通常の Stripe 決済は**2者間**です。顧客 → あなた。これだけ。

マーケットプレイス（プラットフォーム）決済は**最低3者**になります。

- **買い手（Customer）**：お金を払う人
- **売り手（連結アカウント / connected account）**：商品・サービスを提供し、売上を受け取る人
- **プラットフォーム（あなた）**：両者をつなぎ、**手数料（application fee）**を受け取る人

公式の定義はシンプルです。

> 「Connect を使用して、複数の当事者間で支払いを管理および資金を移動できるプラットフォーム、マーケットプレイス、その他のビジネスを構築します。」

設計判断のほぼ全ては、次の3つの問いで決まります。

1. **誰が手数料を取るか**（プラットフォームの取り分をどう抜くか）
2. **誰がリスクを負うか**（チャージバック・返金・マイナス残高・不正利用の負担者）
3. **誰が KYC（本人確認）の責任を持つか**（売り手の審査・コンプライアンスを誰がやるか）

この3つの答えが、**アカウント種別**（第1章）と**課金モデル**（第2章）を一意に決めます。逆に言うと、ここを曖昧にしたまま実装を始めると、後から「手数料の引き方を変える」「リスク負担を移す」となったときに**根本から作り直し**になります。KISS の原則どおり、まずこの3問に答えてから手を動かしてください。

---

## 1. 連結アカウント種別：Standard / Express / Custom の使い分け

連結アカウント（connected account）は、売り手を表す Stripe 上のアカウントです。種別によって**「誰がオンボーディングし、誰がダッシュボードを持ち、誰がリスクを負うか」**が変わります。

### 1.1 決定表：種別の使い分け

| 観点 | Standard | Express | Custom |
| --- | --- | --- | --- |
| Stripe との関係 | 売り手が**自分の Stripe アカウント**を持つ | プラットフォーム管理下の軽量アカウント | プラットフォーム管理下の完全カスタム |
| オンボーディング | Stripe ホスト型（売り手が自分で完結） | Stripe ホスト型（Account Links） | プラットフォームが API で組む or Account Links |
| KYC/本人確認 | **Stripe と売り手**が主導 | プラットフォームと Stripe が分担 | **プラットフォームが主導**（責任重） |
| ダッシュボード | 売り手がフル Stripe ダッシュボード | Express ダッシュボード（限定） | なし（プラットフォームが自前UI） |
| 不正・チャージバック責任 | 売り手寄り | プラットフォーム寄り | **プラットフォームが負う** |
| UX のコントロール | 低（Stripe ブランド） | 中 | **高（完全に自社ブランド）** |
| 実装コスト | 低 | 中 | **高** |
| 向いている形態 | SaaS の決済連携・既存事業者の出店 | フードデリバリ・ギグワーカー精算 | 高度に作り込むマーケットプレイス |

### 1.2 選定の指針

- **Standard**：売り手がすでにビジネスとして確立していて、自分で Stripe を運用できる場合。**プラットフォームの責任が最も軽い**。プラットフォーム側が KYC の重荷を負いたくないなら第一候補。
- **Express**：ギグワーカー・個人売り手のように「Stripe を意識させず、でも本人確認は通したい」場合。Stripe ホスト型オンボーディングで KYC を Stripe に任せつつ、UX はある程度コントロールできる。**多くのマーケットプレイスのスイートスポット**。
- **Custom**：完全に自社ブランドの体験を作り込みたい場合。引き換えに、**KYC・コンプライアンス・不正・チャージバックの責任をプラットフォームが負う**。実装も運用も最も重い。「やりたい」ではなく「やる体制がある」かで選ぶ（YAGNI）。

> **設計判断のコツ**：「UX を自社で握る自由度」と「背負う責任の重さ」はトレードオフです。Custom は自由度が最大ですが、**売り手の本人確認漏れがそのままプラットフォームの法的・金銭的リスク**になります。木材流通DXでは招待制（出店者を運営が審査）だったため、本人確認の負荷とブランド統一のバランスで種別を選びました。「とりあえず Custom」は技術的負債の典型です。まず Express で要件を満たせないか検討してください。

---

## 2. 課金モデル：direct / destination / separate の使い分け

種別を決めたら、次は**お金の流し方**です。Stripe Connect には3つの課金モデルがあり、**「誰の上に charge が作られるか」「誰が販売者（merchant of record）か」「誰がリスクを負うか」**が変わります。これがマーケットプレイス決済の心臓部です。

### 2.1 決定表：課金モデルの使い分け

| 観点 | Direct charges | Destination charges | Separate charges & transfers |
| --- | --- | --- | --- |
| charge が作られる場所 | **連結アカウント上** | **プラットフォーム上** | **プラットフォーム上** |
| 販売者（merchant of record） | 連結アカウント | プラットフォーム | プラットフォーム |
| Stripe 手数料の負担 | 選択可（連結 or プラットフォーム） | プラットフォーム | プラットフォーム |
| 返金・チャージバックの引き落とし元 | 連結アカウント残高 | プラットフォーム残高 | プラットフォーム残高 |
| 手数料の抜き方 | `application_fee_amount` | `application_fee_amount` ＋ `transfer_data[destination]` | 別途 `Transfer` API |
| 複数の売り手に分配 | 不可 | 不可 | **可能** |
| 実装の複雑さ | 低 | 中 | 高 |
| 代表的な用途 | 出店者が自分のブランドで売る（Standard 相性◎） | 1注文＝1売り手のマーケットプレイス | 1注文を複数売り手に按分 |

### 2.2 Direct charges：連結アカウント上に charge を作る

charge は**連結アカウントの上**に作られ、**連結アカウントが販売者**になります。`Stripe-Account` ヘッダで「どの連結アカウントとして実行するか」を指定します。プラットフォームは `application_fee_amount` で手数料を抜きます。

返金・チャージバックは**連結アカウントの残高**から引かれ、Stripe 手数料の負担者は**連結 / プラットフォームのどちらかを選べます**。

```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章）
  },
);
```

**Standard アカウントと相性が良い**モデルです。売り手が自分のブランド・自分の Stripe ダッシュボードで売上を見たいケースに向きます。

### 2.3 Destination charges：プラットフォーム上に charge を作り、自動で送金

charge は**プラットフォームの上**に作られ、**プラットフォームが販売者**になります。`transfer_data[destination]` に連結アカウント ID を指定すると、Stripe が自動で資金を連結アカウントへ送ります。`application_fee_amount` がプラットフォームの取り分です。

```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 },
);
```

ここで `Stripe-Account` ヘッダを**付けない**点に注意してください。charge はプラットフォーム上に作るので、`stripeAccount` オプションは不要です。

`on_behalf_of` は重要です。これを指定すると、**「実質的にどの連結アカウントの取引か」**が Stripe に伝わり、連結アカウントの国・手数料・明細表記（顧客の明細に出る名前）が適用されます。1注文＝1売り手のマーケットプレイスでは、付けておくと精算・税務・カスタマーサポートの整合性が上がります。

**1注文＝1売り手**のマーケットプレイスの王道がこのモデルです。返金・チャージバックはプラットフォーム残高から引かれますが、**プラットフォームは送金を取り消す（reverse）ことで連結アカウントから資金を回収できます**。

### 2.4 Separate charges and transfers：1注文を複数売り手に分配

charge はプラットフォーム上に作り、**別途 `Transfer` API で**連結アカウントへ送金します。**1回の charge から複数の連結アカウントへ送金できる**のが最大の特徴です（例：カート内に複数の出店者の商品が混在する注文）。

`transfer_group` で「同じ注文に紐づく複数の送金」をグルーピングできます。

```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}` },
  );
}
```

`source_transaction` に元の charge を指定すると、**その charge が決済（settle）されてから送金される**ため、残高不足での送金失敗を避けられます。手数料は「**送金しない残額**」として自然にプラットフォームに残ります。

最も柔軟ですが、最も複雑です。**1注文＝1売り手で足りるなら destination charges を選ぶ**べきで、複数売り手への按分が本当に必要になってから separate を使ってください（YAGNI）。

> **設計判断のコツ**：返金・チャージバックの「引き落とし元」を必ず意識してください。direct は連結アカウント残高、destination/separate はプラットフォーム残高です。**プラットフォーム残高から引かれる＝一時的にプラットフォームが立て替える**ので、`Transfer` の reversal（送金取り消し）で売り手から回収する精算ロジックが必要になります。木材流通DXでは、この精算を**トランザクショナル・アウトボックス＋整合化 Lambda（EventBridge 定期実行）**で確実に反映する設計にしました（詳細は第5章）。

---

## 3. オンボーディングと KYC：Account Links で本人確認を通す

連結アカウントは作っただけでは決済できません。**capability（機能）**を有効化し、**本人確認（KYC）**を完了させる必要があります。

### 3.1 capability：何ができるアカウントか

capability は「カード決済を受ける」「プラットフォームから送金を受け取る」といった機能を表します。公式の定義はこうです。

> 「機能とは、カード決済やプラットフォームアカウントからの送金の受け取りなど、連結アカウントにリクエストできる機能を表します。」

主要な capability は次の2つです。

- **`card_payments`**：カード決済（および ACH）を受け取る
- **`transfers`**：プラットフォームからの送金（payout）を受け取る

```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}` },
);
```

capability の状態は **`active` / `inactive` / `pending`** などで表され、`active` でないと該当機能は使えません。注意点として、**`card_payments` と `transfers` の両方を有効化したとき、どちらか一方が `inactive` になると両方が無効化**されます。

### 3.2 requirements ハッシュ：何が足りないかを読む

各 capability には `requirements` ハッシュがあり、**本人確認に必要な情報の不足状況**が分かります。

| フィールド | 意味 |
| --- | --- |
| `past_due` | 提出期限を過ぎた情報（早急に必要） |
| `currently_due` | 機能を維持するために**今**必要な情報 |
| `eventually_due` | 期限切れ前にいずれ必要になる情報 |
| `disabled_reason` | 機能が無効化された理由 |
| `current_deadline` | 情報提出の期限 |

`currently_due` が空でなければ「まだ売り手は決済できない」状態です。この値を見て、UI で「あと何を入力すべきか」を売り手に促します。

### 3.3 Account Links：Stripe ホスト型オンボーディング

KYC の入力フォームを自前で作るのは茨の道です。**Account Links** を使えば、Stripe がホストする本人確認フォームへのリンクを生成でき、KYC の重荷を Stripe に寄せられます（DRY：本人確認フローを自作しない）。

```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は単回使用・短時間で失効）
```

ここに**2つの落とし穴**があります。

1. **リンクは単回使用・短時間で失効する**。失効した状態でアクセスされると `refresh_url` にリダイレクトされます。`refresh_url` のハンドラは「**同じパラメータで Account Link を作り直し、新しい URL にリダイレクトする**」だけにしてください。
2. **`return_url` に戻ってきても、それは完了を意味しない**。「あとでやる（Save and do this later）」でも戻ってきます。**完了判定は必ずサーバ側**で、`stripe.accounts.retrieve()` の `requirements`（`currently_due` が空か）を見るか、`account.updated` Webhook を受けて行ってください。

> **fail-closed の原則**：`return_url` に戻ってきた＝決済可能、と楽観してはいけません。本人確認が未完の売り手に決済を開かせると、規約違反・資金保留・最悪はアカウント凍結につながります。**「確認できるまで決済機能を閉じておく（fail-closed）」**のが正しいデフォルトです。

---

## 4. 冪等性：二重課金・二重送金を構造で防ぐ

マーケットプレイス決済は「お金を動かす」操作の塊です。ネットワークのリトライ・ユーザーの二度押し・Webhook の重複で、**同じ操作が2回走れば二重課金・二重送金**になります。これは「気をつける」では防げません。**構造で防ぐ**——それが冪等性（idempotency）です。

### 4.1 リクエスト側：Idempotency-Key

Stripe への**全ての書き込み（POST）リクエスト**に冪等キーを付けます。公式の仕様は明確です。

- ヘッダ名は **`Idempotency-Key`**。POST のみ有効（GET/DELETE は定義上冪等なので不要）。
- **同じキーで再送すると、最初のリクエストの結果（ステータスコードとボディ）がそのまま返る**。500 エラーすら再現される。
- **キーは最低24時間保存**される。推奨は V4 UUID 等の十分なエントロピーを持つ文字列。
- パラメータが最初の要求と異なると**エラーになる**（誤用の防止）。

stripe-node では、**メソッドの第2引数（リクエストオプション）**に `idempotencyKey` を渡します。

```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 }) },
);
```

**ランダム UUID を毎回生成してはいけません**。それでは「同じ操作」を Stripe が同一視できず、リトライで二重課金します。**「同じ操作なら同じキー」**になる**コンテンツアドレス方式**（注文ID等の自然キー、または内容のハッシュ）で生成するのが要点です。木材流通DXでは、まさにこのコンテンツアドレス方式の冪等キーで Stripe への全リクエストを冪等化しました。

### 4.2 受信側：Webhook の重複排除

冪等キーは「自分が送る」側の話です。**Stripe から届く Webhook も重複・順不同で来る**——これも公式が明記しています。

> 重複は、処理済みイベントIDを記録してスキップするか、`data.object` の ID ＋ `event.type` で識別して対処する。順序は保証されない。

つまり、**Webhook ハンドラは「同じイベントが2回来ても1回しか副作用を起こさない」**よう作らなければなりません。私が使う定石は、**条件付き書き込み（compare-and-set）で重複を弾く**ことです。木材流通DXでは **DynamoDB の `attribute_not_exists` 条件付き書き込み＋30日 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）
  }
}
```

`ConditionalCheckFailedException` 以外のエラーを**握りつぶさない**のが肝心です。「DB が落ちているのに処理済みとみなして進む」のは fail-open であり、本番事故の温床です。**判断がつかないときは安全側（fail-closed）に倒す**——これは第6章のセキュリティ監査でも実際に効きました。

---

## 5. Webhook：署名検証と「3つに分ける」アーキテクチャ

Webhook はマーケットプレイス決済の「神経系」です。本人確認の進捗（`account.updated`）、決済成功（`payment_intent.succeeded`）、入金（`payout.paid` / `payout.failed`）、紛争（`charge.dispute.created`）——お金とリスクに関わるイベントが全部ここを通ります。**だからこそ、署名検証と冪等化が絶対条件**です。

### 5.1 署名検証：constructEvent と生ボディ

Webhook エンドポイントは**インターネットに公開**されます。誰でも POST できるので、**「本当に Stripe が送ったか」を署名で検証**しなければ、攻撃者が偽の「決済成功」を投げ込めてしまいます。

- リクエストには `Stripe-Signature` ヘッダ（`t=<timestamp>,v1=<署名>,...`）が付く。
- 検証は **`stripe.webhooks.constructEvent(rawBody, signature, endpointSecret)`** を使う。
- **必ず生（raw）リクエストボディを渡す**。JSON パース済みのボディでは検証に失敗する。
- 署名鍵は `whsec_` 形式。**環境変数**で管理（コミット厳禁）。
- `v1`（SHA-256 HMAC）のみ検証し、`v0` 等は無視（ダウングレード攻撃の防止）。タイムスタンプの許容ずれ（既定5分）でリプレイ攻撃も防ぐ。

```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
}
```

公式の指針どおり、**重いロジックの前にまず 2xx を返す**設計にしてください。Webhook の処理が遅いと Stripe がタイムアウトと判断して再送し、それがまた重複を生みます。**「受け取って積む」ハンドラ**と**「キューを消費して処理する」ワーカー**を分離する（SRP）のが本番の定石です。

### 5.2 Connect Webhook の落とし穴：`account` フィールドとスコープ

通常の Webhook と違い、**Connect の Webhook はイベントがどの連結アカウントで起きたかを `account` フィールド**で示します。

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

連結アカウントのイベントを受け取るには、Webhook エンドポイントを**「連結アカウント」スコープ**で登録します（API なら `connect=true`、ダッシュボードなら「Events from」を「Connected accounts」に設定）。

スコープの分かれ方が直感に反するので整理します。

- **「自社アカウント」スコープ**：プラットフォーム自身のイベント。**destination charges / separate charges and transfers（＝間接決済）はここ**に来る（charge がプラットフォーム上にあるため）。
- **「連結アカウント」スコープ**：連結アカウント自身のイベント。**direct charges（＝連結アカウント上の決済）と `account.updated`** はここ。

**課金モデルによって Webhook が来るスコープが変わる**——これを知らずに「destination charge の `payment_intent.succeeded` が連結スコープに来るはず」と思い込むと、イベントを取りこぼします。第2章で選んだ課金モデルと、Webhook 登録スコープは必ずセットで設計してください。

### 5.3 Webhook を「3つに分ける」設計

木材流通DXでは、**Webhook を 3 つの Lambda に分離**しました。理由は SRP です。

- **本人確認系**（`account.updated`・`account.application.deauthorized` 等）：売り手の決済可否を更新する
- **決済系**（`payment_intent.succeeded`・`charge.refunded`・`charge.dispute.created` 等）：注文・精算を更新する
- **入金系**（`payout.paid`・`payout.failed` 等）：売り手への入金状況を更新する

1つの巨大ハンドラに全イベントを詰め込むと、片方の不具合がもう片方を巻き込み、デプロイの blast radius も広がります。**関心ごとに分割**すれば、各ハンドラは小さく・テストしやすく・独立してスケールできます（ETC）。

### 5.4 課金調整は「アウトボックス＋整合化」で確実に反映

Webhook は「届けば処理する」仕組みですが、**届かない・処理に失敗する**可能性は常にあります。お金の精算で取りこぼしは許されません。木材流通DXでは、課金調整を**トランザクショナル・アウトボックス＋整合化 Lambda（EventBridge 定期実行）**で担保しました。

考え方はこうです。

1. 業務 DB のトランザクション内で「やるべき精算操作」を**アウトボックステーブル**に書く（業務更新と同一トランザクション＝取りこぼさない）。
2. 別プロセスがアウトボックスを読んで Stripe に反映（冪等キー付きなので再実行安全）。
3. 定期実行の**整合化 Lambda**が「Stripe の状態」と「自分の DB」を突き合わせ、ズレを検出・是正する。

Webhook を「主」、整合化を「保険」にすることで、**「Webhook が来なくても、最終的に必ず整合する」**状態を作れます（信頼性：単一障害点の排除）。決済システムは「だいたい合っている」では失格で、**最終的整合性を能動的に取りに行く**設計が要ります。

---

## 6. セキュリティ：金額はサーバ、検証は必須、迷ったら fail-closed

マーケットプレイス決済は「他人のお金」を扱います。セキュリティの抜けは、そのまま金銭事故・信用失墜になります。私が必ず守る原則を挙げます。

### 6.1 金額はサーバ側で解決する（改ざんの排除）

**クライアントから送られてきた金額を信用してはいけません。** 価格・手数料・売り手の取り分は**全てサーバ側で再計算**します。クライアントは「商品ID・数量」だけを送り、金額は**サーバが権威ある価格表から導出**します。これをやらないと、`amount=1` に書き換えた1円決済が通ってしまいます。

木材流通DXでは、これを徹底し、さらに **Stripe ID の形式を DB の CHECK 制約で検証**しました。`cus_`（顧客）・`sub_`（サブスク）・`acct_`（連結アカウント）といった**プレフィックスを CHECK 制約で縛る**ことで、「顧客IDを入れるべき列に連結アカウントIDが紛れ込む」種の事故をデータ層で防ぎます。**型と制約で不正な状態を表現不可能にする**——アプリのバリデーションだけに頼らない多層防御です。

### 6.2 Webhook 署名検証は必須・fail-closed

第5章の通り、**署名検証なしの Webhook は「誰でも書き込めるエンドポイント」**です。検証失敗は必ず 4xx で拒否し、**生ボディを使う**こと。そして——これは実体験ですが——**「検証や重複判定でエラーになったとき、安全側に倒す（fail-closed）」**ことが決定的に重要です。

木材流通DXのセキュリティ監査で、**Webhook 冪等性の fail-open を fail-closed に是正**した経緯があります。「重複判定の DB アクセスがエラーになったら、とりあえず処理を続ける（fail-open）」という実装は、一見して動きますが、**障害時に二重処理を許す穴**です。これを「判断がつかないなら処理しない（fail-closed）」に直しました。**セキュリティ・信頼性のデフォルトは常に fail-closed**です。

### 6.3 テナント分離と PII

- **テナント分離**：マーケットプレイスは本質的にマルチテナント（複数の売り手）です。「売り手 A のデータを売り手 B が見られない」ことを、アプリのロジックだけでなく**データ層（行レベルの権限）**でも保証します。
- **PII の最小化**：本人確認情報・銀行口座は機微情報です。**自前で持たず Stripe に寄せる**（Express/Standard なら KYC を Stripe ホスト型に任せる）のが、漏洩リスクを下げる最良の設計です。ログにも PII を残しません。

> **第三者検証の重要性**：木材流通DXでは、第三者ペネトレーションテスト（実在15ロール）で**全221エンドポイントの認証欠落 0 件**を実証しました。「自分で安全だと思っている」と「第三者が破れなかった」は別物です。決済を扱うなら、**外部のペネトレで認可の網羅性を証明する**工程を必ず計画に入れてください。

---

## 7. まとめ：チートシート

マーケットプレイス決済を本番に載せるときの早見表です。

**まず3つの問いに答える**

- 誰が手数料を取るか／誰がリスク（チャージバック・KYC）を負うか／UX をどこまで自社で握るか。

**アカウント種別**

- 売り手が自立・責任を負わせたい → **Standard**
- KYC は Stripe に任せ、UX は少し握りたい → **Express**（多くのケースの最適解）
- 完全自社ブランド・責任を負う体制がある → **Custom**

**課金モデル**

- 連結アカウント上で売る（Standard 相性◎） → **direct charges**（`Stripe-Account` ＋ `application_fee_amount`）
- 1注文＝1売り手 → **destination charges**（`application_fee_amount` ＋ `transfer_data[destination]` ＋ `on_behalf_of`）
- 1注文を複数売り手に按分 → **separate charges and transfers**（`Transfer` API ＋ `transfer_group` ＋ `source_transaction`）

**オンボーディング**

- `capabilities`（`card_payments` / `transfers`）をリクエスト → **Account Links**（`type=account_onboarding`）で KYC → `requirements.currently_due` が空かをサーバ側で確認。`return_url` は完了を意味しない。

**冪等・信頼性**

- 送信：`Idempotency-Key` を**コンテンツアドレス方式**で（ランダム UUID 毎回生成は厳禁）。
- 受信：`stripe.webhooks.constructEvent`（**生ボディ**）で署名検証 → イベントIDを**条件付き書き込み**で重複排除 → すぐ 2xx → 重い処理は非同期。
- 取りこぼし対策：**アウトボックス＋整合化**で最終的整合性を能動的に取りに行く。

**セキュリティ**

- 金額は**サーバ側で解決**。Stripe ID は CHECK 制約で形式検証。Webhook 署名検証は必須。**迷ったら fail-closed**。テナント分離はデータ層で。PII は Stripe に寄せる。

---

マーケットプレイス決済は「手数料を抜くだけ」に見えて、**アカウント設計・資金フロー・本人確認・冪等性・リスク負担を一貫して設計する仕事**です。私は招待制 B2B サブスクリプション SaaS（木材流通DX）で、Stripe Connect による「継続課金＋取引精算＋銀行振込」を**冪等・fail-closed・最終的整合**で組み上げ、第三者ペネトレで全221エンドポイントの認証欠落 0 件を実証し、[経済産業大臣賞](/case-studies/lumber-industry-dx)を受賞したプロダクトに載せました。

これを**一人 × 生成AI（Claude Code）**で、速く・安く・しかし**人間の検証ゲートを必ず通す**進め方で構築しています。**「自社のマーケットプレイスで、誰からどう手数料を取り、どうリスクを抑えて資金を動かすか」——その設計から実装・監査まで一気通貫で伴走できます。** 要件整理の段階からでも、お気軽にご相談ください。

なお、**単発決済の本番化は[Stripe Checkout 本番ガイド](/blog/stripe-checkout-sessions-payments-production-guide-2026)**、**継続課金の冪等性と型安全は[サブスク課金の冪等性と型安全](/blog/subscription-platform-billing-idempotency-type-safety)** にまとめています。本記事と合わせて、決済の3局面（単発・継続・多者間）を網羅できます。

---

### 参考（公式ドキュメント）

- [Stripe Connect 概要](https://docs.stripe.com/connect) — Connect とは・連結アカウント・資金の移動
- [Connect の課金（Charges）](https://docs.stripe.com/connect/charges) — direct / destination / separate charges and transfers の違いと `application_fee_amount` / `transfer_data[destination]` / `on_behalf_of`
- [連結アカウントの機能（Account capabilities）](https://docs.stripe.com/connect/account-capabilities) — `card_payments` / `transfers`・`requirements` ハッシュ・`account.updated`
- [ホスト型オンボーディング（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) — 連結アカウントイベントの `account` フィールド・スコープ・`connect=true`
- [Webhooks（署名検証）](https://docs.stripe.com/webhooks) — `Stripe-Signature`・`constructEvent`・生ボディ・重複と順不同・2xx を速く返す
- [冪等リクエスト（Idempotent requests）](https://docs.stripe.com/api/idempotent_requests) — `Idempotency-Key`・24時間保存・同一キーで同一結果
