Stripe Webhook Handling Details
Description
This document provides comprehensive details on how the system processes webhooks from Stripe, ensuring idempotency, data consistency, and proper error handling. It covers the architecture, webhook verification, event routing, and best practices implemented in the codebase.
The webhook system is the backbone of subscription management, as all critical state changes originate from Stripe and are communicated through these events. Proper handling is essential for maintaining synchronization between Stripe and the local database.
Webhook Architecture
---
config:
theme: base
layout: dagre
flowchart:
curve: linear
htmlLabels: true
themeVariables:
edgeLabelBackground: "transparent"
---
flowchart TD
%% == NODES ==
Stripe((Stripe API))
subgraph WebhookLayer["Webhook Controller Layer"]
WebhookController[WebhookController]
Verification{Signature<br/>Verification}
Idempotency{Idempotency<br/>Check}
Router{Event<br/>Router}
end
subgraph HandlerLayer["Event Handler Layer"]
SessionHandler[SubscriptionSessionHandler]
LifecycleHandler[SubscriptionLifeCycleHandler]
InvoiceHandler[SubscriptionInvoiceHandler]
PaymentIntentHandler[PaymentIntentHandler]
end
subgraph Database["Database Layer"]
WebhookEventsDB[(stripe_webhook_events)]
SubscriptionDB[(subscriptions)]
HistoryDB[(subscription_histories)]
end
%% == CONNECTIONS ==
Stripe -->|1. Send Event| WebhookController
WebhookController --> Verification
Verification -->|Valid| Idempotency
Verification -->|Invalid| Error403[403 Forbidden]
Idempotency -->|Check| WebhookEventsDB
WebhookEventsDB -->|Status| Idempotency
Idempotency -->|New/Failed| Router
Idempotency -->|Completed/Processing| 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[Update Event Status]
LifecycleHandler --> UpdateWebhook
InvoiceHandler --> UpdateWebhook
PaymentIntentHandler --> UpdateWebhook
UpdateWebhook --> WebhookEventsDB
UpdateWebhook --> FinalResponse[200 OK]
%% == STYLING ==
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 Event Processing Flow
1. Signature Verification
Purpose: Ensure the webhook request genuinely comes from Stripe and hasn't been tampered with.
Implementation:
// 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('Missing signature or webhook secret');
}
// Stripe SDK verifies HMAC signature
return Webhook::constructEvent($payload, $sigHeader, $webhookSecret);
}
Error Handling:
- Missing signature →
403 Forbidden - Invalid signature →
403 Forbidden(prevents replay attacks) - Malformed payload →
400 Bad Request
2. Idempotency Check
Purpose: Prevent processing the same event multiple times (Stripe may retry webhooks).
Implementation:
// 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]
);
}
Database Schema:
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;
Status Transition:
pending→ Event created, not yet processedprocessing→ Currently being processedcompleted→ Successfully processedfailed→ Processing failed, will be retried
3. Event Routing
Event Types Handled:
| Event Type | Handler | Purpose |
|---|---|---|
checkout.session.completed |
SubscriptionSessionHandler | Activate new subscriptions after payment |
checkout.session.async_payment_succeeded |
SubscriptionSessionHandler | Handle async payment completion |
customer.subscription.created |
(Ignored) | Handled via checkout.session.completed |
customer.subscription.updated |
SubscriptionLifeCycleHandler | Plan changes, cancellations, renewals |
customer.subscription.deleted |
SubscriptionLifeCycleHandler | Final cancellation |
invoice.paid |
SubscriptionInvoiceHandler | Payment success (renewals, plan changes) |
invoice.payment_failed |
SubscriptionInvoiceHandler | Payment failure handling |
payment_intent.succeeded |
PaymentIntentHandler | Update history with payment intent ID |
Routing Logic:
// 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. Handler Execution
Transaction Safety: All handlers wrap database operations in transactions:
// Example from SubscriptionLifeCycleHandler
private function wrapTransaction($callback)
{
return $this->subscriptionRepository->transaction($callback);
}
public function handleSubscriptionUpdated($session, $previousAttributes)
{
return $this->wrapTransaction(function () use ($session, $previousAttributes) {
// All DB operations here are atomic
$this->updateSubscription(...);
$this->createHistory(...);
});
}
Real Log Examples (stripe-2026-01-06.log)
The examples below are extracted from stripe-2026-01-06.log to illustrate common cases. Logs only show key fields (IDs, amount, invoice status). The actual interval (daily/monthly/yearly) is derived from price.recurring.interval in Stripe payloads.
1) Upgrade (Free -> Basic) — Immediate change with paid invoice
Main flow:
customer.subscription.updated->SubscriptionLifeCycleHandlerinvoice.paid+payment_intent.succeeded->SubscriptionInvoiceHandler
Log snippets:
[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"}
Result:
subscription_histories: type =change, payment_status =paid, hasinvoice_id,payment_intent_id.
2) Downgrade (Paid -> Free) — Immediate change without invoice
Main flow:
customer.subscription.updated->SubscriptionLifeCycleHandler
Log snippets:
[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"}
Result:
subscription_histories: type =change, payment_status =n/a, noinvoice_id.
3) Plan change by interval (daily/monthly/yearly)
Multiple immediate plan changes appear in logs with different price IDs; interval is read from 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"}
Result:
subscription_histories: type =change, payment_status =paid, interval =price.recurring.interval.
4) Scheduled cancellation
Main flow:
customer.subscription.updated->SubscriptionLifeCycleHandler
Log snippets:
[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]}
Result:
subscriptions.status->pending_cancellation.- Pending histories are invalidated.
5) Resume after scheduled cancellation
Main flow:
customer.subscription.updated->SubscriptionLifeCycleHandler(resume)
Log snippets:
[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}
Result:
subscriptions.status->active.- Scheduled cancellation entries are cleared.
Error Handling:
// WebhookController.php
private function processStripeEvent(object $event, object $webhookEvent): void
{
try {
$this->logStripe("Processing Stripe event: {$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("Error processing event {$event->type}: " . $th->getMessage());
throw $th; // Will result in 500 response, triggering Stripe retry
}
}
Specific Handler Details
SubscriptionSessionHandler
Purpose: Process checkout session completion for new subscriptions.
Key Event: checkout.session.completed
Process:
- Extract subscription slug from webhook metadata
- Find corresponding
unpaidsubscription in database - Lock record for update (prevent race conditions)
- Update subscription to
activestatus - Update history with payment details
- Populate Stripe subscription ID
Null Safety:
// Safely access invoice line items
$invoiceLineItem = data_get($stripeInvoice, 'lines.data.0');
if (!$invoiceLineItem) {
$this->logStripeError('No line items found in invoice', [...]);
return null;
}
SubscriptionLifeCycleHandler
Purpose: Handle all subscription lifecycle events (plan changes, cancellations, renewals).
Key Events: customer.subscription.updated, customer.subscription.deleted
Detection Logic: The handler implements a priority-based decision tree to determine the correct action:
public function handleSubscriptionUpdated($session, $previousAttributes)
{
// Priority 1: Incomplete expired
if ($this->isIncompleteExpired($session)) {
return $this->handleIncompleteExpired(...);
}
// Priority 2: Free subscription activation
if ($this->isFreeSubscriptionActivation(...)) {
return $this->handleFreeSubscriptionActivation(...);
}
// Priority 3: Scheduled cancellation
if ($this->isScheduledCancellation($session)) {
return $this->handleScheduledCancellation(...);
}
// Priority 4: Past due
if ($this->isPastDue($session)) {
return $this->handlePastDue(...);
}
// Priority 5: Subscription paused/resumed
if ($this->isSubscriptionPaused(...)) { ... }
if ($this->isSubscriptionResumed(...)) { ... }
// Priority 6: Plan change
if ($this->isPlanChanged($previousAttributes, $session)) {
return $this->handlePlanChange(...);
}
// Priority 7: Auto renewal
if ($this->isAutoRenewal(...)) {
return $this->handleAutoRenewal(...);
}
// Default: Regular update
return $this->handleRegularUpdate(...);
}
Null Safety:
// Safe access to session fields
$this->logStripe('Handling subscription updated', [
'subscription_id' => $session->id ?? null,
'status' => $session->status ?? null,
'current_period_start' => $session->current_period_start ?? null,
'current_period_end' => $session->current_period_end ?? null,
]);
// Safe access to plan data
$planData = data_get($session, 'items.data.0.plan');
if (!$planData) {
$this->logStripeError('Plan data not found in session', [...]);
return null;
}
SubscriptionInvoiceHandler
Purpose: Process invoice-related events (payments, failures).
Key Events: invoice.paid, invoice.payment_failed
Billing Reason Handling:
public function handleInvoicePaid($session)
{
$billingReason = $session->billing_reason ?? null;
if (!$billingReason) {
$this->logStripeWarning('Invoice paid without billing_reason', [...]);
return;
}
switch ($billingReason) {
case self::SUBSCRIPTION_CREATE:
return $this->handleNewSubscriptionInvoice($session);
case self::SUBSCRIPTION_CYCLE:
return $this->handleAutoRenewalInvoice($session);
case self::SUBSCRIPTION_UPDATE:
return $this->handlePlanChangeInvoice($session);
default:
$this->logStripeWarning('Unknown billing reason', [...]);
return;
}
}
Race Condition Handling:
private function handlePlanChangeInvoice($session)
{
// Try to find pending change history
$latestHistory = $this->findPendingChangeHistory(...);
if (!$latestHistory) {
// Race condition: invoice.paid arrived before subscription.updated
// Create the change history record now based on invoice data
$latestHistory = $this->createChangeHistoryFromInvoice($subscription, $session);
} else {
// Normal flow: Update existing pending history with payment details
$this->updateHistoryWithPaymentDetails(...);
}
}
Creating History from Invoice Data:
private function createChangeHistoryFromInvoice($subscription, $session): ?object
{
// Case 1: Upgrade or paid downgrade (has charge line)
$newPlanLine = collect($session->lines['data'])->firstWhere('amount', '>', 0);
$oldPlanLine = collect($session->lines['data'])->firstWhere('amount', '<', 0);
if ($newPlanLine) {
// Safe accessor: use data_get() for nested array/object access
$newPlan = data_get($newPlanLine, 'plan') ?? data_get($newPlanLine, 'price');
$oldPlanId = $oldPlanLine ? data_get($oldPlanLine, 'plan.id') : null;
}
// Case 2: Downgrade to free plan (only credit line)
else if ($oldPlanLine && $session->amount_due == 0) {
// Safe accessor: use data_get() for nested array/object access
$oldPlan = data_get($oldPlanLine, 'plan') ?? data_get($oldPlanLine, 'price');
$oldPlanId = data_get($oldPlan, 'id');
// Get new plan from Stripe subscription API
$stripeSubscription = $this->subscriptionStripeService->retrieveSubscription($session->subscription);
// Safe accessor: check if items exist and have data before accessing
$newPlan = data_get($stripeSubscription, 'items.data.0.plan');
}
// Case 3: Cannot determine
else {
$this->logStripeError('Cannot determine plan change from invoice', [...]);
return null;
}
// Create history with all available data
return $this->subscriptionHistoryRepository->create($historyData);
}
Data Integrity Measures
1. Foreign Key Integrity
Problem: packageToProvider->package_id may reference non-existent package.
Solution: Always use relationship chain:
// WRONG
'package_id' => $packageToProvider->package_id,
// CORRECT
$packagePlan = $packagePlanToProvider->packagePlan;
'package_id' => $packagePlan->package_id,
2. Duplicate History Prevention
Check Mechanism:
// Check for existing history within time tolerance (±5 seconds)
$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; // Skip creation
}
Why 5-second tolerance?
- Stripe may have slight timestamp differences between webhook events
- Invoice
period_startvs Subscriptioncurrent_period_startcan differ by a few seconds
3. Null Safety
All array/object access uses safe accessors:
data_get()for nested arrays- Null coalescing (
??) for object properties - Explicit null checks before operations
Error Handling & Logging
Logging Levels
Info: Normal operations
$this->logStripe('Subscription updated successfully', [
'subscription_id' => $subscription->id
]);
Warning: Unexpected but recoverable situations
$this->logStripeWarning('No billing_reason in invoice', [
'invoice_id' => $session->id
]);
Error: Critical failures
$this->logStripeError('Failed to create change history', [
'error' => $th->getMessage(),
'trace' => $th->getTraceAsString(),
]);
Slack Notifications
Critical errors also send Slack alerts:
$this->sendSlack('Critical: Subscription activation failed', $context, NOTIFICATION_ERROR);
Testing & Debugging
Local Webhook Testing
Using Stripe CLI:
stripe listen --forward-to http://localhost/api/v1/admin/stripe/webhook
Manual Postman Testing:
- Use header:
X-Test-Event: customer.subscription.updated - Signature verification is bypassed in local environment
Webhook Logs
Check stripe_webhook_events table for processing history:
SELECT * FROM stripe_webhook_events
WHERE event_type = 'customer.subscription.updated'
ORDER BY created_at DESC
LIMIT 10;
Application Logs
All webhook processing is logged to storage/logs/stripe-{date}.log:
[2025-12-17 17:20:43] local.INFO: Processing Stripe event: customer.subscription.updated
[2025-12-17 17:20:43] local.INFO: Subscription updated successfully {"subscription_id":196}
Best Practices
- Always verify webhook signatures in production
- Implement idempotency using database-level checks
- Use transactions for multi-table updates
- Log comprehensively with context
- Handle null/missing data gracefully
- Return 200 OK quickly to prevent Stripe retries
- Process events asynchronously if operations are slow (use queues)
- Monitor failed events and implement retry logic
- Test race conditions thoroughly
- Keep handlers focused on single responsibility
Document Status: Complete - Production-ready webhook handling system