Stripe Webhook Events Reference

Description

This document provides a comprehensive reference for all Stripe webhook events handled by the system, including their purpose, data structure, processing logic, and expected outcomes. Use this as a quick reference guide when debugging or extending webhook functionality.

Event Processing Overview

graph TD
    A[Stripe Event] --> B{Event Type}
    
    B -->|checkout.session.*| C[SubscriptionSessionHandler]
    B -->|customer.subscription.*| D[SubscriptionLifeCycleHandler]
    B -->|invoice.*| E[SubscriptionInvoiceHandler]
    B -->|payment_intent.succeeded| F[PaymentIntentHandler]
    
    C --> G[(Database)]
    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 Events

checkout.session.completed

Purpose: Activate a new subscription after successful payment via Stripe Checkout.

When Triggered:

  • User completes payment in Stripe Checkout
  • For both paid and free plans

Handler: SubscriptionSessionHandler::handleSessionCompleted()

Key Data Fields:

{
  "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"
}

Processing Logic:

  1. Find local subscription by metadata.subscription_slug
  2. Verify subscription status is unpaid
  3. Lock subscription record
  4. Update subscription:
    • status: unpaidactive
    • payment_provider_subscription_id: Stripe subscription ID
    • deadline_at: Period end from Stripe
  5. Update subscription_histories:
    • payment_status: pendingpaid
    • invoice_id, payment_intent_id, paid_at

Expected Outcome:

  • Subscription activated
  • User gains access to service
  • History record marked as paid

Related Events:

  • invoice.paid (ignored for new subscriptions to prevent duplicate processing)

checkout.session.async_payment_succeeded

Purpose: Handle successful async payment completion (e.g., bank transfers).

When Triggered:

  • Async payment method completes successfully after initial checkout

Handler: SubscriptionSessionHandler::handleSessionCompleted() (same as above)

Note: Rare event, but handled identically to checkout.session.completed.


Subscription Lifecycle Events

customer.subscription.created

Purpose: (Currently ignored) - Subscription creation is handled via checkout.session.completed.

Why Ignored:

  • Prevents duplicate processing
  • Checkout session provides more complete data
  • Subscription is created before payment completes

customer.subscription.updated

Purpose: Handle all subscription modification events (plan changes, cancellations, status changes).

When Triggered:

  • Plan change (upgrade/downgrade)
  • Cancellation scheduled
  • Cancellation resumed
  • Status change (active → past_due, etc.)
  • Period renewal
  • Pause/unpause subscription

Handler: SubscriptionLifeCycleHandler::handleSubscriptionUpdated()

Key Data Fields:

{
  "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": { /* old plan */ },
    "cancel_at_period_end": true
  }
}

Processing Logic (Decision Tree):

1. Is incomplete_expired? → handleIncompleteExpired()
2. Is free activation? → handleFreeSubscriptionActivation()
3. Is scheduled cancellation? → handleScheduledCancellation()
4. Is past_due? → handlePastDue()
5. Is paused? → handleSubscriptionPause()
6. Is resumed from pause? → handleSubscriptionResumeFromPause()
7. Is resumed from cancellation? → handleSubscriptionResume()
8. Is scheduled plan change applied? → handleScheduledPlanChangeApplication()
9. Is plan changed? → handlePlanChange()
   - Immediate change? → handleImmediatePlanChange()
   - Scheduled change? → handleScheduledPlanChange()
10. Is auto renewal? → handleAutoRenewal()
11. Is billing cycle reset? → handleBillingCycleReset()
12. Default: handleRegularUpdate()

Scenarios:

Scenario Detecting Criteria Action
Scheduled Cancellation cancel_at_period_end: true Update status to pending_cancellation, create cancellation history
Cancel Resumed previous: cancel_at_period_end=true, current: false Revert to active, delete cancellation history
Immediate Plan Change Plan ID changed + period start is recent (<2 min) Create change history, update package_plan_id
Scheduled Plan Change Applied scheduled_plan_id matches current plan + period changed Finalize change history
Auto Renewal Period changed, plan unchanged, status=active Create renewal history (pending, finalized by invoice.paid)
Past Due status: 'past_due' Update status, extend grace period

Expected Outcomes:

  • Subscription status updated in database
  • History record created or updated
  • User access adjusted accordingly

