メインコンテンツへスキップ
友田 陽大
決済・課金
Stripe
Stripe Connect
決済
AWS
アーキテクチャ設計

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

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

公開日
読了時間
26分
著者
友田 陽大
シェア
目次

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

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

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

この記事の立ち位置を最初に明確にしておきます。単発決済(1回払い)の本番化はStripe Checkout 本番ガイド、継続課金(サブスクリプション)の冪等性と型安全はサブスク課金の冪等性と型安全 が担当します。本記事はその先、「プラットフォームが自社の顧客ではなく、売り手と買い手の間で資金を動かす」多者間のマーケットプレイス決済を扱います。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 決定表:種別の使い分け

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

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

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

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

// 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 がプラットフォームの取り分です。

// 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 で「同じ注文に紐づく複数の送金」をグルーピングできます。

// 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章)。


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

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

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

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

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

  • card_payments:カード決済(および ACH)を受け取る
  • transfers:プラットフォームからの送金(payout)を受け取る
// 連結アカウントを作成し、必要な 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_paymentstransfers の両方を有効化したとき、どちらか一方が 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:本人確認フローを自作しない)。

// 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()requirementscurrently_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 を渡します。

// 「同じ注文・同じ操作 → 同じキー」になるよう、コンテンツアドレス方式で決定的に生成
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 で重複排除しました。

// 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分)でリプレイ攻撃も防ぐ。
// 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 フィールドで示します。

{
  "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.updatedaccount.application.deauthorized 等):売り手の決済可否を更新する
  • 決済系payment_intent.succeededcharge.refundedcharge.dispute.created 等):注文・精算を更新する
  • 入金系payout.paidpayout.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 chargesStripe-Accountapplication_fee_amount
  • 1注文=1売り手 → destination chargesapplication_fee_amounttransfer_data[destination]on_behalf_of
  • 1注文を複数売り手に按分 → separate charges and transfersTransfer API + transfer_groupsource_transaction

オンボーディング

  • capabilitiescard_payments / transfers)をリクエスト → Account Linkstype=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 件を実証し、経済産業大臣賞を受賞したプロダクトに載せました。

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

なお、単発決済の本番化はStripe Checkout 本番ガイド継続課金の冪等性と型安全はサブスク課金の冪等性と型安全 にまとめています。本記事と合わせて、決済の3局面(単発・継続・多者間)を網羅できます。


参考(公式ドキュメント)

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

経済産業大臣賞受賞 | 木材流通DXのB2B SaaS(Stripe Connect で冪等なマーケットプレイス決済を構築)

ケーススタディを見る