Webhookは「届いて当然」ではない。
ネットワークの問題でリクエストが消えることがある。サーバーが再起動中だと受け取れない。Stripeの側で一時的な障害が起きることもある。「払ったのに読めない」はどれか一つが欠けるだけで起きる。
1cto.jpはWebhookが届かなくてもサービスを止めない設計にした。
Webhookが届かないケース
Stripeから1cto.jpのサーバーへのWebhookは、複数の理由で届かないことがある。
サーバーが応答できない状態のとき(デプロイ中・EC2メンテナンス・nginxの設定ミスによる502)、StripeはWebhookの配信に失敗する。
WebhookのレスポンスまでStripeは5秒待つ。処理に5秒以上かかると、StripeはタイムアウトとしてWebhookが失敗したとみなす。
Stripeにはリトライ機能がある。失敗すると指数バックオフで最大72時間リトライする。しかし72時間以内にサーバーが復旧しなければ、Webhookは永久に届かない。
Webhookのみに依存するリスク
サブスクのアクセス制御をWebhookだけで実現する設計を考える。
ユーザーが980円を支払う → success画面にリダイレクト → Webhookが届いてDBに記録が作られる → 記事が読める。
この設計では、Webhookが届かないと「払ったのに読めない」状態が発生する。ユーザーには何が起きているか分からない。
2層のフォールバック設計
アクセス制御を「DB → Stripe API」の2層で確認する設計にした。
def can_access_content(user: User, article: Article) -> bool:
if article.is_free_sample:
return True
# 層1: DBで確認
sub = db.query(Subscription).filter(
Subscription.user_id == user.id,
Subscription.status == 'active'
).first()
if sub:
return True
volume_purchased = db.query(Purchase).filter(
Purchase.user_id == user.id,
Purchase.volume_no == article.volume_no
).first()
if volume_purchased:
return True
bundle_purchased = db.query(Purchase).filter(
Purchase.user_id == user.id,
Purchase.is_bundle == True
).first()
if bundle_purchased:
return True
# 層2: DBになければStripeに直接問い合わせ
if user.stripe_customer_id:
try:
stripe_subs = stripe.Subscription.list(
customer=user.stripe_customer_id,
status='active',
limit=1
)
if stripe_subs.data:
return True
except stripe.error.StripeError:
pass
return False
DBで見つかればStripeには問い合わせない。通常のケースではDBで解決するため、Stripe APIへの余分なリクエストは最小限で済む。
べき等性の確保
Webhookのリトライで同じイベントが2回届くことがある。同じcheckout.session.completedが2回届いて、DBに購入レコードが2件作られると困る。
stripe_session_idにUNIQUE制約を追加して、重複挿入をDBレベルで防ぐ。
class Purchase(Base):
__tablename__ = 'purchases'
stripe_session_id = Column(String, unique=True, nullable=False)
Webhookハンドラでは2回目の呼び出しを検知して200を返すだけで終わる。Stripeに「成功した」と伝えることで、Stripeは以降のリトライを止める。
existing = db.query(Purchase).filter(
Purchase.stripe_session_id == session_id
).first()
if existing:
return {'status': 'already_processed'}
ローカルでのWebhookテスト
開発中はStripe CLIを使ってローカル環境にWebhookを転送できる。
stripe listen --forward-to localhost:8000/api/checkout/webhook
StripeダッシュボードのTest Modeで決済をテストすると、ローカルのFastAPIサーバーにWebhookが届く。本番環境を使わずに、Webhookの動作確認ができる。
障害時の調査はStripeダッシュボードの「Developers → Webhooks」から始める。配信履歴・ステータスコード・レスポンスBodyが記録されている。特定のイベントを選んで「Resend」ボタンを押すと、任意のWebhookを再送できる。
*次回は「マイページの解約フローを完成させるまでに詰まった7つのポイント」*
*シンプルシステム株式会社 代表 伊藤勝彦*