Stripe Integration in Node.js: Payments, Subscriptions, and the Edge Cases That Cost You Money
Build a production-ready Stripe integration in Node.js with subscriptions, webhooks, retries, idempotency, and real-world payment edge cases.
Senior Developer

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) => {
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<Record<Stripe.Event.Type, (event: Stripe.Event) => Promise<void>>> = {
'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) => {
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 => 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) => {
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<string, string> = {
[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';
}
Comments (0)
Login to post a comment.