Skip to content
SaaS4Builders
Billing

Webhooks

Stripe webhook architecture in SaaS4Builders: signature verification, idempotency, event dispatching, handled events, and how to add new handlers.

Webhooks are the backbone of billing state synchronization. Stripe sends HTTP POST requests to your application whenever billing events occur — subscription created, invoice paid, payment failed, and more. Your application verifies, deduplicates, and dispatches these events to specialized handlers that update local state.


Architecture Overview

Every webhook follows a four-stage pipeline:

flowchart TD
    A["Stripe HTTP POST"] --> B["1. Signature Verification<br/>VerifyStripeWebhookSignature middleware<br/>Validate payload + header<br/>Returns 400 if invalid"]
    B --> C["2. Idempotency Check<br/>StripeWebhookController<br/>Skip if already processed<br/>Returns already_processed"]
    C --> D["3. Dispatch to Handlers<br/>WebhookDispatcher<br/>Route event to registered handlers by event type<br/>Fail-fast on error"]
    D --> E["4. Record Result<br/>Mark as processed / failed / ignored<br/>Update stripe_webhook_events"]

Webhook Route

The webhook endpoint is registered outside the standard authentication middleware — Stripe cannot send Bearer tokens:

POST /api/v1/webhooks/stripe

This route uses the VerifyStripeWebhookSignature middleware instead of Sanctum. The route is named webhooks.stripe.

Environment Configuration

backend/.env
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_signing_secret

You get the signing secret from the Stripe Dashboard (Developers > Webhooks > Signing secret).


Stage 1: Signature Verification

The VerifyStripeWebhookSignature middleware validates that the request genuinely came from Stripe:

backend/app/Http/Middleware/VerifyStripeWebhookSignature.php
final class VerifyStripeWebhookSignature
{
    public function handle(Request $request, Closure $next): Response
    {
        $payload = $request->getContent();
        $signature = $request->header('Stripe-Signature', '');

        try {
            $event = $this->stripeClient->constructWebhookEvent($payload, $signature);
            $request->attributes->set('stripe_event', $event);
        } catch (SignatureVerificationException) {
            return response()->json(['error' => 'Invalid signature'], 400);
        }

        return $next($request);
    }
}

The middleware:

  1. Reads the raw request body and the Stripe-Signature header
  2. Uses the Stripe SDK to verify the signature against your STRIPE_WEBHOOK_SECRET
  3. If valid, constructs a Stripe\Event object and attaches it to the request
  4. If invalid, returns a 400 response immediately — the controller never executes
Never skip signature verification, even in development. Use the Stripe CLI (stripe listen --forward-to) to forward test webhooks with valid signatures.

Stage 2: Idempotency

The controller checks whether the event has already been processed before dispatching it:

backend/app/Http/Controllers/Api/Webhooks/StripeWebhookController.php
final class StripeWebhookController extends Controller
{
    public function handle(Request $request): JsonResponse
    {
        $event = $request->attributes->get('stripe_event');

        // Skip if already processed
        $existing = StripeWebhookEvent::where('stripe_event_id', $event->id)
            ->where('status', WebhookEventStatus::Processed)
            ->first();

        if ($existing !== null) {
            return response()->json(['status' => 'already_processed']);
        }

        // Record the event
        $record = StripeWebhookEvent::updateOrCreate(
            ['stripe_event_id' => $event->id],
            [
                'type' => $event->type,
                'status' => WebhookEventStatus::Pending,
            ]
        );

        // Dispatch to handlers
        $this->dispatcher->dispatch($event, $record);

        return response()->json(['status' => $record->status->value]);
    }
}

The stripe_webhook_events table tracks every event by its Stripe event ID. The WebhookEventStatus enum defines four states:

backend/app/Domain/Billing/Enums/WebhookEventStatus.php
enum WebhookEventStatus: string
{
    case Pending = 'pending';       // Received, dispatching to handlers
    case Processed = 'processed';   // All handlers completed successfully
    case Failed = 'failed';         // A handler threw an exception
    case Ignored = 'ignored';       // No handler registered for this event type
}

This ensures that if Stripe retries a webhook (which it does for up to 72 hours on failure), the same event is never processed twice.


Stage 3: The WebhookDispatcher

The dispatcher routes events to their registered handlers:

backend/app/Infrastructure/Billing/Webhooks/WebhookDispatcher.php
final class WebhookDispatcher
{
    /** @var array<string, WebhookHandlerInterface[]> */
    private array $handlers = [];

