arrow_backTech Blog
stacks
stacksFull Stack2025-11-15·9 min read

Stripe Subscription Integration: A Full Walkthrough with Pitfalls

Complete Stripe subscription integration in a SaaS system — focusing on Webhook event handling, idempotency design, and common gotchas.

StripeWebhookNestJS
On this page

Integration Overview

Stripe subscriptions involve Checkout Session creation, Webhook listening, and subscription lifecycle management.

Stripe has the best payment documentation I've ever seen — but even so, real-world integration pitfalls far exceeded expectations.

Our SaaS system has three paid tiers (Basic / Pro / Enterprise) with monthly and annual billing. Users can upgrade, downgrade, cancel, or resume subscriptions at any time. Seemingly simple requirements, but a massive number of edge cases to handle.

Core Flow

Checkout Session Creation

When users click "Upgrade," the frontend calls backend API to create a Checkout Session, then redirects to Stripe's hosted payment page.

Key configuration:

  • mode: 'subscription': Subscription mode (distinct from one-time payments)
  • success_url / cancel_url: Post-payment redirect URLs
  • customer: Links to the Stripe Customer object in your system
  • metadata: Custom data (userId, planId, etc.) needed in Webhooks

Subscription Lifecycle

A subscription's complete lifecycle:

  1. Creation: User completes initial payment
  2. Renewal: Automatic monthly/annual charge
  3. Upgrade/Downgrade: User changes pricing tier
  4. Pause: User temporarily stops subscription
  5. Cancel: User actively cancels (usually effective at current period end)
  6. Resume: Reactivate before cancellation takes effect
  7. Payment failure: Failed charge retry and dunning

💡 Don't try to manage subscription state yourself — let Stripe be the source of truth, and your system just syncs Stripe's state. Maintaining your own subscription state is a bug factory.


Webhooks Are the Core

Subscription state changes (creation, renewal, cancellation, failure) are all async Webhook notifications — idempotent handling is mandatory.

Key Events

  • checkout.session.completed: Initial payment successful
  • invoice.payment_succeeded: Renewal successful
  • invoice.payment_failed: Renewal failed
  • customer.subscription.updated: Subscription changed (upgrade/downgrade/cancel)
  • customer.subscription.deleted: Subscription terminated

Processing Flow

Each Webhook event's handling:

  1. Verify signature: Confirm request actually came from Stripe
  2. Deduplicate: Check if event.id was already processed
  3. Extract data: Get subscriptionId, customerId, etc. from event object
  4. Update system: Modify user subscription status in database
  5. Trigger follow-ups: Send email notifications, update caches, etc.

Webhook handling is like writing distributed systems — assume any step can fail, any event can arrive multiple times, and order may be scrambled.


Pitfalls

1. Webhook Signature Verification

Must use the raw request body (buffer), never JSON.parse before verifying. The signature is computed on raw bytes — parsing and re-serializing may change field ordering.

In NestJS, configure specific routes to use rawBody:

  • Enable rawBody option globally
  • Use @RawBody() decorator in the Webhook Controller

⚠️ With Express, req.body after body-parser is no longer the raw buffer. Skip JSON parsing middleware on Webhook routes.

2. Idempotency Design

The same event may be delivered multiple times — deduplicate by event.id. Our approach:

  • Before processing, check event.id in Redis (SET NX, TTL 24 hours)
  • If exists, return 200 immediately (Stripe considers it successfully handled)
  • Database operations use transactions for atomicity

3. User Experience

Don't rely on page redirect after Checkout to update status — the Webhook might not have arrived. Our solution:

  • Frontend polls subscription status on success page (every 2s, max 30s)
  • Webhook updates database in background
  • Most cases sync within 3-5 seconds
  • On timeout, show "subscription processing" prompt

4. Upgrade/Downgrade Handling

When upgrading from Basic to Pro, Stripe provides proration (pro-rate billing). But default behavior may be unexpected:

  • proration_behavior: 'create_prorations': Auto-generate difference invoice
  • proration_behavior: 'always_invoice': Charge difference immediately
  • proration_behavior: 'none': No pro-rating, new price starts next period

Testing

Local Testing

Use stripe listen --forward-to to forward Webhooks to local dev. Stripe CLI handles signature verification automatically.

Trigger Test Events

Use Stripe CLI to trigger test events:

  • stripe trigger checkout.session.completed
  • stripe trigger invoice.payment_failed

Test Card Numbers

Stripe provides rich test card numbers:

  • 4242 4242 4242 4242: Successful payment
  • 4000 0000 0000 0341: Payment declined
  • 4000 0000 0000 3220: 3D Secure verification

Before going live, thoroughly test ALL scenarios: successful payment, failed payment, upgrade, downgrade, cancel, resume, Webhook retry. Every scenario can have bugs.

Conclusion

The core of Stripe integration isn't "getting the API to work" — it's "correctly handling all edge cases." Payment systems tolerate zero bugs — one extra charge or one missed permission update directly causes user complaints.

Spend 80% of your time on Webhook handling and edge cases, not on UI.

💡 For first-time payment integration, strongly recommend completing all flows in test mode first, writing integration tests, then switching to production. For payment system releases, caution matters more than speed.

stacks