tasteck
ブログ一覧に戻る
アップデート

本番 Stripe webhook が 5 日間サイレント失敗していた話 — 21 件の invoice.paid を取り戻した運用 incident report

業界 SaaS tasteck の本番で Stripe からの invoice.paid webhook が 5/1 から 5 日間で 21 件全滅していた incident。Stripe からの『webhook 自動停止 5/10』警告メールで気付き、原因特定 → コード修正 → データ修復まで実況。Build-in-Public 第 6 弾、現場の生 incident report。

Build-in-Public 第 5 弾 で「Day 10 経過、clicks 倍増」を書きました。第 6 弾は趣向を変えて、運用の 生 incident report です。

業界 SaaS の本番で Stripe webhook が 5 日間サイレント失敗していた話。Stripe からの「webhook 自動停止します」という警告メールで気付いたところからの実況。

発端: Stripe からの警告メール

ある日、Stripe からこんなメールが届きました:

本番環境で タステック アカウントに関連付けられた Webhook エンドポイントへリクエストを送信する際に、問題が発生しました。

問題が発生した Webhook エンドポイントの URL: https://api.no-tel.com/v1/api/staff/payment/webhook

このエンドポイントに 2026年5月1日 2:02:21 UTC以降、イベント通知の送信を 16 回試みました。

Stripe はこの Webhook エンドポイントへのイベント通知の送信を 2026年5月10日 2:02:21 UTC までに停止します。

5/10 で webhook が自動停止される — これは止めなければサブスクリプションの請求書通知が届かなくなる致命的な状況。

まず curl で生存確認

エンドポイント自体が落ちてる可能性もあるので、空 payload で叩いてみます。

curl -X POST -H "Content-Type: application/json" \
  -d '{"type":"ping"}' \
  https://api.no-tel.com/v1/api/staff/payment/webhook
# → 201 Created

エンドポイントは 生きてる。じゃあ Stripe からの実イベントだけ失敗してる、と切り分け。

本番 PM2 ログで原因特定

EC2 の PM2 ログから直接エラー grep:

ssh ec2-user@13.230.83.170 \
  "grep 'サブスクリプション' /home/ec2-user/.pm2/logs/api-error.log | tail -40"

# → HttpException [Error]: サブスクリプションアイテムが見つかりませんでした。 ×40+

特定の lookup 失敗が連発してました。コード見ると:

const stripeSubscriptionItem = await ...
  .getRepository(StripeSubscriptionItem)
  .where("stripe_id = :stripeId", { stripeId: subscriptionId })
  .getOne();
if (!stripeSubscriptionItem) {
  throw new HttpException(
    "サブスクリプションアイテムが見つかりませんでした。",
    HttpStatus.BAD_REQUEST  // ← これが Stripe からは「retry 対象」
  );
}

落とし穴 ①: Stripe webhook の 4xx は retry される

ここで第 1 のトラップ。

「DB に row が無い → クライアントエラー → 4xx で返す」というのは REST API の自然なエラーハンドリングですが、Stripe webhook は 4xx も retry 対象 にします (公式仕様)。

つまり「DB 不整合 → 4xx → Stripe が再送 → また 4xx → 再送... 」の 無限リトライループ。最終的に Stripe が「3 日経っても 200 来ない」と判定して webhook 自動停止 (今回 5/10 期限)。

4xx → 200 OK + warning ログ に倒す方が安全。

落とし穴 ②: 月初にしか発火しないタイミングバグ

invoice.paid イベントは 月次サブスクの請求サイクルで発火します。今回失敗してたのは「自動継続課金」の通知。

つまり、月初の数日に一気に 20 件失敗 → 後の 25 日は何も起きない → Sentry も普段は静か。

Sentry の無料枠を超えてイベント送信が止まってたこともあって、5 日間誰にも気付かれずいたわけです。

新規 SaaS のあるあるトラップ:

  • 月初イベント = 月に 1 度しか発火しない
  • 失敗ログは日常 noise に紛れる
  • Sentry 無料枠 (5K events/month) はすぐ枯れる

真の原因: 1 customer の subscription_items 行欠損

調査スクリプトを本番 RDS に投げて該当 customer を引きます:

SELECT * FROM company_groups WHERE customer_id = 'cus_xxx';
-- → MK株式会社 (company_group_id=96, plan='starter')

SELECT * FROM stripe_subscriptions WHERE company_group_id = 96;
-- → 2 件 (アクティブ 1, 削除済 1)

SELECT * FROM stripe_subscription_items WHERE stripe_subscription_id IN (175, 176);
-- → 0 件!!

該当 customer の subscription_items 行が完全に 0 件。過去のデータ移行 or サブスク作成時の INSERT 漏れ。

修復 A: データ修復 (root cause)

Stripe Dashboard で該当 subscription を開き、subscription item の Stripe ID と plan_type を確認:

  • subscription item ID: si_xxx
  • price ID: price_xxx (スタンダードプラン月払い ¥15,000)
  • → DB 上の plan_type = starter

足りない 1 行を INSERT:

INSERT INTO stripe_subscription_items
  (stripe_subscription_id, stripe_id, plan_type, is_annual)
VALUES
  (176, 'sub_xxx', 'starter', 0);

(注: 既存 records の慣習で stripe_id 列に subscription ID を入れる設計。1 sub = 1 item 前提のドメイン)

修復 B: handler ロバスト化 (将来抑止)

データ修復は 1 customer 単位の対症療法。将来同じ「DB 不整合」customer が現れた時に webhook が retry ループに陥らないよう、handler を倒します:

if (!stripeSubscriptionItem) {
  // 元: throw HttpException(BAD_REQUEST)
  console.warn(
    `[webhook] StripeSubscriptionItem not found for sub_id=${subId}, skipping plan update`
  );
  break;  // 200 OK で抜ける
}

customer.subscription.deleted / invoice.paid 内の 3 箇所 の throw を同様に warning + break に変更。

動作確認

A の INSERT 後、Stripe Dashboard で 1 件「再送する」をクリック:

2026-05-05T05:27:01.500Z POST /v1/api/staff/payment/webhook 201 2 Stripe/1.0

201 OK ✅。続けて B のデプロイで将来のリトライループも完全抑止。

学び

  1. Stripe webhook は 4xx を retry する — DB 不在は 200 + warning 推奨
  2. 月初イベントは noise に紛れる — 月次集計の Sentry alert が必要 (Sentry の rate limit にも注意)
  3. データ移行漏れは 1 customer ずつ静かに眠ってる — 全 subscription に対する items 整合性チェックを定期 batch で
  4. incident report の透明な公開 = Build-in-Public の質を上げる (技術 readers と業界経営者の両方に signal)

業界 SaaS の運用は、SaaS だけど業種特化分のドメイン理解 (subscription cycle / 法定複雑度 / 運用習慣) が重なってデバッグが地味に難しい。「沈黙する失敗」を炙り出す観測体制が一番効きます。


関連記事:

30日無料トライアル実施中

現場を、もっと軽やかに。
tasteck で始めましょう。

お申込みは 3 分で完了。カード登録なし、電話番号認証のみ。

  • カード登録なしで開始
  • データ移行サポート無料
  • 全機能 30 日間お試し