{"schemaVersion":"1.0","type":"Article","types":["Article"],"slug":"stripe-integration-in-node-js-payments-subscriptions-and-the-edge-cases-that-cost-you-money-vfsa4","url":"https://zyvop.com/stripe-integration-in-node-js-payments-subscriptions-and-the-edge-cases-that-cost-you-money-vfsa4","title":"Stripe Integration in Node.js: Payments, Subscriptions, and the Edge Cases That Cost You Money","subtitle":"Build a production-ready Stripe integration in Node.js with subscriptions, webhooks, retries, idempotency, and real-world payment edge cases.","tldr":"Full production Stripe subscription flow with idempotency keys, webhook verification, event deduplication, subscription lifecycle handling, hosted checkout integration, and robust processing for payments, failures, cancellations, and renewals.","keywords":["Stripe PaymentIntent Node.js","Stripe TypeScript","invoice.paid webhook","Stripe Node.js integration","Stripe subscriptions Node.js","Stripe customer portal","Stripe idempotency","Stripe webhooks 2026"],"entities":["ZyVOP","Stripe PaymentIntent Node.js","Stripe TypeScript","invoice.paid webhook","Stripe Node.js integration","Stripe subscriptions Node.js","Stripe customer portal","Stripe idempotency","Stripe webhooks 2026"],"keyTakeaways":["Stripe's documentation is excellent.","It shows you how to create a PaymentIntent, how to set up a subscription, how to handle a webhook.","What it does not show you is how all the pieces fit together into a payment system that handles failures gracefully, does not double-charge customers, and does not lose revenue when a webhook is delayed."],"headings":["Setup","One-Time Payments","Subscriptions","Webhook Handling: The Critical Path","The Event Handlers","The Customer Portal","The Failure Modes That Cost Money","Price ID Mapping"],"outboundLinks":[],"contentText":"Stripe's documentation is excellent. It shows you how to create a PaymentIntent, how to set up a subscription, how to handle a webhook. What it does not show you is how all the pieces fit together into a payment system that handles failures gracefully, does not double-charge customers, and does not lose revenue when a webhook is delayed. This guide covers the full production implementation: one-time payments with idempotency, subscription management, webhook handling, and the failure modes that tutorial code ignores. Setup npm install stripe// src/lib/stripe.ts import Stripe from 'stripe'; export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-11-20.acacia', // Pin to a specific version typescript: true, maxNetworkRetries: 2, // Retry on network failures automatically timeout: 20_000, // 20 second timeout });Pinning the API version is not optional. Stripe releases breaking changes under new versions; without pinning, a Stripe API update can silently change your integration's behavior. One-Time Payments The flow: your server creates a PaymentIntent, sends the client_secret to the client, the client confirms it with Stripe.js. Your server never handles raw card data. // src/routes/payments.ts import { stripe } from '../lib/stripe'; import { randomUUID } from 'crypto'; router.post('/payments/create-intent', authenticate, async (req, res) =&gt; { const { amount, currency = 'usd', orderId } = req.body; // Idempotency key — if this request is retried, Stripe returns the same // PaymentIntent instead of creating a duplicate charge const idempotencyKey = `pi-${req.user.id}-${orderId}`; const paymentIntent = await stripe.paymentIntents.create( { amount: Math.round(amount * 100), // Stripe uses cents currency, metadata: { userId: req.user.id, tenantId: req.tenant.id, orderId, }, // Capture payment immediately (default) // Use capture_method: 'manual' for auth-then-capture flows }, { idempotencyKey } ); // Store the PaymentIntent ID — needed to reconcile webhook events await db.query( `UPDATE orders SET stripe_payment_intent_id = $1, status = 'awaiting_payment' WHERE id = $2 AND tenant_id = $3`, [paymentIntent.id, orderId, req.tenant.id] ); // Only send client_secret to the client — never the full PaymentIntent object res.json({ clientSecret: paymentIntent.client_secret }); });Subscriptions Subscriptions in Stripe are more complex than one-time payments. The lifecycle involves: creating a customer, attaching a payment method, creating the subscription, and then handling ongoing billing events via webhooks. // src/services/subscriptionService.ts export async function createSubscription(params: { userId: string; tenantId: string; email: string; priceId: string; // Stripe Price ID — e.g. 'price_pro_monthly' paymentMethodId: string; // From Stripe.js on the frontend }) { // Step 1 — Get or create Stripe customer let stripeCustomerId = await getStripeCustomerId(params.tenantId); if (!stripeCustomerId) { const customer = await stripe.customers.create({ email: params.email, metadata: { tenantId: params.tenantId, userId: params.userId }, }); stripeCustomerId = customer.id; await db.query( 'UPDATE tenants SET stripe_customer_id = $1 WHERE id = $2', [stripeCustomerId, params.tenantId] ); } // Step 2 — Attach payment method to customer await stripe.paymentMethods.attach(params.paymentMethodId, { customer: stripeCustomerId, }); // Set as default payment method await stripe.customers.update(stripeCustomerId, { invoice_settings: { default_payment_method: params.paymentMethodId, }, }); // Step 3 — Create subscription const subscription = await stripe.subscriptions.create({ customer: stripeCustomerId, items: [{ price: params.priceId }], payment_settings: { payment_method_types: ['card'], save_default_payment_method: 'on_subscription', }, expand: ['latest_invoice.payment_intent'], metadata: { tenantId: params.tenantId, userId: params.userId, }, }); // Step 4 — Store subscription reference await db.query(` UPDATE tenants SET stripe_subscription_id = $1, plan = $2, subscription_status = $3, current_period_end = to_timestamp($4) WHERE id = $5 `, [ subscription.id, getPlanFromPriceId(params.priceId), subscription.status, subscription.current_period_end, params.tenantId, ]); return subscription; } export async function cancelSubscription(tenantId: string) { const tenant = await getTenant(tenantId); if (!tenant.stripe_subscription_id) { throw new NotFoundError('Subscription'); } // Cancel at period end — user keeps access until billing cycle ends // Use { immediately: true } to cancel and refund immediately const subscription = await stripe.subscriptions.update( tenant.stripe_subscription_id, { cancel_at_period_end: true } ); await db.query( `UPDATE tenants SET subscription_status = 'canceling' WHERE id = $1`, [tenantId] ); return subscription; }Webhook Handling: The Critical Path Webhooks are how Stripe tells you what actually happened — payment succeeded, invoice paid, subscription cancelled, payment failed. Your webhook handler is more important than your payment creation endpoint. // src/routes/webhooks/stripe.ts import { stripe } from '../../lib/stripe'; const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!; // Map Stripe events to handlers — add new ones here as you expand const eventHandlers: Partial&lt;Record&lt;Stripe.Event.Type, (event: Stripe.Event) =&gt; Promise&lt;void&gt;&gt;&gt; = { 'payment_intent.succeeded': handlePaymentSucceeded, 'payment_intent.payment_failed': handlePaymentFailed, 'invoice.paid': handleInvoicePaid, 'invoice.payment_failed': handleInvoicePaymentFailed, 'customer.subscription.updated': handleSubscriptionUpdated, 'customer.subscription.deleted': handleSubscriptionDeleted, 'customer.subscription.trial_will_end': handleTrialEnding, }; router.post('/webhooks/stripe', express.raw({ type: 'application/json' }), // Must be raw — not parsed JSON async (req, res) =&gt; { const signature = req.headers['stripe-signature'] as string; let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(req.body, signature, WEBHOOK_SECRET); } catch (err) { logger.warn({ error: (err as Error).message }, 'Stripe webhook signature invalid'); return res.status(400).json({ error: 'Invalid signature' }); } // Acknowledge immediately — Stripe retries if you don't respond within 30s res.status(200).json({ received: true }); // Deduplicate — Stripe delivers at-least-once, not exactly-once const alreadyProcessed = await db.query( `INSERT INTO processed_webhooks (event_id, source) VALUES ($1, 'stripe') ON CONFLICT DO NOTHING`, [event.id] ).then(r =&gt; r.rowCount === 0); if (alreadyProcessed) { logger.info({ eventId: event.id }, 'Duplicate Stripe event — skipping'); return; } const handler = eventHandlers[event.type]; if (handler) { try { await handler(event); } catch (err) { logger.error({ eventId: event.id, eventType: event.type, error: (err as Error).message, }, 'Stripe webhook handler failed'); // Do not re-throw — the 200 is already sent // Queue for retry instead await webhookQueue.add('stripe-retry', { event }, { attempts: 3, backoff: { type: 'exponential', delay: 5000 }, }); } } } );The Event Handlers async function handlePaymentSucceeded(event: Stripe.Event) { const paymentIntent = event.data.object as Stripe.PaymentIntent; const { orderId } = paymentIntent.metadata; await db.query(` UPDATE orders SET status = 'paid', paid_at = NOW(), stripe_payment_id = $1 WHERE stripe_payment_intent_id = $2 AND status != 'paid' -- Idempotent `, [paymentIntent.id, paymentIntent.id]); const order = await db.query( 'SELECT * FROM orders WHERE stripe_payment_intent_id = $1', [paymentIntent.id] ); if (order.rows[0]) { await emailQueue.add('order-confirmation', { orderId: order.rows[0].id, userId: order.rows[0].user_id, tenantId: order.rows[0].tenant_id, }, { jobId: `order-confirmation-${order.rows[0].id}`, // No duplicate emails }); } } async function handleInvoicePaid(event: Stripe.Event) { const invoice = event.data.object as Stripe.Invoice; const subscription = await stripe.subscriptions.retrieve(invoice.subscription as string); await db.query(` UPDATE tenants SET subscription_status = 'active', current_period_end = to_timestamp($1), plan = $2 WHERE stripe_customer_id = $3 `, [ subscription.current_period_end, getPlanFromPriceId(subscription.items.data[0].price.id), invoice.customer, ]); } async function handleInvoicePaymentFailed(event: Stripe.Event) { const invoice = event.data.object as Stripe.Invoice; // Stripe will retry automatically — do not immediately downgrade // Update status to reflect the failed payment without removing access await db.query( `UPDATE tenants SET subscription_status = 'past_due' WHERE stripe_customer_id = $1`, [invoice.customer] ); // Send a payment failed email to the account owner const tenant = await db.query( 'SELECT * FROM tenants WHERE stripe_customer_id = $1', [invoice.customer] ); if (tenant.rows[0]) { await emailQueue.add('payment-failed', { tenantId: tenant.rows[0].id, invoiceUrl: invoice.hosted_invoice_url, }); } } async function handleSubscriptionDeleted(event: Stripe.Event) { const subscription = event.data.object as Stripe.Subscription; await db.query(` UPDATE tenants SET plan = 'free', subscription_status = 'cancelled', stripe_subscription_id = NULL, current_period_end = NULL WHERE stripe_customer_id = $1 `, [subscription.customer]); }The Customer Portal Instead of building your own subscription management UI, use Stripe's hosted customer portal — billing history, plan upgrades, cancellation, payment method updates all handled by Stripe. router.post('/billing/portal', authenticate, async (req, res) =&gt; { const tenant = await getTenant(req.tenant.id); if (!tenant.stripe_customer_id) { return res.status(400).json({ error: 'No billing account found' }); } const session = await stripe.billingPortal.sessions.create({ customer: tenant.stripe_customer_id, return_url: `${process.env.APP_URL}/settings/billing`, }); res.json({ url: session.url }); });The client redirects to session.url. Stripe handles everything. Subscription changes fire webhook events that your handlers process. The Failure Modes That Cost Money No idempotency keys on PaymentIntent creation. If a network timeout causes the client to retry the creation request, you create two PaymentIntents and potentially double-charge the customer. Always pass an idempotencyKey derived from the order ID. Granting access based on subscription creation, not webhook confirmation. The subscription might be created but the initial payment might fail. Wait for invoice.paid before granting access. Not handling invoice.payment_failed gracefully. Stripe retries failed invoices automatically — do not immediately downgrade customers on the first failure. Mark as past_due, notify them, and give Stripe's retry schedule a chance to work. Canceling subscriptions immediately instead of at period end. When a customer cancels, they have paid for the current period. Cancel with cancel_at_period_end: true and let them use the service until the period ends. It reduces support requests and chargebacks. Not testing with Stripe's test cards. Every failure scenario has a test card. 4000000000000002 declines every time. 4000002500003155 requires 3D Secure. 4000000000009995 fails after charge. Run through each one before shipping. # Stripe test card numbers worth knowing 4242 4242 4242 4242 — Success 4000 0000 0000 0002 — Card declined 4000 0025 0000 3155 — Requires 3D Secure authentication 4000 0000 0000 9995 — Insufficient funds 4000 0000 0000 0069 — Expired cardPrice ID Mapping Keep a lookup table between Stripe Price IDs and your internal plan names. Price IDs change when you create new prices; this layer insulates your code from those changes. // src/lib/stripe.ts const PRICE_TO_PLAN: Record&lt;string, string&gt; = { [process.env.STRIPE_PRICE_PRO_MONTHLY!]: 'pro', [process.env.STRIPE_PRICE_PRO_ANNUAL!]: 'pro', [process.env.STRIPE_PRICE_ENTERPRISE!]: 'enterprise', }; export function getPlanFromPriceId(priceId: string): string { return PRICE_TO_PLAN[priceId] || 'free'; }","contentHash":"sha256:ec5c58acaae881ea39f5ee57deb5866da21ba29ad81e8d9a183b42be3fbd8180","authorName":"ZyVOP","authorUrl":"https://zyvop.com/author/zyvop","authorSameAs":["https://zyvop.com","https://github.com/zyvop","https://x.com/zyvop1"],"category":null,"tags":["Stripe PaymentIntent Node.js","Stripe TypeScript","invoice.paid webhook","Stripe Node.js integration","Stripe subscriptions Node.js","Stripe customer portal","Stripe idempotency","Stripe webhooks 2026"],"audience":"Software engineers and developers building applications with Stripe PaymentIntent Node.js","tone":"Practical and evidence-based engineering guidance","readingTimeMinutes":6,"wordCount":1413,"faqs":null,"primaryTopic":"Stripe PaymentIntent Node.js","publishedAt":"2026-05-26T13:07:56.175Z","updatedAt":"2026-08-23T06:45:00.643Z","canonicalUrl":"https://zyvop.com/stripe-integration-in-node-js-payments-subscriptions-and-the-edge-cases-that-cost-you-money-vfsa4"}