Stripe is well-documented and the developer experience is genuinely good. The mistakes that hurt businesses are not in the basic payment flow. They are in the edge cases: the subscription that fails silently, the webhook that the server processes twice, the payment that succeeds but the database does not update because the redirect never fired.
Mistake 1: Trusting client-side redirects for payment confirmation
The most common integration mistake: the payment flow sends the user to a success_url after payment, and the server grants access when the user hits that URL. This seems to work in testing. In production, users close the browser tab before the redirect fires. Their payment goes through. Their account is never upgraded.
The correct pattern: the success_url shows a confirmation page, but the actual account provisioning happens via webhook. When Stripe sends a checkout.session.completed or payment_intent.succeeded event, that is when your server updates the database. The redirect is for UX only.
// Webhook handler (not the success redirect) does the real work
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
return res.status(400).send('Webhook signature verification failed');
}
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object;
await db.user.update({
where: { stripeCustomerId: session.customer },
data: { plan: 'pro', subscriptionId: session.subscription }
});
break;
case 'customer.subscription.deleted':
await db.user.update({
where: { stripeCustomerId: event.data.object.customer },
data: { plan: 'free' }
});
break;
}
res.json({ received: true });
});Mistake 2: Not verifying webhook signatures
If your webhook endpoint processes any POST request without verifying the Stripe signature, an attacker can send fake payment success events. They construct a payload that looks like a successful payment and send it to your endpoint. Your server upgrades their account without any money changing hands.
The fix in the code above uses stripe.webhooks.constructEvent() with your webhook signing secret. The signing secret is found in the Stripe dashboard under Webhooks → your endpoint → Signing secret. Never skip this verification.
Important: the webhook body must be the raw bytes, not a parsed JSON object. Useexpress.raw() for the webhook route, not express.json(). The signature is computed over the raw bytes.
Mistake 3: Processing webhook events without idempotency
Stripe sends each event at least once but sometimes more than once. If your webhook handler grants a subscription upgrade on every invocation, a retry from Stripe (because your server returned a 500 or took too long) will upgrade the account twice. With some events, that does not matter. With events that trigger actions (sending a welcome email, provisioning resources, creating records), it does.
// Store processed event IDs to prevent duplicate processing
async function handleWebhook(event) {
// Check if we already processed this event
const existing = await db.stripeEvent.findUnique({
where: { stripeEventId: event.id }
});
if (existing) {
console.log('Duplicate event, skipping:', event.id);
return;
}
// Mark as processing before doing the work
await db.stripeEvent.create({
data: { stripeEventId: event.id, type: event.type, processedAt: new Date() }
});
// Now do the actual work
switch (event.type) {
case 'checkout.session.completed':
await provisionSubscription(event.data.object);
break;
// ...
}
}Mistake 4: Not handling subscription lifecycle events
A subscription is not a one-time payment. It can be created, renewed, fail to renew, be cancelled by the user, be cancelled by you, be paused, or be upgraded/downgraded. Applications that only handle checkout.session.completed will grant access at signup but never revoke it when the subscription lapses.
The minimum set of events to handle for a subscription-based product:
checkout.session.completed: initial signupinvoice.payment_succeeded: successful renewalinvoice.payment_failed: failed renewal, begin grace periodcustomer.subscription.deleted: subscription ended, revoke accesscustomer.subscription.updated: plan change, update local state
Mistake 5: Using the Stripe secret key in frontend code
Stripe has two keys: the publishable key (starts with pk_) for use in the browser, and the secret key (starts with sk_) for server-side use only. The secret key allows charging cards, accessing customer data, and issuing refunds.
If the secret key is in client-side JavaScript (because someone put it in a React component or in a NEXT_PUBLIC_ environment variable), it is visible to anyone who views the page source. Exposing it is equivalent to publishing your bank account credentials.
// WRONG: this exposes the secret key in the browser bundle
const stripe = Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY); // never do this
// CORRECT: use publishable key in the browser
const stripe = Stripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY); // pk_...
// Secret key only in server-side code:
// app/api/create-checkout/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); // sk_... server only$ review --stripe-setup
If your Stripe integration was put together quickly and you want to know if it handles the edge cases, I can go through it. Webhook handling and subscription lifecycle are where most issues hide.
$ ./request-backend-help.sh →