customer.subscription.deleted

Purpose: Handle final subscription cancellation.

When Triggered:

  • Subscription canceled immediately
  • Scheduled cancellation period ends
  • Payment failures exhaust all retries

Handler: SubscriptionLifeCycleHandler::handleSubscriptionDeleted()

Key Data Fields:

{
  "id": "sub_...",
  "object": "subscription",
  "status": "canceled",
  "canceled_at": 1702857600,
  "current_period_end": 1705536000,
  "cancellation_details": {
    "reason": "cancellation_requested",
    "comment": "User requested cancellation",
    "feedback": "too_expensive"
  }
}

Processing Logic:

  1. Find local subscription by provider_subscription_id
  2. Update subscription:
    • status: → canceled
    • canceled_at: Timestamp from Stripe
    • canceled_reason: From cancellation_details
  3. Create immediate_cancellation history record
  4. Clear pending invoice items (if any)

Expected Outcome:

  • Subscription canceled in database
  • User access terminated (or continues until grace_period_end_at)
  • Cancellation history created

Invoice Events

invoice.paid

Purpose: Process successful invoice payments (renewals, plan changes).

When Triggered:

  • Recurring payment succeeds
  • Plan change payment succeeds
  • Initial subscription payment succeeds (ignored, handled by checkout.session.completed)

Handler: SubscriptionInvoiceHandler::handleInvoicePaid()

Key Data Fields:

{
  "id": "in_...",
  "object": "invoice",
  "subscription": "sub_...",
  "billing_reason": "subscription_cycle", // or "subscription_update", "subscription_create"
  "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
    }]
  }
}

Processing Logic (Based on billing_reason):

1. subscription_create:

  • Ignored - Handled by checkout.session.completed instead
  • Prevents duplicate history creation

2. subscription_cycle (Auto Renewal):

  • Find subscription by provider_subscription_id
  • Create new history record:
    • type: renewal
    • payment_status: paid
    • invoice_id, payment_intent_id, paid_at
  • Update subscription deadline_at

3. subscription_update (Plan Change):

  • Check for pending change history
  • If found (normal flow):
    • Update history with payment details
    • Set payment_status: paid
  • If NOT found (race condition - invoice.paid arrived first):
    • Call createChangeHistoryFromInvoice()
    • Extract old_plan_id from credit line (amount < 0)
    • Extract new_plan_id from charge line (amount > 0) or Stripe API
    • Create complete history record

Expected Outcomes:

  • Renewal: New history period created, deadline extended
  • Plan Change: Payment recorded, change finalized

invoice.payment_failed

Purpose: Handle failed recurring payments.

When Triggered:

  • Automatic payment fails
  • Insufficient funds, expired card, etc.

Handler: SubscriptionInvoiceHandler::handleInvoicePaymentFailed()

Key Data Fields:

{
  "id": "in_...",
  "object": "invoice",
  "subscription": "sub_...",
  "billing_reason": "subscription_cycle",
  "attempt_count": 1,
  "amount_due": 1000,
  "next_payment_attempt": 1703030400
}

Processing Logic:

  1. Find subscription by provider_subscription_id
  2. Find latest history record
  3. If latest history is paid (first failure):
    • Create new history:
      • type: renewal
      • payment_status: failed
      • payment_attempt: 1
  4. If latest history is failed (retry failure):
    • Update history:
      • payment_attempt: + 1

Note:

  • Subscription status change (activepast_duecanceled) is handled by customer.subscription.updated / deleted events
  • This event only tracks payment attempts

Expected Outcome:

  • Failed payment logged
  • Payment attempt count incremented
  • Awaits Stripe's retry logic or final status change

invoice.payment_action_required

Purpose: (Not currently handled) - Could be used for 3D Secure challenges.

Future Enhancement: Notify user to complete authentication.


Payment Intent Events

payment_intent.succeeded

Purpose: Update history records with payment intent ID for tracking.

When Triggered:

  • Any payment successfully processed

Handler: PaymentIntentHandler::handlePaymentIntentSucceeded()

Key Data Fields:

{
  "id": "pi_...",
  "object": "payment_intent",
  "invoice": "in_...",
  "amount": 1000,
  "status": "succeeded"
}

