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:
- Find local subscription by
metadata.subscription_slug - Verify subscription status is
unpaid - Lock subscription record
- Update subscription:
status:unpaid→activepayment_provider_subscription_id: Stripe subscription IDdeadline_at: Period end from Stripe
- Update subscription_histories:
payment_status:pending→paidinvoice_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:
- Find local subscription by
provider_subscription_id - Update subscription:
status: →canceledcanceled_at: Timestamp from Stripecanceled_reason: From cancellation_details
- Create
immediate_cancellationhistory record - 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.completedinstead - Prevents duplicate history creation
2. subscription_cycle (Auto Renewal):
- Find subscription by
provider_subscription_id - Create new history record:
type:renewalpayment_status:paidinvoice_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_idfrom credit line (amount < 0) - Extract
new_plan_idfrom charge line (amount > 0) or Stripe API - Create complete history record
- Call
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:
- Find subscription by
provider_subscription_id - Find latest history record
- If latest history is
paid(first failure):- Create new history:
type:renewalpayment_status:failedpayment_attempt: 1
- Create new history:
- If latest history is
failed(retry failure):- Update history:
payment_attempt: + 1
- Update history:
Note:
- Subscription status change (
active→past_due→canceled) is handled bycustomer.subscription.updated/deletedevents - 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:
- Find history by
invoice_idwherepayment_intent_id IS NULL - 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 processingstorage/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