    public function register(string $eventType, WebhookHandlerInterface $handler): self
    {
        $this->handlers[$eventType][] = $handler;
        return $this;
    }

    public function dispatch(Event $event, StripeWebhookEvent $record): void
    {
        $handlers = $this->handlers[$event->type] ?? [];

        if ($handlers === []) {
            $record->update([
                'status' => WebhookEventStatus::Ignored,
                'error' => 'no_handler',
            ]);
            return;
        }

        foreach ($handlers as $handler) {
            try {
                $handler->handle($event);
            } catch (\Throwable $e) {
                $record->update([
                    'status' => WebhookEventStatus::Failed,
                    'error' => $e->getMessage(),
                ]);
                throw $e;  // Re-throw so Stripe sees a 500 and retries
            }
        }

        $record->update([
            'status' => WebhookEventStatus::Processed,
            'processed_at' => now(),
        ]);
    }
}

Dispatch Strategy

  • Fail-fast: If any handler throws an exception, the event is marked as Failed and the exception is re-thrown. Stripe receives a 500 response and will retry.
  • Sequential execution: Multiple handlers for the same event type run in registration order. A failure stops the chain — subsequent handlers do not execute.
  • Unhandled events: Events with no registered handler are marked as Ignored. This is normal — Stripe sends many event types, and you only need to handle the ones relevant to your application.

Handled Events

All handlers implement the WebhookHandlerInterface:

backend/app/Infrastructure/Billing/Webhooks/Contracts/WebhookHandlerInterface.php
interface WebhookHandlerInterface
{
    public function handle(Event $event): void;
}

Handlers are registered in the WebhookServiceProvider:

backend/app/Providers/WebhookServiceProvider.php
final class WebhookServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $dispatcher = $this->app->make(WebhookDispatcher::class);

        $dispatcher->register('checkout.session.completed',
            $this->app->make(CheckoutSessionCompletedHandler::class));

        $dispatcher->register('customer.subscription.created',
            $this->app->make(SubscriptionCreatedHandler::class));

        $dispatcher->register('customer.subscription.updated',
            $this->app->make(SubscriptionUpdatedHandler::class));

        $dispatcher->register('customer.subscription.deleted',
            $this->app->make(SubscriptionDeletedHandler::class));

        $dispatcher->register('invoice.paid',
            $this->app->make(InvoicePaidHandler::class));

        $dispatcher->register('invoice.payment_failed',
            $this->app->make(InvoicePaymentFailedHandler::class));

        $dispatcher->register('invoice.created',
            $this->app->make(InvoiceCreatedHandler::class));

        $dispatcher->register('invoice.finalized',
            $this->app->make(InvoiceFinalizedHandler::class));
    }
}

Event Reference

