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 processed
  • processing → Currently being processed
  • completed → Successfully processed
  • failed → 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 -> SubscriptionLifeCycleHandler
  • invoice.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, has invoice_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, no invoice_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:

  1. Extract subscription slug from webhook metadata
  2. Find corresponding unpaid subscription in database
  3. Lock record for update (prevent race conditions)
  4. Update subscription to active status
  5. Update history with payment details
  6. 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_start vs Subscription current_period_start can 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

  1. Always verify webhook signatures in production
  2. Implement idempotency using database-level checks
  3. Use transactions for multi-table updates
  4. Log comprehensively with context
  5. Handle null/missing data gracefully
  6. Return 200 OK quickly to prevent Stripe retries
  7. Process events asynchronously if operations are slow (use queues)
  8. Monitor failed events and implement retry logic
  9. Test race conditions thoroughly
  10. Keep handlers focused on single responsibility

Document Status: Complete - Production-ready webhook handling system