stripe-payments
Stripe integration covering Checkout, Payment Intents, subscriptions, webhook verification, idempotency, and SCA
Stripe Payments Integration
Secure payment flows with webhook handling, idempotency, and SCA support.
Checkout Sessions
BAD: Client-side price, no idempotency
// ❌ Trusting client data, XSS risk
app.post('/checkout', async (req, res) => {
const { priceId, amount } = req.body; // Never trust client
const session = await stripe.checkout.sessions.create({
line_items: [{ price: priceId, quantity: 1 }],
success_url: req.body.successUrl, // XSS risk
});
res.json({ url: session.url });
});
GOOD: Server-side price lookup, idempotency, metadata
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
});
app.post('/checkout', async (req, res) => {
const { productId, userId } = req.body;
const prices = await stripe.prices.list({ product: productId, active: true });
if (!prices.data.length) return res.status(400).json({ error: 'Invalid product' });
const session = await stripe.checkout.sessions.create({
line_items: [{ price: prices.data[0].id, quantity: 1 }],
mode: 'payment',
success_url: `${process.env.BASE_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.BASE_URL}/cancel`,
metadata: { userId, productId },
}, {
idempotencyKey: `checkout_${userId}_${productId}_${Date.now()}`,
});
res.json({ url: session.url });
});
Payment Intents with SCA
BAD: Deprecated Charges API, no 3D Secure
// ❌ Deprecated, no SCA support
app.post('/charge', async (req, res) => {
await stripe.charges.create({
amount: req.body.amount,
source: req.body.token, // Deprecated
});
});
GOOD: Payment Intent with automatic SCA, React Elements
// Server-side
app.post('/payment-intent', async (req, res) => {
const order = await db.orders.findUnique({ where: { id: req.body.orderId } });
if (!order) return res.status(404).json({ error: 'Order not found' });
const paymentIntent = await stripe.paymentIntents.create({
amount: order.totalCents,
currency: 'usd',
automatic_payment_methods: { enabled: true },
metadata: { orderId: order.id },
}, { idempotencyKey: `pi_${order.id}` });
res.json({ clientSecret: paymentIntent.client_secret });
});
// Client-side React
import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
function CheckoutForm() {
const stripe = useStripe();
const elements = useElements();
return (
<form onSubmit={async (e) => {
e.preventDefault();
if (!stripe || !elements) return;
await stripe.confirmPayment({
elements,
confirmParams: { return_url: `${window.location.origin}/complete` },
});
}}>
<PaymentElement />
<button disabled={!stripe}>Pay</button>
</form>
);
}
Subscriptions
BAD: Duplicate customers, no proration
// ❌ Creates duplicate customers
app.post('/subscribe', async (req, res) => {
const customer = await stripe.customers.create({ email: req.body.email });
const sub = await stripe.subscriptions.create({ customer: customer.id, items: [{ price: req.body.priceId }] });
res.json(sub);
});
GOOD: Idempotent customer, trials, proration
async function subscribe(userId: string, priceId: string, trialDays?: number) {
const user = await db.users.findUnique({ where: { id: userId } });
const customer = user.stripeCustomerId
? await stripe.customers.retrieve(user.stripeCustomerId) as Stripe.Customer
: await stripe.customers.create(
{ email: user.email, metadata: { userId } },
{ idempotencyKey: `customer_${userId}` }
);
if (!user.stripeCustomerId) {
await db.users.update({ where: { id: userId }, data: { stripeCustomerId: customer.id } });
}
return await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: priceId }],
trial_period_days: trialDays,
payment_settings: { save_default_payment_method: 'on_subscription' },
metadata: { userId },
}, { idempotencyKey: `sub_${userId}_${priceId}` });
}
// Upgrade/downgrade with proration
async function changePlan(subId: string, newPriceId: string) {
const sub = await stripe.subscriptions.retrieve(subId);
return await stripe.subscriptions.update(subId, {
items: [{ id: sub.items.data[0].id, price: newPriceId }],
proration_behavior: 'create_prorations',
});
}
Webhook Verification (Critical)
BAD: No signature verification
// ❌ CRITICAL SECURITY FLAW
app.post('/webhook', async (req, res) => {
const event = req.body;
await fulfillOrder(event.data.object.metadata.orderId); // Attacker-controlled!
});
GOOD: Signature verification, idempotent processing
import { buffer } from 'micro';
export const config = { api: { bodyParser: false } }; // Next.js
export default async function webhook(req, res) {
const buf = await buffer(req);
const sig = req.headers['stripe-signature']!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(buf, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Idempotency check
const existing = await db.webhookEvents.findUnique({ where: { stripeEventId: event.id } });
if (existing) return res.json({ received: true, duplicate: true });
await db.webhookEvents.create({ data: { stripeEventId: event.id, type: event.type } });
switch (event.type) {
case 'payment_intent.succeeded':
await handlePayment(event.data.object as Stripe.PaymentIntent);
break;
case 'customer.subscription.updated':
await syncSubscription(event.data.object as Stripe.Subscription);
break;
}
res.json({ received: true });
}
Customer Portal and Refunds
// Self-service billing portal
app.post('/portal', async (req, res) => {
const user = await db.users.findUnique({ where: { id: req.body.userId } });
if (!user?.stripeCustomerId) return res.status(400).json({ error: 'No customer' });
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.BASE_URL}/account`,
});
res.json({ url: session.url });
});
// Refunds with idempotency
async function refund(paymentIntentId: string) {
return await stripe.refunds.create({
payment_intent: paymentIntentId,
}, { idempotencyKey: `refund_${paymentIntentId}` });
}
// Cancel subscription
async function cancelSub(subId: string, immediately = false) {
return immediately
? await stripe.subscriptions.cancel(subId)
: await stripe.subscriptions.update(subId, { cancel_at_period_end: true });
}
Error Handling
BAD: Generic errors, no retry
// ❌ No error specificity
try {
await stripe.paymentIntents.create({ /* ... */ });
} catch (err) {
res.status(500).json({ error: 'Something went wrong' });
}
GOOD: Type-specific errors with retry logic
async function createPayment(params: Stripe.PaymentIntentCreateParams, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await stripe.paymentIntents.create(params, {
idempotencyKey: params.metadata?.orderId,
});
} catch (err) {
if (err instanceof Stripe.errors.StripeCardError) {
throw new Error(`Card declined: ${err.message}`); // Don't retry
} else if (err instanceof Stripe.errors.StripeRateLimitError) {
if (i < retries - 1) {
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
continue;
}
} else if (err instanceof Stripe.errors.StripeAuthenticationError) {
throw new Error('Invalid API key'); // Don't retry
}
throw err;
}
}
throw new Error('Payment failed after retries');
}
Testing
// Test cards
const TEST_CARDS = {
success: '4242424242424242',
declined: '4000000000000002',
requiresAuth: '4000002500003155', // 3D Secure
insufficientFunds: '4000000000009995',
};
// Test clocks for subscription testing
async function createTestClock(customerId: string) {
const clock = await stripe.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000),
});
await stripe.customers.update(customerId, { test_clock: clock.id });
return clock;
}
// Advance time
async function advanceTime(clockId: string, days: number) {
const clock = await stripe.testHelpers.testClocks.retrieve(clockId);
return await stripe.testHelpers.testClocks.advance(clockId, {
frozen_time: clock.frozen_time + (days * 86400),
});
}
Configuration
# .env.local
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
// Validate on startup
if (!process.env.STRIPE_SECRET_KEY) throw new Error('STRIPE_SECRET_KEY required');
if (!process.env.STRIPE_WEBHOOK_SECRET) console.warn('Webhooks will fail without STRIPE_WEBHOOK_SECRET');
const isTestMode = process.env.STRIPE_SECRET_KEY.startsWith('sk_test_');
console.log(`Stripe: ${isTestMode ? 'TEST' : 'LIVE'} mode`);