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 -> SubscriptionLifeCycleHandler
  • invoice.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_idpayment_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}

ベストプラクティス

  1. 本番環境では常にwebhook署名を検証する
  2. データベースレベルのチェックを使用して冪等性を実装する
  3. 複数テーブル更新にはトランザクションを使用する
  4. コンテキスト付きで包括的にログを記録する
  5. null/欠損データを適切に処理する
  6. Stripeの再試行を防ぐため、200 OKを迅速に返す
  7. 操作が遅い場合は非同期でイベントを処理する(キューを使用)
  8. 失敗したイベントを監視し、再試行ロジックを実装する
  9. 競合状態を徹底的にテストする
  10. ハンドラーは単一責任に焦点を当てる

ドキュメントステータス: 完了 - 本番環境対応のwebhook処理システム