Skip to main content
Back to Blog
Architecture

Stripe Integration Lessons: What the Docs Don't Tell You

April 12, 202613 min read
StripePaymentsWebhooksSaaSTypeScriptFinTech

Stripe Integration Lessons: What the Docs Don't Tell You

Stripe's documentation is excellent — for the happy path. But production billing has edge cases that will break your system if you're not prepared.

Here's what I learned integrating Stripe into the Nexural trading platform.

The Webhook State Machine

Stripe sends webhooks for everything. Your job is to handle them idempotently — because Stripe will retry failed webhooks, and you'll get duplicates.

// Every webhook handler must be idempotent
async function handleSubscriptionUpdated(event: Stripe.Event) {
  const subscription = event.data.object as Stripe.Subscription;

  // Check if we've already processed this event
  const existing = await db.webhookEvents.findUnique({
    where: { stripeEventId: event.id }
  });
  if (existing) return; // Already processed — skip

  // Process the event
  await db.subscriptions.update({
    where: { stripeId: subscription.id },
    data: {
      status: subscription.status,
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    }
  });

  // Record that we processed this event
  await db.webhookEvents.create({
    data: { stripeEventId: event.id, processedAt: new Date() }
  });
}

Key insight: Store every Stripe event ID you process. Check it before processing. This prevents double-charges, double-cancellations, and all the billing nightmares.

Subscription Lifecycle (The Real One)

The Stripe docs show: create → active → cancelled. Reality is messier:

                    ┌─── past_due ──── unpaid ──── cancelled
                    │
trialing → active ──┤
                    │
                    ├─── paused
                    │
                    └─── cancelled (voluntary)

past_due is the dangerous state. The customer's card failed. Stripe will retry (dunning). During this window:

  • Do you cut off access immediately? (aggressive — you'll lose customers)
  • Do you maintain access for 7 days? (generous — you'll eat the cost)
  • Do you show a warning banner? (balanced — my approach)
function getUserAccess(subscription: Subscription): AccessLevel {
  switch (subscription.status) {
    case 'active':
    case 'trialing':
      return 'full';
    case 'past_due':
      return 'degraded'; // Show banner, limit some features
    case 'unpaid':
    case 'cancelled':
      return 'free_tier'; // Read-only access
    default:
      return 'none';
  }
}

Proration: The Math Nobody Explains

When a customer upgrades mid-cycle, Stripe prorates. But the proration logic has gotchas:

  • Upgrade mid-month: Customer pays the difference immediately
  • Downgrade mid-month: Customer gets a credit applied to next invoice
  • Upgrade then downgrade in same cycle: Credit and charge partially cancel out, but NOT exactly — there's rounding

I handle this by always using proration_behavior: 'create_prorations' and showing the customer exactly what they'll pay before confirming:

// Preview the proration before applying
const preview = await stripe.invoices.retrieveUpcoming({
  customer: customerId,
  subscription: subscriptionId,
  subscription_items: [{
    id: existingItemId,
    price: newPriceId,
  }],
});
// Show: "You'll be charged $X.XX today"

The Webhook Verification Mistake

Always verify webhook signatures. But the common mistake is reading the body as JSON before verifying:

// WRONG — parsing body before verification
app.post('/webhook', express.json(), (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.body, // This is already parsed — verification will fail
    sig,
    secret
  );
});

// RIGHT — use raw body for verification
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.body, // Raw buffer — verification works
    sig,
    secret
  );
});

This bug is in probably 50% of Stripe integration tutorials online.

Testing Billing

You cannot test billing with unit tests alone. You need:

  1. Stripe test mode for webhook simulation
  2. A test clock (stripe.testHelpers.testClocks) to simulate time passing
  3. Actual webhook delivery to your staging environment
  4. Edge case scripts that simulate: card decline, card expiry, disputed charge, refund

I wrote a billing-scenarios.ts script that runs through 12 billing scenarios against the Stripe test API. It catches regressions before they reach production.

What I'd Do Differently

  1. Use Stripe Billing Portal from day one. I built a custom subscription management UI. Stripe's hosted portal does 90% of what I built, for free, with better UX.
  2. Implement invoice.payment_failed webhook immediately. I added dunning handling late and had 3 customers churned before I caught the failed payments.
  3. Log every Stripe API call. When something goes wrong with billing, you need the full request/response history. I added structured logging after a customer reported being double-charged.

Billing code is the highest-stakes code in your application. Test it more than anything else.

Want to see this in action?

Check out the projects and case studies behind these articles.