Stripe EventHandlerWhat It Does
checkout.session.completedCheckoutSessionCompletedHandlerCreates local subscription record after Stripe Checkout completes. Syncs billing address (when tax enabled). Cancels previous free subscription if upgrading.
customer.subscription.createdSubscriptionCreatedHandlerSyncs subscription period dates and status from Stripe. Extracts first subscription item ID.
customer.subscription.updatedSubscriptionUpdatedHandlerSyncs status changes, period dates, trial end date, cancellation pending state, and quantity.
customer.subscription.deletedSubscriptionDeletedHandlerMarks subscription as canceled using Stripe's canceled_at timestamp. Dispatches SubscriptionCanceled event.
invoice.paidInvoicePaidHandlerCreates local invoice (if it doesn't exist) with all line items and tax records, then marks as paid.
invoice.payment_failedInvoicePaymentFailedHandlerDispatches PaymentFailed event for your notification system. Subscription status update is handled separately by customer.subscription.updated.
invoice.createdInvoiceCreatedHandlerUpdates usage snapshots with Stripe's invoiced amounts for metered line items. Closes the billing loop for usage-based pricing.
invoice.finalizedInvoiceFinalizedHandlerDispatches ReconcileUsageInvoiceJob to compare Stripe's invoiced amounts against local shadow calculations.

All handler files are located in backend/app/Infrastructure/Billing/Webhooks/Handlers/.


Subscription Webhook Details

CheckoutSessionCompletedHandler

This is the primary subscription creation path. When a user completes Stripe Checkout:

  1. Validates the checkout session mode is subscription
  2. Reads tenant_id and plan_id from session metadata
  3. Creates the local Subscription record with status active (default)
  4. Syncs the billing address from the checkout session (when STRIPE_TAX_ENABLED=true)
  5. Cancels any previous free subscription (upgrade scenario)
  6. Auto-completes onboarding if billing details are filled
  7. Dispatches SubscriptionCreated event

Idempotency: Skips if a subscription with the same stripe_subscription_id already exists.

SubscriptionUpdatedHandler

The most frequently called handler — Stripe sends this event for every subscription state change:

  • Status transitions (e.g., trialingactive, activepast_due)
  • Trial end date updates
  • Quantity changes (seat-based plans)
  • Cancellation scheduling (cancel_at timestamp detected)

The handler uses two shared traits:

  • MapsStripeStatus — Maps Stripe status strings to SubscriptionStatus enum values
  • ExtractsItemPeriod — Extracts billing period from the first subscription item (required since Stripe API version 2026-01-28, where period dates moved from subscription level to item level)

Invoice Webhook Details

InvoicePaidHandler

The primary invoice creation path in stripe_managed mode:

  1. Checks if a local invoice already exists for this stripe_invoice_id
  2. If yes: updates the existing invoice to paid status
  3. If no: creates a new local invoice via InvoicingStrategyInterface, mapping all Stripe line items to InvoiceLineDraft entries and tax breakdowns to TaxRecordDraft entries
  4. Syncs the payment charge (best-effort, non-blocking)

InvoiceCreatedHandler

Handles the billing loop for usage-based pricing:

  1. Finds the most recent unreported usage snapshot for each metered line item
  2. Matches Stripe's invoiced amounts to local meter data
  3. Updates snapshots with stripe_invoice_id and stripe_invoiced_amount_cents

This ensures that every usage period's billing is accounted for and can be reconciled.


Adding a New Webhook Handler

To handle a new Stripe event type, follow these three steps:

Step 1: Create the Handler

backend/app/Infrastructure/Billing/Webhooks/Handlers/YourNewHandler.php
<?php

declare(strict_types=1);

namespace App\Infrastructure\Billing\Webhooks\Handlers;

use App\Infrastructure\Billing\Webhooks\Contracts\WebhookHandlerInterface;
use Stripe\Event;

final class YourNewHandler implements WebhookHandlerInterface
{
    public function handle(Event $event): void
    {
        $data = $event->data->object;

        // Your processing logic here
        // Remember: this must be idempotent
    }
}

Step 2: Register in WebhookServiceProvider

backend/app/Providers/WebhookServiceProvider.php
$dispatcher->register(
    'your.event.type',
    $this->app->make(YourNewHandler::class)
);

Step 3: Enable in Stripe Dashboard

Go to Developers > Webhooks in the Stripe Dashboard and add your.event.type to the list of events sent to your webhook endpoint.

You can register multiple handlers for the same event type. They execute sequentially in registration order. This is useful when different subsystems need to react to the same event.

Stripe Dashboard Configuration

Required Events

Enable these events in your Stripe webhook configuration:

checkout.session.completed
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
invoice.paid
invoice.payment_failed
invoice.created
invoice.finalized

Webhook URL

Set your webhook endpoint URL in the Stripe Dashboard:

https://your-domain.com/api/v1/webhooks/stripe

For local development, use the Stripe CLI to forward events to your local server:

stripe listen --forward-to http://localhost:8000/api/v1/webhooks/stripe

The CLI will output a temporary signing secret — use it as STRIPE_WEBHOOK_SECRET in your .env.


Retry Safety

Stripe retries failed webhooks (HTTP 5xx or timeout) with exponential backoff for up to 72 hours. Your handlers must be idempotent — processing the same event twice must produce the same result.

The existing handlers achieve idempotency through several strategies:

StrategyUsed By
stripe_event_id deduplicationStripeWebhookController — skips entirely if event was already processed
stripe_subscription_id checkCheckoutSessionCompletedHandler — skips if subscription already exists
stripe_invoice_id upsertInvoicePaidHandler — updates existing invoice instead of creating duplicate
snapshot reference checkInvoiceCreatedHandler — skips if usage snapshot already references this invoice
When writing custom handlers, always check for existing records before creating new ones. Use unique external IDs (like stripe_*_id fields) as deduplication keys.

Monitoring Webhook Health

The stripe_webhook_events table provides a complete audit trail of all received webhooks. You can monitor webhook health by querying event statuses:

StatusMeaningAction
processedSuccessfully handledNone — working as expected
ignoredNo handler registeredNormal for events you don't need
failedHandler threw an exceptionCheck logs, fix the issue — Stripe will retry
pendingReceived but processing didn't completeMay indicate a crash during processing

Failed events are the most important to monitor. The error message is stored in the error column of the stripe_webhook_events table.


What's Next