Stripe Integration Patterns: Subscriptions, Webhooks, and the Hardening You Need Before Launch
Every SaaS ships a broken Stripe integration at some point. It is usually not the happy path that breaks — it is the edge case nobody anticipated: a webhook arriving twice, a subscription update that never synced to the database, a test-mode key that made it past staging and into production. Billing bugs are uniquely painful because they show up in invoices, in customer inboxes, and in support queues rather than in error logs.
This post covers the Stripe integration patterns that prevent billing incidents in production. They are not complicated, but they require deliberate implementation that most early-stage tutorials skip.
Webhook reliability is the foundation
Stripe communicates asynchronous events — payment succeeded, subscription upgraded, invoice paid — through webhooks. If your webhook handling is fragile, your billing state will drift from Stripe's state, and drift is how customers get double-charged or lose access to features they paid for.
The first requirement is signature verification. Every incoming webhook request must be validated against the Stripe-Signature header using your endpoint's signing secret. Without this, any party can send a fake event to your endpoint:
// Symfony example
$payload = $request->getContent();
$sigHeader = $request->headers->get('Stripe-Signature');
$secret = $this->stripeWebhookSecret;
try {
$event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $secret);
} catch (\Stripe\Exception\SignatureVerificationException $e) {
return new Response('Invalid signature', 400);
}
The second requirement is idempotency. Stripe will retry a webhook if your endpoint returns anything other than a 2xx response. Your handler must be safe to call multiple times with the same event. The standard approach is to store processed event IDs and skip re-processing:
if ($this->webhookEventRepository->hasProcessed($event->id)) {
return new Response('Already processed', 200);
}
// Process the event...
$this->webhookEventRepository->markProcessed($event->id, $event->type);
Store the event ID before processing if your operation is not atomic, or use a database transaction that inserts the event ID and executes the business logic together. This prevents a failure mid-processing from leaving you with a partially-applied state and no record of the event ID.
The third requirement is async processing. Your webhook endpoint should do the minimum: verify the signature, store the raw event, and return 200 immediately. Move the actual processing into a background job. This keeps your response time well under Stripe's timeout threshold and decouples your webhook receiver from the complexity of downstream systems.
Subscription state sync is where drift happens
The most common production issue in Stripe integrations is not a bug in the payment flow — it is subscription state drift. Stripe is the source of truth for subscription status. Your database is a cache. When the two diverge, customers experience incorrect access control: they can access features they are not paying for, or they lose access to features they are.
Drift happens when:
- A webhook is missed because your endpoint was down during Stripe's retry window
- A webhook is received but processing fails silently
- A subscription is modified directly in the Stripe dashboard without a corresponding update in your system
The fix is a reconciliation job. Run it daily. It fetches active subscriptions from Stripe and compares them to your database records, flagging or correcting any discrepancies:
// Symfony Console command or scheduled task
public function reconcile(): void
{
$stripeSubscriptions = $this->stripe->subscriptions->all([
'status' => 'all',
'limit' => 100,
'expand' => ['data.customer'],
]);
foreach ($stripeSubscriptions->autoPagingIterator() as $stripeSub) {
$localSub = $this->subscriptionRepository->findByStripeId($stripeSub->id);
if (!$localSub) {
$this->logger->warning('Subscription in Stripe not found locally', [
'stripe_id' => $stripeSub->id,
'customer' => $stripeSub->customer->email ?? 'unknown',
]);
continue;
}
if ($localSub->getStatus() !== $stripeSub->status) {
$this->logger->warning('Subscription status mismatch', [
'stripe_id' => $stripeSub->id,
'local_status' => $localSub->getStatus(),
'stripe_status' => $stripeSub->status,
]);
$localSub->setStatus($stripeSub->status);
$this->subscriptionRepository->save($localSub);
}
}
}
Run this job off-peak and alert on discrepancies rather than silently correcting them — you want to know when drift is happening, not just fix it invisibly.
Idempotency keys for mutations
Any Stripe API call that creates or modifies a resource — creating a subscription, issuing a refund, applying a promo code — should include an idempotency key. This prevents duplicate operations if your application retries a request after a network timeout.
Stripe caches the result of idempotent requests for 24 hours. If you send the same idempotency key twice, the second call returns the cached result without executing the operation again:
$this->stripe->subscriptions->create([
'customer' => $customerId,
'items' => [['price' => $priceId]],
'payment_behavior' => 'default_incomplete',
'expand' => ['latest_invoice.payment_intent'],
], [
'idempotency_key' => 'sub_create_' . $userId . '_' . $planId . '_' . $requestId,
]);
Derive idempotency keys from the inputs of the operation, not from timestamps. A key like sub_create_{userId}_{planId}_{requestId} is specific enough to prevent collisions while being stable across retries of the same logical request.
Test and live key isolation
Mixing test and live keys is embarrassing when it happens and surprisingly easy to do. The classic failure mode: a developer sets STRIPE_SECRET_KEY=sk_test_... locally, it gets committed to a .env.example file, gets copied into a staging environment, and staging runs on live keys.
A few practices prevent this:
First, validate the key prefix at startup. sk_live_ keys should only appear in your production environment. Add an assertion in your application bootstrap:
$key = $this->stripeSecretKey;
$isProduction = $this->kernel->getEnvironment() === 'prod';
$isLiveKey = str_starts_with($key, 'sk_live_');
if ($isProduction && !$isLiveKey) {
throw new \RuntimeException('Production environment must use a live Stripe key.');
}
if (!$isProduction && $isLiveKey) {
throw new \RuntimeException('Non-production environment must not use a live Stripe key.');
}
Second, use separate Stripe accounts for test and production where possible, not just separate key sets within a single account. This gives you fully isolated webhook endpoints, isolated customer records, and no risk of test data appearing in your live dashboard.
Third, tag your webhook endpoints with the environment. Stripe lets you create multiple webhook endpoints. Name them clearly: production-api, staging-api. Use the correct signing secret for each.
The testing strategy that catches billing bugs before they reach customers
Stripe's test environment covers the basics, but the scenarios that cause production incidents are the edge cases: payment failure followed by retry, subscription cancellation mid-cycle, invoice generation failure. These need explicit test coverage.
Stripe provides test card numbers for specific scenarios — expired cards, insufficient funds, 3D Secure challenges. Cover each in your integration test suite:
4242 4242 4242 4242 — succeeds
4000 0000 0000 0002 — declined
4000 0027 6000 3184 — 3D Secure required
4000 0000 0000 9995 — insufficient funds
Beyond card tests, use the Stripe CLI to replay webhook events in your local environment:
stripe listen --forward-to localhost:8000/webhook/stripe
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
This lets you test your webhook handler for each event type without needing to trigger real subscription changes. Run these as part of your integration test suite before every deployment.
For reconciliation testing, write a test that intentionally corrupts your local subscription state, runs the reconciliation job, and verifies that the state is corrected. If that test does not exist, you do not know your reconciliation works.
Handling failed payments gracefully
Stripe's Smart Retries handle most failed payment recovery automatically, but your application needs to respond to the events that accompany the retry cycle. When invoice.payment_failed arrives, communicate clearly to the customer — a passive "your payment failed" email is less effective than an actionable prompt with a link to update their payment method.
When customer.subscription.deleted arrives because retries are exhausted, downgrade the customer's access state immediately. Do not wait for them to log in and discover it. Send a final notification explaining what happened and how to reactivate.
For high-value customers, consider a grace period: keep their access active for a short window after a failed payment, notify them aggressively within that window, and only restrict access when the window closes. This reduces involuntary churn without exposing you to significant revenue risk.
What this looks like at scale
These patterns are not just for large teams. A two-person SaaS team that implements verified webhooks, idempotent event processing, and a weekly reconciliation job will have fewer billing incidents than a ten-person team that skipped those steps. The investment at the start is two or three days of implementation. The cost of a billing incident in customer trust, support time, and emergency engineering is typically much higher.
Wolf-Tech works with SaaS teams on the kind of custom software development and web application development that includes exactly this layer — billing architecture, webhook infrastructure, and the production hardening that keeps billing reliable. If your Stripe integration needs a review or you are building it from scratch, reach out at hello@wolf-tech.io or visit wolf-tech.io.

