Stripe Integration Patterns: Subscriptions, Webhooks, and the Hardening You Need Before Launch
Billing bugs are the worst kind of production incident. They don't just cause downtime — they charge the wrong customer, silently skip renewals, or double-invoice an enterprise account the week before their annual review. By the time you notice, damage to trust is already done.
In my experience working with SaaS products across their early and growth stages, Stripe integration patterns are where I see the most recurring, avoidable mistakes. Not because Stripe is hard to use — their documentation is genuinely excellent — but because the test-happy path works so smoothly that teams ship to production before discovering the edge cases that only appear at scale or under failure conditions.
This post covers the production patterns that matter: webhook verification and replay safety, idempotency across subscription events, keeping your database in sync with Stripe's state, and a testing strategy that catches billing bugs before your first invoice goes wrong.
Why Stripe Integrations Break in Production
The Stripe Checkout or Payment Intents flow you wire up in a weekend typically works fine for the first few hundred customers. The breakage starts when you hit the combination of real-world conditions that your happy path never exercised:
- A webhook arrives twice (Stripe retries on any non-200 response, including your slow database writes)
- A customer's card is declined on renewal, then updated and retried, generating a cascade of subscription update events
- Your server restarts mid-payment and the job that should fulfill the order never runs
- A test key accidentally makes it to staging — or worse, a live key ends up in a test environment
- Your local subscription state drifts from Stripe's truth over weeks, and you only discover it when a customer disputes a charge
None of these are exotic failure modes. They're the normal texture of production billing. The patterns below exist specifically to handle them.
Webhook Verification: The Non-Negotiable First Step
Every Stripe webhook endpoint must verify the signature before processing anything. Stripe provides a signing secret per endpoint, and the SDK exposes a one-liner to validate it:
$payload = @file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'];
$secret = $_ENV['STRIPE_WEBHOOK_SECRET'];
try {
$event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $secret);
} catch (\Stripe\Exception\SignatureVerificationException $e) {
http_response_code(400);
exit();
}
The common mistake is reading the event from $_POST or parsing the JSON body yourself and passing it to constructEvent. This breaks signature validation because Stripe signs the raw payload, and any re-encoding changes the byte sequence. Always pass the raw input stream.
A second mistake: using a single webhook endpoint and secret for all environments. Create separate Stripe webhook endpoints for production and staging, with separate secrets stored in separate environment variables. This is the simplest way to prevent test events from triggering production fulfillment logic — or live events from polluting your test data.
Idempotency: Handle Every Event at Least Once, Process It Exactly Once
Stripe's event delivery guarantee is "at least once." Your processing guarantee needs to be "exactly once." The gap between those two requirements is where billing bugs live.
The solution is an idempotency layer: before processing any webhook event, check whether you've already handled it. Record the Stripe event ID in your database when processing begins, and skip (with a 200 response) any event whose ID already exists.
// In your webhook handler
$eventId = $event->id;
if ($this->eventRepository->exists($eventId)) {
// Already processed — acknowledge and exit
return new Response('', 200);
}
// Mark as in-progress before any side effects
$this->eventRepository->markReceived($eventId);
try {
$this->processEvent($event);
$this->eventRepository->markProcessed($eventId);
} catch (\Throwable $e) {
$this->eventRepository->markFailed($eventId, $e->getMessage());
// Re-throw or return 500 to trigger Stripe retry
throw $e;
}
This table — often called stripe_events or billing_events — becomes your audit log. It tells you exactly which events arrived, when, what happened during processing, and which ones failed and why. You'll want it the first time a customer emails to say their account wasn't activated after payment.
For mutation API calls (creating subscriptions, updating quantities, canceling), always pass an idempotency key. Stripe stores the response for 24 hours and returns the same result for duplicate requests with the same key:
$stripe->subscriptions->create([
'customer' => $customerId,
'items' => [['price' => $priceId]],
], [
'idempotency_key' => 'sub_create_' . $userId . '_' . $priceId,
]);
Subscription State Synchronisation
The second most common billing bug class is drift between your database's subscription state and Stripe's actual state. This happens when your webhook handler fails silently, when you migrate data incorrectly, or when a developer manipulates subscription records in Stripe's dashboard without a corresponding local update.
The rule to internalize: Stripe is the source of truth for subscription state. Your database is a cache.
That means your subscription status, current period end, plan, and cancellation state should always be derived from Stripe's data, not maintained independently. A minimal subscription record might look like this:
// What to store locally
[
'user_id' => $userId,
'stripe_customer_id' => $event->data->object->customer,
'stripe_subscription_id' => $event->data->object->id,
'status' => $event->data->object->status,
'current_period_end' => $event->data->object->current_period_end,
'cancel_at_period_end' => $event->data->object->cancel_at_period_end,
'synced_at' => new \DateTimeImmutable(),
]
Update this record on every relevant subscription event: customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.payment_succeeded, and invoice.payment_failed. These five events cover the vast majority of lifecycle changes.
For Symfony projects, services that gate feature access should query your local subscription record (fast), while a background job periodically fetches the live Stripe state and reconciles any differences (safe). More on that reconciliation job below.
Building a Reconciliation Job
Even with a well-hardened webhook handler, drift can occur. Stripe retries webhooks for up to 72 hours, but they can still fail if your server is down for an extended window, if a deployment introduces a processing bug, or if you run a migration that corrupts local state.
A nightly reconciliation job is your safety net. It fetches the list of active subscriptions from Stripe and compares them against your local records, flagging or auto-correcting discrepancies:
// Pseudo-code for a Symfony console command
$stripeSubscriptions = $this->stripe->subscriptions->all([
'status' => 'all',
'limit' => 100,
// Use auto-pagination for large catalogs
]);
foreach ($stripeSubscriptions->autoPagingIterator() as $stripeSub) {
$local = $this->subscriptionRepository->findByStripeId($stripeSub->id);
if (!$local) {
$this->logger->warning('Stripe subscription missing locally', [
'stripe_id' => $stripeSub->id,
]);
$this->subscriptionSyncService->syncFromStripe($stripeSub);
continue;
}
if ($local->status !== $stripeSub->status) {
$this->logger->warning('Subscription status mismatch', [
'stripe_id' => $stripeSub->id,
'local' => $local->status,
'stripe' => $stripeSub->status,
]);
$this->subscriptionSyncService->syncFromStripe($stripeSub);
}
}
Run this job nightly and emit the discrepancy count as a metric. A healthy system should show zero drift most of the time, with occasional single-event corrections. If you're seeing systematic drift, your webhook handler has a bug worth investigating.
Handling the Failed Payment Lifecycle
Subscription renewals fail. Cards expire, spending limits are hit, banks flag charges as suspicious. Stripe's dunning logic handles automatic retries, but your application needs to respond gracefully to the invoice.payment_failed event.
At minimum:
- Set the local subscription status to
past_duewheninvoice.payment_failedfires - Gate access appropriately (most SaaS products give a grace period of 7–14 days)
- Send a transactional email prompting the customer to update their payment method
When the customer updates their card and Stripe retries successfully, invoice.payment_succeeded fires and customer.subscription.updated brings the status back to active. Your webhook handler should restore access automatically — no manual intervention required.
The trap to avoid: using invoice.payment_failed to immediately cancel access. Stripe's smart retries mean the charge may succeed on the next automatic attempt. Cutting access on first failure generates unnecessary churn and support tickets from paying customers whose banks just had a temporary hiccup.
The Environment Hygiene Checklist
Before launch, verify each of these:
STRIPE_SECRET_KEYfor production starts withsk_live_; test environments usesk_test_STRIPE_WEBHOOK_SECRETis different for every environment (production, staging, local dev)- Webhook signing verification is enabled and tested — try sending a tampered payload and confirm it returns 400
- Idempotency keys are on every mutation API call
- The
stripe_eventstable (or equivalent) exists and is indexed onevent_id - A reconciliation job is scheduled and alerting on discrepancy counts
- Failed payment emails are tested against all three retry scenarios: immediate retry success, delayed retry success, final failure leading to cancellation
Testing Strategy
Stripe's test mode is comprehensive, but the most useful testing tool in production hardening is the Stripe CLI's stripe trigger command. It fires any event type against your local webhook endpoint with realistic payloads:
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
stripe trigger invoice.payment_succeeded
Combine this with a webhook event log (your stripe_events table) and you can verify end-to-end that the right side effects occur for every event type you handle.
For automated test suites, avoid mocking Stripe entirely. Instead, use Stripe's test clocks to simulate subscription lifecycles in accelerated time — creating a subscription, advancing the clock to the renewal date, triggering the renewal event, and asserting the correct database state. This is slower than unit tests but catches the class of bug that mocks systematically miss: the interaction between your event handler logic and your database's actual constraints.
If you're on Symfony, a dedicated KernelTestCase that fires the webhook controller directly with a pre-signed test payload (using your test webhook secret) gives you fast, realistic coverage without needing the Stripe CLI in your CI pipeline.
When to Reach for a Billing Library
If your billing model involves usage-based charges, tiered pricing, per-seat billing, or complex proration logic, consider whether a billing library (Lago, Orb, or m3ter) is the right layer rather than building on raw Stripe API calls. These tools handle metering, aggregation, and proration edge cases that are genuinely difficult to get right on top of Stripe's primitives.
For straightforward flat-rate or per-seat subscriptions, raw Stripe is usually sufficient — and adding a billing layer introduces its own integration surface. The patterns in this post apply regardless of which approach you take; webhooks, idempotency, and reconciliation are necessary at every level of the stack.
Getting This Right Before You Need To
Billing is one of those areas where the cost of getting it right upfront is much lower than the cost of fixing it in production. A missed renewal that a customer notices, a double charge that triggers a dispute, a free access window that persists for months because a cancellation event was silently dropped — these are recoverable problems, but they cost time, money, and customer trust that's hard to rebuild.
The patterns above aren't sophisticated. They're unglamorous plumbing: a database table, a nightly job, careful key management, and a test fixture or two. But that plumbing is what separates a billing integration that embarrasses you in year two from one that runs silently in the background while you focus on product.
If you're building a SaaS and want a second opinion on your billing architecture before launch — or need help cleaning up a Stripe integration that's already showing cracks — reach out at hello@wolf-tech.io or visit wolf-tech.io to see how we work.
Related reading:

