Webhooks
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:
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
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:
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:
- Reads the raw request body and the
Stripe-Signatureheader - Uses the Stripe SDK to verify the signature against your
STRIPE_WEBHOOK_SECRET - If valid, constructs a
Stripe\Eventobject and attaches it to the request - If invalid, returns a
400response immediately — the controller never executes
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:
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:
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:
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
Failedand 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:
interface WebhookHandlerInterface
{
public function handle(Event $event): void;
}
Handlers are registered in the WebhookServiceProvider:
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 Event | Handler | What It Does |
|---|---|---|
checkout.session.completed | CheckoutSessionCompletedHandler | Creates local subscription record after Stripe Checkout completes. Syncs billing address (when tax enabled). Cancels previous free subscription if upgrading. |
customer.subscription.created | SubscriptionCreatedHandler | Syncs subscription period dates and status from Stripe. Extracts first subscription item ID. |
customer.subscription.updated | SubscriptionUpdatedHandler | Syncs status changes, period dates, trial end date, cancellation pending state, and quantity. |
customer.subscription.deleted | SubscriptionDeletedHandler | Marks subscription as canceled using Stripe's canceled_at timestamp. Dispatches SubscriptionCanceled event. |
invoice.paid | InvoicePaidHandler | Creates local invoice (if it doesn't exist) with all line items and tax records, then marks as paid. |
invoice.payment_failed | InvoicePaymentFailedHandler | Dispatches PaymentFailed event for your notification system. Subscription status update is handled separately by customer.subscription.updated. |
invoice.created | InvoiceCreatedHandler | Updates usage snapshots with Stripe's invoiced amounts for metered line items. Closes the billing loop for usage-based pricing. |
invoice.finalized | InvoiceFinalizedHandler | Dispatches 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:
- Validates the checkout session mode is
subscription - Reads
tenant_idandplan_idfrom session metadata - Creates the local
Subscriptionrecord with statusactive(default) - Syncs the billing address from the checkout session (when
STRIPE_TAX_ENABLED=true) - Cancels any previous free subscription (upgrade scenario)
- Auto-completes onboarding if billing details are filled
- Dispatches
SubscriptionCreatedevent
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.,
trialing→active,active→past_due) - Trial end date updates
- Quantity changes (seat-based plans)
- Cancellation scheduling (
cancel_attimestamp detected)
The handler uses two shared traits:
MapsStripeStatus— Maps Stripe status strings toSubscriptionStatusenum valuesExtractsItemPeriod— 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:
- Checks if a local invoice already exists for this
stripe_invoice_id - If yes: updates the existing invoice to
paidstatus - If no: creates a new local invoice via
InvoicingStrategyInterface, mapping all Stripe line items toInvoiceLineDraftentries and tax breakdowns toTaxRecordDraftentries - Syncs the payment charge (best-effort, non-blocking)
InvoiceCreatedHandler
Handles the billing loop for usage-based pricing:
- Finds the most recent unreported usage snapshot for each metered line item
- Matches Stripe's invoiced amounts to local meter data
- Updates snapshots with
stripe_invoice_idandstripe_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
<?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
$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.
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:
| Strategy | Used By |
|---|---|
| stripe_event_id deduplication | StripeWebhookController — skips entirely if event was already processed |
| stripe_subscription_id check | CheckoutSessionCompletedHandler — skips if subscription already exists |
| stripe_invoice_id upsert | InvoicePaidHandler — updates existing invoice instead of creating duplicate |
| snapshot reference check | InvoiceCreatedHandler — skips if usage snapshot already references this invoice |
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:
| Status | Meaning | Action |
|---|---|---|
processed | Successfully handled | None — working as expected |
ignored | No handler registered | Normal for events you don't need |
failed | Handler threw an exception | Check logs, fix the issue — Stripe will retry |
pending | Received but processing didn't complete | May 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
- Subscriptions & Lifecycle — How webhook events drive subscription state transitions
- Invoices — How invoice data is synced from Stripe via webhooks
- Stripe Integration — Stripe setup and the provider abstraction layer
- Currency Rules — Multi-currency invariants enforced across all webhook handlers
Tax Configuration
Stripe Tax integration in SaaS4Builders: automatic tax calculation, VAT validation, reverse charge detection, and the TaxProvider abstraction.
Currency Rules
Multi-currency architecture in SaaS4Builders: the Money value object, currency invariants, zero-decimal handling, resolution chain, and domain enforcement.