全国オンライン対応受付 平日 9:00-18:00
お問い合わせ

開発記録 / 1cto.jp制作記 / Vol.23 1cto.jp制作記 Vol.2

記事 03

Webhookが届かなくてもサービスを止めないフォールバック設計

ネットワークの問題でリクエストが消えることがある。サーバーが再起動中だと受け取れない。Stripeの側で一時的な障害が起きることもある。「払ったのに読めない」はどれか一つが欠けるだけで起きる。

2026-06-07 公開

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つのポイント」*

*シンプルシステム株式会社 代表 伊藤勝彦*