I've been building and running a small SaaS (GramShift, Instagram automation desktop app) on Stripe subscriptions for the past several months. Getting the basic checkout flow to work was easy — what took more careful design were three implementation patterns where the docs mention the risk but it's easy to skim past.
Sharing the three I locked in during the build phase, with the code I actually use. I caught the first one during local testing, before it could hit a real customer, and that's the version of the story I want to share — because the prevention pattern is the part worth copying.
Pitfall 1: Webhook idempotency — design for retries from day one
Stripe webhooks are designed to be retried. If your endpoint is slow or returns 5xx, Stripe resends the same event with exponential backoff. The docs say this. My initial code looked like this:
fastify.post('/api/stripe/webhook', async (req, reply) => {
const event = stripe.webhooks.constructEvent(
req.rawBody, req.headers['stripe-signature'], WEBHOOK_SECRET
);
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
await db.run(
`UPDATE users SET plan = 'pro', expires_at = ? WHERE id = ?`,
session.expires_at, session.client_reference_id
);
}
return reply.send({ received: true });
});
Looks fine. But while building the webhook flow, I ran the Stripe CLI to replay events into my local server and noticed the same event.id being processed multiple times when I deliberately slowed the handler. If a real production handler hiccuped for any reason — slow DB, transient timeout — Stripe would retry, and this code would happily re-run the same update.
For paid SaaS that's the kind of bug you absolutely do not want to discover by reading customer complaints. So I locked in an idempotency table before going live:
fastify.post('/api/stripe/webhook', async (req, reply) => {
const event = stripe.webhooks.constructEvent(
req.rawBody, req.headers['stripe-signature'], WEBHOOK_SECRET
);
const exists = await db.get(
'SELECT 1 FROM stripe_events WHERE event_id = ?', event.id
);
if (exists) return reply.send({ received: true, duplicate: true });
await db.run(
'INSERT INTO stripe_events (event_id, type, created_at) VALUES (?, ?, ?)',
event.id, event.type, Math.floor(Date.now() / 1000)
);
// ...actual processing...
return reply.send({ received: true });
});
Cost: one extra table, one extra query per webhook. Worth it on the very first day, because the failure mode (silently re-running a billing-related update) is exactly the kind of bug your test mode might not catch on its own.
Pitfall 2: 3D Secure (SCA) — your EU/Brazil customers will need it
The standard PaymentIntent + stripe.confirmCardPayment() flow works perfectly with 4242 4242 4242 4242. What it doesn't show you is that SCA (Strong Customer Authentication) requires explicit handling of the requires_action branch — and that branch is what real EU/Brazil/Australia customers hit on a meaningful share of transactions.
I added the branch during implementation after reading the SCA docs more carefully:
const result = await stripe.confirmCardPayment(clientSecret, {
payment_method: { card: cardElement, billing_details: { name } }
});
if (result.error) {
showError(result.error.message);
} else if (result.paymentIntent.status === 'requires_action') {
const { error } = await stripe.handleCardAction(
result.paymentIntent.client_secret
);
if (error) showError(error.message);
else showSuccess();
} else if (result.paymentIntent.status === 'succeeded') {
showSuccess();
}
Test card: 4000 0025 0000 3155 — this always triggers 3D Secure. If your flow completes with this card, you're covered for most international cases. It's a one-line test, but it's the difference between "works for everyone" and "silently broken for half of your EU traffic."
Pitfall 3: "Immediate cancel" vs "end-of-period cancel" — choose the right default
The simplest cancel implementation is subscription.delete — terminate now. It's one line of code, and it's almost always the wrong default.
When I sketched the cancel UX, I quickly hit three predictable problems:
- A customer who cancels mid-cycle naturally expects either prorated refund OR continued access until period end. Immediate termination satisfies neither.
- "Cancel" and "turn off auto-renew" are not the same intent. Treating them the same loses customer trust.
- Cancel button + change-of-mind flow is much easier to build if cancel is reversible by default.
So I defaulted to cancel_at_period_end:
async function cancelAtPeriodEnd(subscriptionId) {
return stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true
});
}
function renderCancelButton(sub) {
const endDate = new Date(sub.current_period_end * 1000)
.toLocaleDateString('en-US');
return `<button>Cancel at next renewal (${endDate})</button>`;
}
If someone really wants immediate cancel + refund, that's a separate "contact support" flow — not a self-serve button. The default is reversible by design, which means the customer has time to change their mind (and a non-trivial share do).
Quick checklist before you launch Stripe subscriptions
- Idempotency table on every webhook, keyed by event ID — design it in before going live, not after
-
Handle
requires_actionfor 3D Secure, test with4000 0025 0000 3155 - Default to end-of-period cancel, immediate cancel via support
- Self-subscribe for a full cycle on a test card before launch — sign up, get charged, change cards, cancel, resume
All three are mentioned in the Stripe docs, but they're easy to read past because the happy-path code works without them. Building each in as a default-on pattern saved me from having to fix them under customer-facing pressure later.
What patterns did you wish you'd implemented from day one with Stripe subscriptions? Curious what others built in early.
Top comments (0)