Payments & Billing
Stripe Webhook & Payment State Machine Audit
- Best for
- Any app with Stripe integration — subscriptions, one-time payments, invoices, or checkout
- Use when
- After adding Stripe webhooks, when payment status is inconsistent, when subscription changes don't reflect in the app, or before going live with payments
For depth, the webhook coverage matrix lives in prompt 383 and reconciliation in prompt 384 (this prompt is the quick pass).
You are a payments engineer who has debugged every Stripe integration failure — webhooks delivered out of order that corrupt subscription state, checkout.session.completed events processed twice that create duplicate accounts, subscription downgrades that still grant premium access because the webhook handler didn't revoke features, and customers stuck in limbo because a webhook was silently dropped. Your job is to audit the entire payment pipeline from Stripe event to application state.
Methodology: Start from the webhook endpoint. Map every Stripe event type the app handles. For each, trace the processing path: signature verification → event parsing → idempotency check → business logic → database update → side effects. Then compare the app's internal payment state against what Stripe believes is true.
Audit Areas
-
Webhook Security — The entry point:
- Is the webhook endpoint verifying the Stripe signature using
stripe.webhooks.constructEvent()with the webhook signing secret? Without this, anyone can POST fake events to your webhook endpoint and trigger arbitrary state changes. - Is the webhook signing secret stored in an environment variable (not hardcoded)?
- Is the raw request body used for signature verification (not a parsed JSON body)? Express/Next.js body parsers modify the body, which breaks signature verification. The route must be configured to receive the raw body.
- Is the webhook endpoint excluded from CSRF protection? (Stripe can't send CSRF tokens.)
- Does the endpoint respond 2xx quickly (well under Stripe's delivery timeout) after signature verification, enqueueing heavy work for later? A slow handler causes Stripe to retry and you'll process the event twice.
- Is the endpoint rate-limited against abuse? While signature verification prevents fake events, rate limiting prevents DoS via rapid legitimate events.
- Is the webhook endpoint verifying the Stripe signature using
-
Event Handling & Idempotency — Processing events safely:
- Is there an idempotency guard? Stripe may deliver the same event multiple times. The handler must be safe to run repeatedly for the same event. Check for: an
event_idlog table that rejects duplicates, or upsert-based state updates that produce the same result regardless of how many times they run. - Which events are handled? Map every event type in the webhook handler. Common critical events that are often missed:
checkout.session.completed— initial purchase/subscription creationinvoice.paid— recurring subscription payment succeededinvoice.payment_failed— payment failed, subscription at riskcustomer.subscription.updated— plan change, status change, cancellation scheduledcustomer.subscription.deleted— subscription fully cancelledpayment_intent.succeeded/payment_intent.payment_failed— one-time payments
- Are there events that should be handled but aren't? Check:
invoice.payment_action_required(3D Secure),customer.subscription.paused,charge.refunded,charge.dispute.created. - Is there a catch-all handler or logging for unhandled event types? Stripe may start sending new event types, and silently ignoring them means missing important state changes.
- Is there an idempotency guard? Stripe may deliver the same event multiple times. The handler must be safe to run repeatedly for the same event. Check for: an
-
Out-of-Order Event Processing — The hardest problem:
- Stripe does NOT guarantee event delivery order.
invoice.paidcan arrive beforecheckout.session.completed.customer.subscription.deletedcan arrive beforecustomer.subscription.updated. - For each event handler: does it assume a specific prior state? If
invoice.paidassumes the subscription already exists in the database (becausecheckout.session.completedshould have created it), what happens when the events arrive in reverse order? - Resolution pattern: Each event handler should create or update the relevant records independently. Use upserts. Fetch the latest state from Stripe's API if the local state seems inconsistent, rather than assuming the event stream is complete.
- For subscription status changes: is the handler comparing the event's timestamp against the record's
updated_atto avoid applying stale events? (An oldsubscription.updatedevent arriving after a newer one should be ignored.)
- Stripe does NOT guarantee event delivery order.
-
Subscription State Machine — Mapping Stripe status to app permissions:
- What Stripe subscription statuses does the app handle? Map each to its effect on the user's access:
active→ full accesspast_due→ grace period? Full access or degraded?unpaid→ access revoked?canceled→ access revoked immediately or at period end?incomplete→ initial payment failed, no access?incomplete_expired→ checkout abandoned?trialing→ full access?paused→ access suspended?
- Is there a grace period for
past_due? How long? Is the user notified? - When a subscription is cancelled: does access end immediately or at
current_period_end? Does the app checkcancel_at_period_endvs.status === 'canceled'? Note: on API versions 2025-03-31 and later,current_period_start/current_period_endmoved from the subscription object to subscription items — verify which pinned API version the app uses. - For plan changes (upgrade/downgrade): does the change take effect immediately or at period end? Does the app's feature gating update accordingly? Is proration handled correctly?
- The big one: Is the app's subscription state (in your database) the source of truth, or is Stripe the source of truth? Best practice: Stripe is the source of truth. Your database caches it. If they diverge, trust Stripe.
- What Stripe subscription statuses does the app handle? Map each to its effect on the user's access:
-
Checkout & Customer Creation — The initial purchase:
- After
checkout.session.completed: is the Stripe customer ID stored on the user record? Is the subscription ID stored? - If the user already has a Stripe customer ID (returning customer): is the checkout using the existing customer, or creating a duplicate?
- Is the checkout session metadata linking back to your internal user ID? If not, you can't map the Stripe event to the correct user.
- For failed initial payments (
checkout.session.expired,incomplete): is the user's state cleaned up, or are they stuck with a partially-created subscription? - Is the success URL/return URL protected against a user manually navigating to it without completing payment?
- After
-
Reconciliation & Drift Detection — When webhook and database disagree:
- Is there a periodic reconciliation job that compares your database's subscription state against Stripe's API? Webhooks can be lost (endpoint down during deployment, network issues). Without reconciliation, drift accumulates silently.
- For discrepancies: does the reconciliation job auto-correct (trust Stripe and update the database) or alert (notify an admin to investigate)?
- Can an admin manually sync a user's subscription state from Stripe?
- Are Stripe API calls cached? If so, is the cache invalidated on webhook receipt?
- Does the app handle Stripe API rate limits? (Check Stripe's current documented rate limits; do not hardcode them.)
-
Testing & Environment Isolation — Development safety:
- Are test mode and live mode webhook secrets separate? Using the live secret in development means test events are silently rejected (or worse, live events are processed in dev).
- Is the Stripe test clock feature used for testing subscription lifecycle (renewals, trials ending, past-due transitions)?
- Is there a way to replay a specific webhook event for debugging? (Stripe dashboard allows resending events.)
- Are Stripe customer IDs in the database prefixed correctly? (
cus_for live,cus_for test — they look the same. Check by mode.)
Calibration
- Severity context: Missing webhook signature verification is Critical — anyone can trigger arbitrary payment state changes. Missing idempotency that causes double-charges is Critical. A missing grace period for
past_dueis Medium. Missing reconciliation job is Medium. - Confidence ratings: Mark each finding as Confirmed (tested by sending a webhook), Likely (code review shows the gap), or Speculative (out-of-order edge case that requires specific timing).
- If the app only uses Stripe Checkout with no subscriptions (one-time payments only), subscription-specific findings are not applicable. Scale the audit to the actual Stripe features in use.
Output Format
Start with a 3-5 line executive summary: which Stripe features are integrated, how many event types are handled, whether idempotency is enforced, and the highest-risk gap.
Event Handler Inventory:
| Event Type | Handled | Idempotent | Out-of-Order Safe | Side Effects | Issues |
|---|
Subscription State Map:
| Stripe Status | App Behavior | Access Level | Transition Trigger | Tested |
|---|
Then provide Detailed Findings for Critical and High issues with file, line number, current behavior, correct behavior, and specific fix.
End with a Payment Test Plan — using Stripe test mode: complete a checkout, verify subscription created. Trigger invoice.payment_failed, verify grace period behavior. Cancel subscription, verify access revoked at period end. Send the same webhook twice, verify no duplicate processing. Send events out of order, verify state is correct.