Stripe Connect Webhookの落とし穴
はじめに
Stripe Connect を使って「プラットフォーム + クリエイター」型のサービスを構築している方へ。
クリエイターが Stripe アカウント連携を完了したはずなのに、自分のDBには PENDING のまま記録されている。Stripe Dashboard を開くと「有効」になっている。でも account.updated イベントの Webhook が届かない。
FolioInkを運用中にこんな状態に陥りました。
原因を結論から言うと、Stripe には「Account用 Webhook」と「Connected accounts用 Webhook」という2種類のエンドポイントがあり、この2つが完全に別物だったことした。
この記事では、
何が起きたか(症状と調査過程)
なぜ起きたか(Stripe Connect のイベント配信の仕組み)
どう直したか(2つのエンドポイントと2つの署名シークレット)
をまとめます。Stripe Connect を実装中の方、または「クリエイターの連携ステータスが更新されない」問題で調査している方の参考になれば幸いです。
想定読者:
Stripe Connect を実装している/する予定の個人開発者
account.updatedイベントが受信できない問題で詰まっている方Stripe Dashboard の「Webhook」設定に複数種類あることを初めて知った方
前提:FolioInk の Stripe Connect 構成
話を具体にするため、私のサービスの構成を先に書きます。
FolioInk は「クリエイターが記事を有料で販売/サブスク提供するプラットフォーム」です。決済フローは Stripe Connect (Express) を使っています。
プラットフォーム(私のStripeアカウント): 売上の一部を手数料として受け取る
Connected account(クリエイターのStripeアカウント): 記事代金を受け取る
クリエイターがサービスに登録すると、以下のフローで連携が完了します。
クリエイターが「Stripe連携を開始」ボタンを押す
サーバー側で Stripe Connect の Express アカウントを作成(
stripe.accounts.create)クリエイターが Stripe のオンボーディング画面で本人確認・銀行口座登録を実施
Stripe 側の審査が通ると、アカウントが
charges_enabled: true/payouts_enabled: trueにStripe から
account.updatedイベントが Webhook で飛んでくるFolioInk 側でそれを受け取り、DB の
stripeAccountStatusをPENDING→ACTIVEに更新
この ステップ5 が届かない、というのが本記事のテーマです。
症状:Stripe側はACTIVE、DBはPENDINGのまま
FolioInkの管理画面をチェックすると「連携してから審査中のまま」の状態が数日続いているアカウントがあることに気が付きました。
調査すると、
Stripe Dashboard で該当のConnected accountを確認 →
charges_enabled: true、payouts_enabled: true(完全に有効)FolioInk の DB で
CreatorProfile.stripeAccountStatusを確認 →PENDING(オンボーディング開始時の初期値のまま)
つまり、Stripe 側は連携完了しているのに、自分のアプリがそれを知らない状態。
最初は「Webhook のシグネチャ検証が失敗してるのか?」「イベントハンドラーが落ちてるのか?」と疑って、
Vercel のログ →
account.updatedの受信ログがそもそも無いStripe Dashboard の Webhook 配信ログを確認 →
account.updatedイベントが 1件も送信されていない
という状況でした。
「送られてすらいない」となると、コード側の問題ではなく Stripe Dashboard の設定側 を疑うしかありませんでした。
原因:Stripe Webhook には2種類ある
Stripe Dashboard の「開発者」→「Webhooks」を開くと、エンドポイント作成時に イベントタイプ を選ぶ画面があります。
ここに重要な選択肢が隠れていました。
Events on your account(自分のアカウントのイベント)
Events on Connected accounts(Connected accountsのイベント)
この2つは完全に別のエンドポイントとして扱われます。
それぞれのイベント範囲
エンドポイントタイプ | 受け取るイベント例 |
|---|---|
Events on your account |
|
Events on Connected accounts |
|
私の設定の問題
FolioInk の Webhook エンドポイントは、"Events on your account" だけ 作成されていました。
つまり、Connected account 側で発生する account.updated イベントは、そもそも送信対象にしていなかった。Stripe は「送る先がない」ので送っていなかった。届かないのは当然です。
Stripe の公式ドキュメントには明記されているのですが、実装初期は単純な決済フロー(checkout.session.completed 等)しか気にしておらず、Connect 特有のイベントについて意識せずにエンドポイントを1本だけ作ってしまった、というのが根本原因です。
解決策:エンドポイントを2本立てる
やるべきことは次の2つです。
Stripe Dashboard に "Connected accounts" 用のエンドポイントを新規作成する
アプリ側で2つの署名シークレット両方を使って動作検証する
手順1:Connect用エンドポイントをStripe Dashboardに追加
Stripe Dashboard →「開発者」→「Webhooks」→「エンドポイントを追加」。
URL: 既存の Webhook URL と同じでOK
受信するイベント:
Events on Connected accountsを選択イベントタイプ: 最低限
account.updated。必要に応じてcapability.updated等も追加
作成後、署名シークレット(whsec_...)が新しく発行されます。これが既存の "Events on your account" エンドポイントのシークレットとは別物であることが重要です。
手順2:環境変数に追加
従来は STRIPE_WEBHOOK_SECRET のみを持っていたところに、STRIPE_CONNECT_WEBHOOK_SECRET を追加。
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxx # Account用
STRIPE_CONNECT_WEBHOOK_SECRET=whsec_yyyyyyyyyy # Connected accounts用Vercel の場合は Environment Variables に追加して再デプロイ。
手順3:Webhookハンドラーで両方のシークレットを試す
同じURLで両方のエンドポイントを受ける構成 にしたので、届いたリクエストに対してどちらのシークレットで検証すれば通るかを順番に試す実装にしました。
// src/app/api/stripe/webhook/route.ts
export async function POST(req: NextRequest) {
const stripe = getStripe();
const body = await req.text();
const sig = req.headers.get("stripe-signature");
if (!sig) {
return NextResponse.json({ error: "署名がありません" }, { status: 400 });
}
// Account用とConnect用の2つのWebhookエンドポイントがStripe側に存在し、
// それぞれ固有の署名シークレットを持つ。順番に検証を試みる。
const webhookSecrets = [
process.env.STRIPE_WEBHOOK_SECRET,
process.env.STRIPE_CONNECT_WEBHOOK_SECRET,
].filter(Boolean) as string[];
let event: Stripe.Event | null = null;
for (const secret of webhookSecrets) {
try {
event = stripe.webhooks.constructEvent(body, sig, secret);
break; // 検証成功したらループ終了
} catch {
// 次のシークレットで検証を試みる
}
}
if (!event) {
return NextResponse.json({ error: "署名検証に失敗しました" }, { status: 400 });
}
// 以降、event.type で分岐してハンドラーを呼び出す
// ...
}account.updated のハンドラー実装
Stripe の account.charges_enabled と account.payouts_enabled を見て、両方 true なら ACTIVE、それ以外は PENDING に更新します。
async function handleAccountUpdated(account: Stripe.Account) {
const status =
account.charges_enabled && account.payouts_enabled
? "ACTIVE"
: "PENDING";
await prisma.creatorProfile.updateMany({
where: { stripeAccountId: account.id },
data: { stripeAccountStatus: status },
});
}これで、クリエイターが Stripe 側でオンボーディングを完了した瞬間に、FolioInk の DB も自動で ACTIVE に切り替わるようになりました。
過去のクリエイターをどうリカバリしたか
ここまでの修正で、今後新しく連携するクリエイターはちゃんと動くようになりました。
でも、問題が発覚する前に連携を完了していたクリエイターは PENDING のまま取り残されています。この取り残し分は Webhook がそもそも送られなかったので、再送で救うこともできません。
リカバリは以下の流れで実施しました。
Stripe Dashboard で Connected accounts の一覧を取得
各アカウントの
charges_enabled/payouts_enabledを確認FolioInk 側の DB で該当クリエイターの
stripeAccountStatusを手動でACTIVEに更新
数名規模だったので手動で済みましたが、数が多い場合は Stripe の accounts.list API で一括取得して、スクリプトで同期する方が確実です。
なお、Stripe は account.updated イベントを過去に遡って再送する手段を提供していません。エンドポイントが存在しなかった時期のイベントは永遠に届かないので、この手動同期は避けられません。
設計上の反省:エンドポイント分離か、統合URLか
今回は「同じURLで両方受けて、コード側で2つのシークレットを順に試す」という実装にしました。これはコードが2つのイベントソースを扱っていることを明示できる反面、署名検証が2回試行される分わずかに無駄があります。
もう1つの選択肢は、エンドポイントのURL自体を分ける 方法です。
/api/stripe/webhook→ Account用のみ/api/stripe/connect-webhook→ Connected accounts用のみ
この方が責務が明確で、ハンドラー側も「このエンドポイントはConnected accounts用」と決め打ちできて見通しが良いです。将来のリファクタ候補として残しています。
個人開発でまだ小規模なうちは統合URLで十分ですが、Connect関連のイベント種別が増えてきたら分離 する方が保守性で勝ると思います。
他にも注意したほうがいいConnect関連イベント
account.updated だけ受けておけば最低限動きますが、本番運用では他のイベントも見ておくと事故が減ります。
イベント | 用途 |
|---|---|
| オンボーディング完了・制限の有無を検知 |
| 個別の機能(card_payments, transfers)の有効化を検知 |
| クリエイターがStripe連携を解除した時の検知 |
| 本人確認情報の更新検知(KYCリクエストの解消判定) |
特に account.application.deauthorized は、「クリエイターが自分でStripe連携を切った」ケースを検知する唯一の手段なので、本番化する前に追加しておくことを推奨します。
まとめ
Stripe Connect でハマったら、まず Webhook 設定を疑う。
ポイントをまとめます。
Webhookエンドポイントは2種類ある — "Events on your account" と "Events on Connected accounts" は別物。Connect を使うなら両方作る
署名シークレットも2種類になる — それぞれのエンドポイントで独立して発行される。環境変数を2つ持つか、URLを分けるかの設計判断が必要
既存データの手動リカバリは避けられない — Webhookが設定されていなかった期間のイベントは再送できない。
accounts.listAPI で現状を取り直してDB同期が必要Connect関連のイベント種別を一度棚卸しする —
account.updatedだけでなくaccount.application.deauthorizedなども本番では重要
個人開発で Stripe Connect を初めて実装する際、ドキュメントを読んでいても「Account 用と Connected accounts 用のエンドポイントが別になる」という点は意外と見落としがちです。私も踏みました。
この記事がそれを事前に回避するための一助になれば幸いです。