Stripe Webhook処理の詳細
説明
このドキュメントは、Stripeからのwebhookを処理する方法について包括的な詳細を提供し、冪等性、データ整合性、適切なエラー処理を保証します。アーキテクチャ、webhook検証、イベントルーティング、およびコードベースに実装されたベストプラクティスをカバーしています。
webhookシステムはサブスクリプション管理のバックボーンであり、すべての重要な状態変更はStripeから発信され、これらのイベントを通じて通信されます。適切な処理は、Stripeとローカルデータベース間の同期を維持するために不可欠です。
Webhookアーキテクチャ
---
config:
theme: base
layout: dagre
flowchart:
curve: linear
htmlLabels: true
themeVariables:
edgeLabelBackground: "transparent"
---
flowchart TD
%% == ノード ==
Stripe((Stripe API))
subgraph WebhookLayer["Webhookコントローラー層"]
WebhookController[WebhookController]
Verification{署名<br/>検証}
Idempotency{冪等性<br/>チェック}
Router{イベント<br/>ルーター}
end
subgraph HandlerLayer["イベントハンドラー層"]
SessionHandler[SubscriptionSessionHandler]
LifecycleHandler[SubscriptionLifeCycleHandler]
InvoiceHandler[SubscriptionInvoiceHandler]
PaymentIntentHandler[PaymentIntentHandler]
end
subgraph Database["データベース層"]
WebhookEventsDB[(stripe_webhook_events)]
SubscriptionDB[(subscriptions)]
HistoryDB[(subscription_histories)]
end
%% == 接続 ==
Stripe -->|1. イベント送信| WebhookController
WebhookController --> Verification
Verification -->|有効| Idempotency
Verification -->|無効| Error403[403 Forbidden]
Idempotency -->|チェック| WebhookEventsDB
WebhookEventsDB -->|ステータス| Idempotency
Idempotency -->|新規/失敗| Router
Idempotency -->|完了/処理中| Return200[200 OK]
Router -->|checkout.session.completed| SessionHandler
Router -->|customer.subscription.*| LifecycleHandler
Router -->|invoice.*| InvoiceHandler
Router -->|payment_intent.succeeded| PaymentIntentHandler
SessionHandler --> SubscriptionDB
SessionHandler --> HistoryDB
LifecycleHandler --> SubscriptionDB
LifecycleHandler --> HistoryDB
InvoiceHandler --> SubscriptionDB
InvoiceHandler --> HistoryDB
PaymentIntentHandler --> HistoryDB
SessionHandler --> UpdateWebhook[イベントステータス更新]
LifecycleHandler --> UpdateWebhook
InvoiceHandler --> UpdateWebhook
PaymentIntentHandler --> UpdateWebhook
UpdateWebhook --> WebhookEventsDB
UpdateWebhook --> FinalResponse[200 OK]
%% == スタイリング ==
style Stripe fill:#fcd9d9,stroke:#cc3333,stroke-width:2px
style WebhookLayer fill:#e6f3ff,stroke:#0066cc,stroke-width:2px
style HandlerLayer fill:#f0f8e6,stroke:#339933,stroke-width:2px
style Database fill:#ffe6cc,stroke:#ff9900,stroke-width:2px
style Error403 fill:#ffcccc,stroke:#cc0000,stroke-width:2px
style Return200 fill:#ccffcc,stroke:#00cc00,stroke-width:2px
style FinalResponse fill:#ccffcc,stroke:#00cc00,stroke-width:2px
Webhookイベント処理フロー
1. 署名検証
目的: webhookリクエストが本当にStripeから来ており、改ざんされていないことを確認します。
実装:
// WebhookController.php
private function getLiveEventObject(Request $request): object
{
$payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature');
$webhookSecret = config('stripe.config.webhook_secret');
if (empty($sigHeader) || empty($webhookSecret)) {
throw new \UnexpectedValueException('署名またはwebhookシークレットが見つかりません');
}
// Stripe SDKがHMAC署名を検証
return Webhook::constructEvent($payload, $sigHeader, $webhookSecret);
}
エラー処理:
- 署名なし →
403 Forbidden - 無効な署名 →
403 Forbidden(リプレイ攻撃を防止) - 不正なペイロード →
400 Bad Request
2. 冪等性チェック
目的: 同じイベントを複数回処理することを防ぎます(Stripeはwebhookを再試行する場合があります)。
実装:
// WebhookController.php
private function getOrCreateWebhookEvent(object $event, ?string $requestId): object
{
$webhookEvent = $this->stripeWebhookEventRepository->findExistingEvent(
$event->data->object['id'],
$requestId,
$event->type
);
$eventStatus = ($webhookEvent && $webhookEvent->status === StripeWebhookEventStatus::Failed->value)
? StripeWebhookEventStatus::Failed->value
: StripeWebhookEventStatus::Pending->value;
return $this->stripeWebhookEventRepository->updateOrCreate(
[
'stripe_event_id' => $event->data->object['id'],
'request_id' => $requestId,
'event_type' => $event->type
],
['status' => $eventStatus]
);
}
データベーススキーマ:
CREATE TABLE stripe_webhook_events (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
stripe_event_id VARCHAR(255) NOT NULL,
request_id VARCHAR(255),
event_type VARCHAR(255) NOT NULL,
status ENUM('pending', 'processing', 'completed', 'failed') NOT NULL DEFAULT 'pending',
error TEXT,
processed_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Indexes for efficient filtering and lookup
INDEX idx_status (status),
INDEX idx_event_type (event_type),
INDEX idx_stripe_event_id (stripe_event_id),
INDEX idx_request_id (request_id),
-- Unique constraint for idempotency
UNIQUE KEY unique_event (stripe_event_id, request_id, event_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ステータス遷移:
pending→ イベント作成済み、未処理processing→ 現在処理中completed→ 正常に処理完了failed→ 処理失敗、再試行予定
3. イベントルーティング
処理されるイベントタイプ:
| イベントタイプ | ハンドラー | 目的 |
|---|---|---|
checkout.session.completed |
SubscriptionSessionHandler | 支払い後に新規サブスクリプションを有効化 |
checkout.session.async_payment_succeeded |
SubscriptionSessionHandler | 非同期支払い完了を処理 |
customer.subscription.created |
(無視) | checkout.session.completedで処理 |
customer.subscription.updated |
SubscriptionLifeCycleHandler | プラン変更、キャンセル、更新 |
customer.subscription.deleted |
SubscriptionLifeCycleHandler | 最終キャンセル |
invoice.paid |
SubscriptionInvoiceHandler | 支払い成功(更新、プラン変更) |
invoice.payment_failed |
SubscriptionInvoiceHandler | 支払い失敗処理 |
payment_intent.succeeded |
PaymentIntentHandler | payment intent IDで履歴を更新 |
ルーティングロジック:
// WebhookController.php
private array $eventHandlers = [
WEBHOOK_EVENT_CHECKOUT_SESSION_COMPLETED => 'handleCheckoutSession',
WEBHOOK_EVENT_SUBSCRIPTION_UPDATED => 'handleSubscriptionUpdated',
WEBHOOK_EVENT_SUBSCRIPTION_DELETED => 'handleSubscriptionDeleted',
WEBHOOK_EVENT_INVOICE_PAID => 'handleInvoicePaid',
WEBHOOK_EVENT_INVOICE_PAYMENT_FAILED => 'handleInvoicePaymentFailed',
WEBHOOK_EVENT_PAYMENT_INTENT_SUCCEEDED => 'handlePaymentIntentSucceeded'
];
4. ハンドラー実行
トランザクション安全性: すべてのハンドラーはデータベース操作をトランザクションでラップします:
// SubscriptionLifeCycleHandlerの例
private function wrapTransaction($callback)
{
return $this->subscriptionRepository->transaction($callback);
}
public function handleSubscriptionUpdated($session, $previousAttributes)
{
return $this->wrapTransaction(function () use ($session, $previousAttributes) {
// ここのすべてのDB操作はアトミック
$this->updateSubscription(...);
$this->createHistory(...);
});
}
ログ実例 (stripe-2026-01-06.log)
以下は stripe-2026-01-06.log からの実例です。ログは主要項目(ID、金額、invoice 状態)のみを表示しています。実際の interval(daily/monthly/yearly)は Stripe の price.recurring.interval で判断します。
1) アップグレード (Free -> Basic) — 即時変更 + 支払いあり
主な流れ:
customer.subscription.updated->SubscriptionLifeCycleHandlerinvoice.paid+payment_intent.succeeded->SubscriptionInvoiceHandler
ログ例:
[2026-01-06 16:25:25] local.INFO: Immediate plan change detected {"subscription_id":234,"new_plan_id":"price_1QZO2IC6W0lx7trg9iz1f9Rn"}
[2026-01-06 16:25:26] local.INFO: Immediate plan change finalized from Stripe latest_invoice (payment already settled) {"subscription_id":234,"change_history_id":3417,"invoice_id":"in_1SmUdmC6W0lx7trg6wkk44yS","amount_due":2000,"invoice_status":"paid","payment_intent_status":"succeeded"}
結果:
subscription_histories: type =change, payment_status =paid,invoice_idとpayment_intent_idがセット。
2) ダウングレード (Paid -> Free) — 即時変更 + invoice なし
主な流れ:
customer.subscription.updated->SubscriptionLifeCycleHandler
ログ例:
[2026-01-06 15:23:02] local.INFO: Immediate plan change detected {"subscription_id":231,"new_plan_id":"price_1RnD3yC6W0lx7trgicZwdJbN"}
[2026-01-06 15:23:02] local.INFO: Immediate plan change finalized with no invoice {"subscription_id":231,"change_history_id":3407,"new_plan_amount":0,"payment_status":"n/a"}
結果:
subscription_histories: type =change, payment_status =n/a,invoice_idなし。
3) プラン変更 (daily/monthly/yearly)
複数の即時変更がログに出ます。interval は Stripe price を参照します。
[2026-01-06 16:27:36] local.INFO: Immediate plan change detected {"subscription_id":234,"new_plan_id":"price_1QZO4IC6W0lx7trg01Mh3Z5a"}
[2026-01-06 16:27:38] local.INFO: Immediate plan change finalized from Stripe latest_invoice (payment already settled) {"subscription_id":234,"change_history_id":3419,"invoice_id":"in_1SmUfvC6W0lx7trgvuZ48LUf","amount_due":999,"invoice_status":"paid","payment_intent_status":"succeeded"}
[2026-01-06 16:39:47] local.INFO: Immediate plan change detected {"subscription_id":234,"new_plan_id":"price_1QMoGlC6W0lx7trgnOM4q2YW"}
[2026-01-06 16:39:48] local.INFO: Immediate plan change finalized from Stripe latest_invoice (payment already settled) {"subscription_id":234,"change_history_id":3420,"invoice_id":"in_1SmUrgC6W0lx7trgAIdMSUN7","amount_due":56789,"invoice_status":"paid","payment_intent_status":"succeeded"}
結果:
subscription_histories: type =change, payment_status =paid, interval =price.recurring.interval。
4) 予約キャンセル (scheduled cancellation)
主な流れ:
customer.subscription.updated->SubscriptionLifeCycleHandler
ログ例:
[2026-01-06 16:43:57] local.INFO: Subscription updated for scheduled cancellation {"subscription_id":234,"deadline_at":"2026-02-06 16:39:40","grace_period_end_at":"2026-02-13 16:39:40"}
[2026-01-06 16:43:57] local.INFO: Subscription marked for scheduled cancellation, and pending histories invalidated {"subscription_id":234,"cancellation_requested_at":"2026-01-06 16:43:55","cancellation_effective_at":"2026-02-06 16:39:40","invalidated_histories":[3421]}
結果:
subscriptions.status->pending_cancellation。- pending の history を無効化。
5) 予約キャンセル解除(再開)
主な流れ:
customer.subscription.updated->SubscriptionLifeCycleHandler(resume)
ログ例:
[2026-01-06 16:50:26] local.INFO: Subscription status transition {"subscription_id":234,"from":"pending_cancellation","to":"active","context":{"event":"subscription.updated","stripe_subscription_id":"sub_1SmUd3C6W0lx7trg06YbgX1Y","action":"resume"}}
[2026-01-06 16:50:26] local.INFO: Subscription resumed from scheduled cancellation {"subscription_id":234}
結果:
subscriptions.status->active。- 予約キャンセル履歴をクリア。
エラー処理:
// WebhookController.php
private function processStripeEvent(object $event, object $webhookEvent): void
{
try {
$this->logStripe("Stripeイベント処理中: {$event->type}");
$handler = $this->eventHandlers[$event->type];
$item = (object)$event->data->object;
$previousAttributes = isset($event->data->previous_attributes)
? (object)$event->data->previous_attributes
: null;
$this->{$handler}($item, $previousAttributes);
$webhookEvent->update(['status' => StripeWebhookEventStatus::Completed->value]);
} catch (\Throwable $th) {
$webhookEvent->update([
'status' => StripeWebhookEventStatus::Failed->value,
'error' => $th->getMessage()
]);
$this->logStripe("イベント処理エラー {$event->type}: " . $th->getMessage());
throw $th; // 500レスポンスとなり、Stripeの再試行をトリガー
}
}
データ整合性対策
1. 外部キー整合性
問題: packageToProvider->package_idが存在しないpackageを参照する可能性があります。
解決策: 常にリレーションシップチェーンを使用:
// 間違い
'package_id' => $packageToProvider->package_id,
// 正しい
$packagePlan = $packagePlanToProvider->packagePlan;
'package_id' => $packagePlan->package_id,
2. 重複履歴の防止
チェックメカニズム:
// 時間許容範囲内(±5秒)で既存の履歴をチェック
$periodStart = format_timestamp($session->current_period_start);
$periodStartCarbon = \Carbon\Carbon::parse($periodStart);
$existingHistory = $this->subscriptionHistoryRepository
->findWhere([
'subscription_id' => $subscription->id,
'type' => $type->value,
])
->filter(function($history) use ($periodStartCarbon) {
$historyStart = \Carbon\Carbon::parse($history->started_at);
return abs($historyStart->diffInSeconds($periodStartCarbon)) <= 5;
})
->first();
if ($existingHistory) {
return $existingHistory; // 作成をスキップ
}
なぜ5秒の許容範囲?
- Stripeはwebhookイベント間でタイムスタンプにわずかな差異がある場合があります
- インボイスの
period_startとサブスクリプションのcurrent_period_startは数秒異なる場合があります
3. Null安全性
すべての配列/オブジェクトアクセスは安全なアクセサーを使用します:
- ネストされた配列には
data_get() - オブジェクトプロパティにはnull合体演算子(
??) - 操作前の明示的なnullチェック
エラー処理とログ記録
ログレベル
Info: 通常の操作
$this->logStripe('サブスクリプションが正常に更新されました', [
'subscription_id' => $subscription->id
]);
Warning: 予期しないが回復可能な状況
$this->logStripeWarning('インボイスにbilling_reasonがありません', [
'invoice_id' => $session->id
]);
Error: 重大な失敗
$this->logStripeError('変更履歴の作成に失敗しました', [
'error' => $th->getMessage(),
'trace' => $th->getTraceAsString(),
]);
Slack通知
重大なエラーはSlackアラートも送信します:
$this->sendSlack('重大: サブスクリプションの有効化に失敗しました', $context, NOTIFICATION_ERROR);
テストとデバッグ
ローカルWebhookテスト
Stripe CLIを使用:
stripe listen --forward-to http://localhost/api/v1/admin/stripe/webhook
Postmanで手動テスト:
- ヘッダーを使用:
X-Test-Event: customer.subscription.updated - ローカル環境では署名検証がバイパスされます
Webhookログ
処理履歴はstripe_webhook_eventsテーブルで確認:
SELECT * FROM stripe_webhook_events
WHERE event_type = 'customer.subscription.updated'
ORDER BY created_at DESC
LIMIT 10;
アプリケーションログ
すべてのwebhook処理はstorage/logs/stripe-{date}.logに記録されます:
[2025-12-17 17:20:43] local.INFO: Stripeイベント処理中: customer.subscription.updated
[2025-12-17 17:20:43] local.INFO: サブスクリプションが正常に更新されました {"subscription_id":196}
ベストプラクティス
- 本番環境では常にwebhook署名を検証する
- データベースレベルのチェックを使用して冪等性を実装する
- 複数テーブル更新にはトランザクションを使用する
- コンテキスト付きで包括的にログを記録する
- null/欠損データを適切に処理する
- Stripeの再試行を防ぐため、200 OKを迅速に返す
- 操作が遅い場合は非同期でイベントを処理する(キューを使用)
- 失敗したイベントを監視し、再試行ロジックを実装する
- 競合状態を徹底的にテストする
- ハンドラーは単一責任に焦点を当てる
ドキュメントステータス: 完了 - 本番環境対応のwebhook処理システム