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:
- Stripe test mode for webhook simulation
- A test clock (
stripe.testHelpers.testClocks) to simulate time passing - Actual webhook delivery to your staging environment
- 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
- 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.
- Implement invoice.payment_failed webhook immediately. I added dunning handling late and had 3 customers churned before I caught the failed payments.
- 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.