Stripe Webhookイベントリファレンス
説明
このドキュメントは、システムが処理するすべてのStripe webhookイベントの包括的なリファレンスを提供し、その目的、データ構造、処理ロジック、および期待される結果を含みます。webhook機能をデバッグまたは拡張する際のクイックリファレンスガイドとして使用してください。
イベント処理概要
graph TD
A[Stripeイベント] --> B{イベントタイプ}
B -->|checkout.session.*| C[SubscriptionSessionHandler]
B -->|customer.subscription.*| D[SubscriptionLifeCycleHandler]
B -->|invoice.*| E[SubscriptionInvoiceHandler]
B -->|payment_intent.succeeded| F[PaymentIntentHandler]
C --> G[(データベース)]
D --> G
E --> G
F --> G
style A fill:#fcd9d9,stroke:#cc3333
style C fill:#f0f8e6,stroke:#339933
style D fill:#f0f8e6,stroke:#339933
style E fill:#f0f8e6,stroke:#339933
style F fill:#f0f8e6,stroke:#339933
style G fill:#ffe6cc,stroke:#ff9900
チェックアウトイベント
checkout.session.completed
目的: Stripeチェックアウトを介した支払い成功後に新規サブスクリプションを有効化します。
トリガー時:
- ユーザーがStripeチェックアウトで支払いを完了
- 有料プランと無料プランの両方
ハンドラー: SubscriptionSessionHandler::handleSessionCompleted()
主要データフィールド:
{
"id": "cs_test_...",
"object": "checkout.session",
"mode": "subscription",
"subscription": "sub_...",
"invoice": "in_...",
"customer": "cus_...",
"metadata": {
"subscription_slug": "sub_abc123"
},
"amount_total": 1000,
"currency": "jpy",
"payment_status": "paid"
}
処理ロジック:
metadata.subscription_slugでローカルサブスクリプションを検索- サブスクリプションステータスが
unpaidであることを確認 - サブスクリプションレコードをロック
- サブスクリプションを更新:
status:unpaid→activepayment_provider_subscription_id: StripeサブスクリプションIDdeadline_at: Stripeからの期間終了
- subscription_historiesを更新:
payment_status:pending→paidinvoice_id,payment_intent_id,paid_at
期待される結果:
- サブスクリプション有効化
- ユーザーがサービスにアクセス可能
- 履歴レコードが支払済みとしてマーク
関連イベント:
invoice.paid(重複処理を防ぐため、新規サブスクリプションでは無視)
サブスクリプションライフサイクルイベント
customer.subscription.updated
目的: すべてのサブスクリプション変更イベントを処理します(プラン変更、キャンセル、ステータス変更)。
トリガー時:
- プラン変更(アップグレード/ダウングレード)
- キャンセルスケジュール
- キャンセル再開
- ステータス変更(active → past_due など)
- 期間更新
- サブスクリプション一時停止/再開
ハンドラー: SubscriptionLifeCycleHandler::handleSubscriptionUpdated()
主要データフィールド:
{
"id": "sub_...",
"object": "subscription",
"status": "active",
"items": {
"data": [{
"plan": {
"id": "price_...",
"product": "prod_...",
"interval": "month"
}
}]
},
"current_period_start": 1702857600,
"current_period_end": 1705536000,
"cancel_at_period_end": false,
"canceled_at": null,
"previous_attributes": {
"items": { /* 旧プラン */ },
"cancel_at_period_end": true
}
}
処理ロジック(決定ツリー):
1. incomplete_expired? → handleIncompleteExpired()
2. 無料有効化? → handleFreeSubscriptionActivation()
3. スケジュールされたキャンセル? → handleScheduledCancellation()
4. 支払期限切れ? → handlePastDue()
5. 一時停止? → handleSubscriptionPause()
6. 一時停止から再開? → handleSubscriptionResumeFromPause()
7. キャンセルから再開? → handleSubscriptionResume()
8. スケジュールされたプラン変更適用? → handleScheduledPlanChangeApplication()
9. プラン変更? → handlePlanChange()
- 即時変更? → handleImmediatePlanChange()
- スケジュール変更? → handleScheduledPlanChange()
10. 自動更新? → handleAutoRenewal()
11. 請求サイクルリセット? → handleBillingCycleReset()
12. デフォルト: handleRegularUpdate()
シナリオ:
| シナリオ | 検出基準 | アクション |
|---|---|---|
| スケジュールされたキャンセル | cancel_at_period_end: true |
ステータスをpending_cancellationに更新、キャンセル履歴を作成 |
| キャンセル再開 | previous: cancel_at_period_end=true, current: false |
activeに戻す、キャンセル履歴を削除 |
| 即時プラン変更 | プランID変更 + 期間開始が最近(<2分) | 変更履歴を作成、package_plan_idを更新 |
| スケジュールされたプラン変更適用 | scheduled_plan_idが現在のプランと一致 + 期間変更 |
変更履歴を確定 |
| 自動更新 | 期間変更、プラン不変、status=active | 更新履歴を作成(保留中、invoice.paidで確定) |
| 支払期限切れ | status: 'past_due' |
ステータスを更新、猶予期間を延長 |
期待される結果:
- データベースでサブスクリプションステータスが更新
- 履歴レコードが作成または更新
- ユーザーアクセスが適切に調整
customer.subscription.deleted
目的: 最終的なサブスクリプションキャンセルを処理します。
トリガー時:
- サブスクリプションが即座にキャンセル
- スケジュールされたキャンセル期間が終了
- 支払い失敗がすべての再試行を使い果たす
ハンドラー: SubscriptionLifeCycleHandler::handleSubscriptionDeleted()
主要データフィールド:
{
"id": "sub_...",
"object": "subscription",
"status": "canceled",
"canceled_at": 1702857600,
"current_period_end": 1705536000,
"cancellation_details": {
"reason": "cancellation_requested",
"comment": "ユーザーがキャンセルをリクエスト",
"feedback": "too_expensive"
}
}
処理ロジック:
provider_subscription_idでローカルサブスクリプションを検索- サブスクリプションを更新:
status: →canceledcanceled_at: Stripeからのタイムスタンプcanceled_reason: cancellation_detailsから
immediate_cancellation履歴レコードを作成- 保留中のインボイスアイテムをクリア(ある場合)
期待される結果:
- データベースでサブスクリプションがキャンセル
- ユーザーアクセスが終了(またはgrace_period_end_atまで継続)
- キャンセル履歴が作成
インボイスイベント
invoice.paid
目的: 成功したインボイス支払いを処理します(更新、プラン変更)。
トリガー時:
- 定期支払いが成功
- プラン変更支払いが成功
- 初回サブスクリプション支払いが成功(無視、checkout.session.completedで処理)
ハンドラー: SubscriptionInvoiceHandler::handleInvoicePaid()
主要データフィールド:
{
"id": "in_...",
"object": "invoice",
"subscription": "sub_...",
"billing_reason": "subscription_cycle",
"amount_paid": 1000,
"amount_due": 1000,
"currency": "jpy",
"payment_intent": "pi_...",
"period_start": 1702857600,
"period_end": 1705536000,
"lines": {
"data": [{
"plan": { "id": "price_...", "interval": "month" },
"amount": 1000
}]
}
}
処理ロジック(billing_reasonに基づく):
1. subscription_create:
- 無視 - 代わりに
checkout.session.completedで処理 - 重複履歴作成を防止
2. subscription_cycle(自動更新):
provider_subscription_idでサブスクリプションを検索- 新しい履歴レコードを作成:
type:renewalpayment_status:paidinvoice_id,payment_intent_id,paid_at
- サブスクリプションの
deadline_atを更新
3. subscription_update(プラン変更):
- 保留中の変更履歴をチェック
- 見つかった場合(通常フロー):
- 支払い詳細で履歴を更新
payment_status:paidに設定
- 見つからない場合(競合状態 - invoice.paidが先に到着):
createChangeHistoryFromInvoice()を呼び出し- クレジットライン(amount < 0)から
old_plan_idを抽出 - チャージライン(amount > 0)またはStripe APIから
new_plan_idを抽出 - 完全な履歴レコードを作成
期待される結果:
- 更新: 新しい履歴期間が作成され、期限が延長
- プラン変更: 支払いが記録され、変更が確定
invoice.payment_failed
目的: 失敗した定期支払いを処理します。
トリガー時:
- 自動支払いが失敗
- 残高不足、カード期限切れなど
ハンドラー: SubscriptionInvoiceHandler::handleInvoicePaymentFailed()
主要データフィールド:
{
"id": "in_...",
"object": "invoice",
"subscription": "sub_...",
"billing_reason": "subscription_cycle",
"attempt_count": 1,
"amount_due": 1000,
"next_payment_attempt": 1703030400
}
処理ロジック:
provider_subscription_idでサブスクリプションを検索- 最新の履歴レコードを検索
- 最新の履歴が
paidの場合(初回失敗):- 新しい履歴を作成:
type:renewalpayment_status:failedpayment_attempt: 1
- 新しい履歴を作成:
- 最新の履歴が
failedの場合(再試行失敗):- 履歴を更新:
payment_attempt: + 1
- 履歴を更新:
注意:
- サブスクリプションステータス変更(
active→past_due→canceled)はcustomer.subscription.updated/deletedイベントで処理 - このイベントは支払い試行のみを追跡
期待される結果:
- 失敗した支払いが記録
- 支払い試行カウントが増加
- Stripeの再試行ロジックまたは最終ステータス変更を待機
支払いインテントイベント
payment_intent.succeeded
目的: 追跡のためにpayment intent IDで履歴レコードを更新します。
トリガー時:
- 任意の支払いが正常に処理
ハンドラー: PaymentIntentHandler::handlePaymentIntentSucceeded()
主要データフィールド:
{
"id": "pi_...",
"object": "payment_intent",
"invoice": "in_...",
"amount": 1000,
"status": "succeeded"
}
処理ロジック:
payment_intent_id IS NULLのinvoice_idで履歴を検索- 履歴を更新:
payment_intent_id=pi_...
注意: 主にデータ完全性のため、invoice.paidがすでにほとんどの支払い情報を入力しています。
イベント処理パターン
パターン1: 冪等性
すべてのイベントはstripe_webhook_eventsテーブルに対してチェックされます:
if ($webhookEvent->status === 'completed') {
return '200 OK - すでに処理済み';
}
if ($webhookEvent->status === 'processing') {
return '200 OK - 現在処理中';
}
// 新規または失敗の場合のみ処理
パターン2: 競合状態の処理
プラン変更イベントはどちらの順序でも到着可能:
シナリオA:
1. customer.subscription.updated → 保留中の履歴を作成
2. invoice.paid → 支払いで履歴を確定
シナリオB:
1. invoice.paid → インボイスデータから完全な履歴を作成
2. customer.subscription.updated → 既存の履歴を検出、作成をスキップ
実装:
subscription_id + type + started_at(±5秒許容)で既存の履歴をチェック- 見つかった場合は更新、見つからない場合は利用可能なデータから作成
パターン3: トランザクション安全性
すべてのデータベース変更はトランザクションでラップされます:
return $this->wrapTransaction(function () use (...) {
// すべてのDB操作がアトミック
$this->updateSubscription(...);
$this->createHistory(...);
});
パターン4: Null安全性
すべてのwebhookデータアクセスは安全なメソッドを使用します:
// 良い
$planId = data_get($session, 'items.data.0.plan.id');
$billingReason = $session->billing_reason ?? null;
// 悪い
$planId = $session->items['data'][0]['plan']['id']; // クラッシュする可能性
Webhook問題のデバッグ
処理ステータスの確認
SELECT
stripe_event_id,
event_type,
status,
error,
created_at,
processed_at
FROM stripe_webhook_events
WHERE event_type LIKE '%subscription%'
ORDER BY created_at DESC
LIMIT 20;
失敗したイベントの検索
SELECT * FROM stripe_webhook_events
WHERE status = 'failed'
ORDER BY created_at DESC;
履歴作成の確認
SELECT
sh.id,
sh.type,
sh.payment_status,
sh.invoice_id,
sh.started_at,
sh.created_at
FROM subscription_histories sh
WHERE sh.subscription_id = :subscription_id
ORDER BY sh.created_at DESC;
確認するログ
storage/logs/stripe-{date}.log- すべてのwebhook処理storage/logs/laravel.log- 一般的なアプリケーションエラー
クイックリファレンステーブル
| イベント | Billing Reason | ハンドラーメソッド | 履歴作成? | サブスクリプション更新? |
|---|---|---|---|---|
checkout.session.completed |
N/A | handleSessionCompleted() |
いいえ(既存を更新) | はい |
invoice.paid |
subscription_create |
(無視) | いいえ | いいえ |
invoice.paid |
subscription_cycle |
handleAutoRenewalInvoice() |
はい(renewal) | はい(deadline) |
invoice.paid |
subscription_update |
handlePlanChangeInvoice() |
はい/更新(change) | はい(deadline) |
invoice.payment_failed |
Any | handleInvoicePaymentFailed() |
はい/更新(failed) | いいえ |
customer.subscription.updated |
N/A | handleSubscriptionUpdated() |
変動 | はい |
customer.subscription.deleted |
N/A | handleSubscriptionDeleted() |
はい(cancellation) | はい |
payment_intent.succeeded |
N/A | handlePaymentIntentSucceeded() |
いいえ(既存を更新) | いいえ |
ドキュメントステータス: すべてのwebhookイベントの完全なリファレンス