Processing Logic:

  1. Find history by invoice_id where payment_intent_id IS NULL
  2. Update history: payment_intent_id = pi_...

Note: Mostly for data completeness, as invoice.paid already populates most payment info.


Event Handling Patterns

Pattern 1: Idempotency

All events are checked against stripe_webhook_events table:

if ($webhookEvent->status === 'completed') {
    return '200 OK - Already processed';
}
if ($webhookEvent->status === 'processing') {
    return '200 OK - Currently processing';
}
// Only process if new or failed

Pattern 2: Race Condition Handling

Plan Change Events can arrive in either order:

Scenario A:
1. customer.subscription.updated → Create pending history
2. invoice.paid → Finalize history with payment

Scenario B:
1. invoice.paid → Create complete history from invoice data
2. customer.subscription.updated → Detect existing history, skip creation

Implementation:

  • Check for existing history by subscription_id + type + started_at (±5s tolerance)
  • If found, update it; if not, create from available data

Pattern 3: Transaction Safety

All database modifications are wrapped in transactions:

return $this->wrapTransaction(function () use (...) {
    // All DB operations atomic
    $this->updateSubscription(...);
    $this->createHistory(...);
});

Pattern 4: Null Safety

All webhook data access uses safe methods:

// Good
$planId = data_get($session, 'items.data.0.plan.id');
$billingReason = $session->billing_reason ?? null;

// Bad
$planId = $session->items['data'][0]['plan']['id']; // May crash

Debugging Webhook Issues

Check Processing Status

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;

Find Failed Events

SELECT * FROM stripe_webhook_events
WHERE status = 'failed'
ORDER BY created_at DESC;

Verify History Creation

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;

Logs to Check

  • storage/logs/stripe-{date}.log - All webhook processing
  • storage/logs/laravel.log - General application errors

Event Flow Examples

Example 1: New Paid Subscription

1. checkout.session.completed
   → SubscriptionSessionHandler
   → Update subscription (unpaid → active)
   → Update history (pending → paid)
   
2. invoice.paid (billing_reason: subscription_create)
   → SubscriptionInvoiceHandler
   → Ignored (already handled by checkout)

Example 2: Auto Renewal Success

1. invoice.paid (billing_reason: subscription_cycle)
   → SubscriptionInvoiceHandler
   → Create new history (renewal, paid)
   → Update subscription deadline
   
2. customer.subscription.updated
   → SubscriptionLifeCycleHandler
   → Detect auto renewal
   → Check for existing history (found from invoice.paid)
   → Skip duplicate creation

Example 3: Immediate Plan Upgrade

1. customer.subscription.updated
   → SubscriptionLifeCycleHandler
   → Detect immediate plan change
   → Create history (type: change, payment_status: pending)
   → Update subscription package_plan_id
   
2. invoice.paid (billing_reason: subscription_update)
   → SubscriptionInvoiceHandler
   → Find pending history
   → Update with payment details (paid, invoice_id, amount)

Example 4: Downgrade to Free (Race Condition)

1. invoice.paid (billing_reason: subscription_update, amount: 0)
   → SubscriptionInvoiceHandler
   → No pending history found (race condition!)
   → Create history from invoice:
      - Extract old_plan_id from credit line
      - Fetch new_plan from Stripe API
      - payment_status: n/a (free plan)
   
2. customer.subscription.updated
   → SubscriptionLifeCycleHandler
   → Detect plan change
   → Find existing history (created by invoice.paid)
   → Skip creation, only update subscription record

Quick Reference Table

Event Billing Reason Handler Method Creates History? Updates Subscription?
checkout.session.completed N/A handleSessionCompleted() No (updates existing) Yes
invoice.paid subscription_create (ignored) No No
invoice.paid subscription_cycle handleAutoRenewalInvoice() Yes (renewal) Yes (deadline)
invoice.paid subscription_update handlePlanChangeInvoice() Yes/Update (change) Yes (deadline)
invoice.payment_failed Any handleInvoicePaymentFailed() Yes/Update (failed) No
customer.subscription.updated N/A handleSubscriptionUpdated() Varies Yes
customer.subscription.deleted N/A handleSubscriptionDeleted() Yes (cancellation) Yes
payment_intent.succeeded N/A handlePaymentIntentSucceeded() No (updates existing) No

Document Status: Complete reference for all webhook events