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

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_urlPost-payment redirect URLs
  • customerLinks to the Stripe Customer object in your system
  • metadataCustom data (userId, planId, etc.) needed in Webhooks
  • Subscription Lifecycle

    A subscription's complete lifecycle:

    1
    CreationUser completes initial payment
    2
    RenewalAutomatic monthly/annual charge
    3
    Upgrade/DowngradeUser changes pricing tier
    4
    PauseUser temporarily stops subscription
    5
    CancelUser actively cancels (usually effective at current period end)
    6
    ResumeReactivate before cancellation takes effect
    7
    Payment failureFailed 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.completedInitial payment successful
  • invoice.payment_succeededRenewal successful
  • invoice.payment_failedRenewal failed
  • customer.subscription.updatedSubscription changed (upgrade/downgrade/cancel)
  • customer.subscription.deletedSubscription terminated
  • Processing Flow

    Each Webhook event's handling:

    1
    Verify signatureConfirm request actually came from Stripe
    2
    DeduplicateCheck if event.id was already processed
    3
    Extract dataGet subscriptionId, customerId, etc. from event object
    4
    Update systemModify user subscription status in database
    5
    Trigger follow-upsSend 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 4242Successful payment
  • 4000 0000 0000 0341Payment declined
  • 4000 0000 0000 32203D 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