Dec 27, 2024
Building Scalable E-Commerce with Stripe: Payment Architecture for Growth
10 min · Stripe · E-Commerce · Payments · Architecture · Integration
Building Scalable E-Commerce with Stripe: Payment Architecture for Growth
Stripe has become the de facto standard for online payments. It handles complexity—PCI compliance, fraud prevention, payment methods, currencies—so you can focus on your business.
However, integrating Stripe into a scalable e-commerce platform requires thoughtful architecture. This guide covers production patterns used by companies processing billions in GMV.
Stripe Architecture Overview
A robust e-commerce payment system has these components:
┌─────────────────────────────────────┐
│ E-Commerce Frontend │
│ (Checkout Page, Payment Form) │
└────────────┬────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Stripe Elements / Payment Element │
│ (Client-side payment capture) │
└────────────┬────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Your Backend API │
│ (Create PaymentIntent, Confirm) │
└────────┬──────────────────────────┬──┘
│ │
▼ ▼
┌─────────────┐ ┌──────────────┐
│ Stripe API │ │ Webhooks │
│ (Sync) │ │ (Async) │
└──────┬──────┘ └──────┬───────┘
│ │
└───────────┬───────────┘
▼
┌────────────────────────┐
│ Database │
│ (Payments, Orders) │
└────────────────────────┘
Setting Up Stripe
Install Dependencies
npm install stripe
npm install --save-dev @stripe/stripe-js
Environment Setup
# .env.local
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_your_key
STRIPE_SECRET_KEY=sk_live_your_key
STRIPE_WEBHOOK_SECRET=whsec_your_secret
Initialize Stripe Client
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-10',
appInfo: {
name: 'My E-Commerce Store',
version: '1.0.0',
},
});
Payment Intent Flow
The Payment Intent is Stripe's recommended approach for handling payments:
interface CreatePaymentIntentRequest {
orderId: string;
customerId: string;
amount: number;
currency: string;
email: string;
}
export async function createPaymentIntent(
request: CreatePaymentIntentRequest
): Promise<{ clientSecret: string; paymentIntentId: string }> {
try {
// Verify order exists and amount is correct
const order = await db.order.findUnique({
where: { id: request.orderId },
});
if (!order) {
throw new Error('Order not found');
}
if (order.totalAmount * 100 !== request.amount) {
throw new Error('Amount mismatch');
}
// Check for existing payment intent
const existingPayment = await db.payment.findUnique({
where: { orderId: request.orderId },
});
if (
existingPayment &&
existingPayment.status === 'succeeded'
) {
throw new Error('Order already paid');
}
// Create or update payment intent
let paymentIntent;
if (
existingPayment &&
existingPayment.stripePaymentIntentId
) {
// Update existing intent
paymentIntent = await stripe.paymentIntents.update(
existingPayment.stripePaymentIntentId,
{
amount: request.amount,
description: `Order ${request.orderId}`,
metadata: {
orderId: request.orderId,
customerId: request.customerId,
},
}
);
} else {
// Create new intent
paymentIntent = await stripe.paymentIntents.create({
amount: request.amount, // Amount in cents
currency: request.currency,
description: `Order ${request.orderId}`,
customer: request.customerId,
receipt_email: request.email,
metadata: {
orderId: request.orderId,
customerId: request.customerId,
},
// Automatic payment methods for modern wallets
automatic_payment_methods: {
enabled: true,
},
// Save payment method for future use
setup_future_usage: 'on_session',
});
}
// Store/update payment record
await db.payment.upsert({
where: { orderId: request.orderId },
create: {
orderId: request.orderId,
customerId: request.customerId,
stripePaymentIntentId: paymentIntent.id,
amount: request.amount,
currency: request.currency,
status: 'created',
metadata: paymentIntent.metadata,
},
update: {
stripePaymentIntentId: paymentIntent.id,
status: 'created',
},
});
return {
clientSecret: paymentIntent.client_secret!,
paymentIntentId: paymentIntent.id,
};
} catch (error) {
console.error('Payment intent creation failed:', error);
throw error;
}
}
Frontend: Collecting Payment Details
Use Stripe Elements for secure payment collection:
// app/checkout/page.tsx
'use client';
import { loadStripe } from '@stripe/stripe-js';
import {
Elements,
PaymentElement,
useStripe,
useElements,
} from '@stripe/react-stripe-js';
import { useState } from 'react';
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);
export default function CheckoutPage() {
const [orderId, setOrderId] = useState<string>('');
return (
<div className="max-w-2xl mx-auto py-12">
<h1 className="text-3xl font-bold mb-8">Checkout</h1>
<Elements
stripe={stripePromise}
options={{
mode: 'payment',
amount: 5000, // $50.00 in cents
currency: 'usd',
appearance: {
theme: 'dark',
variables: {
colorPrimary: '#3b82f6',
},
},
}}
>
<CheckoutForm orderId={orderId} />
</Elements>
</div>
);
}
interface CheckoutFormProps {
orderId: string;
}
function CheckoutForm({ orderId }: CheckoutFormProps) {
const stripe = useStripe();
const elements = useElements();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!stripe || !elements) {
return;
}
setLoading(true);
setError(null);
try {
// Step 1: Create payment intent on backend
const response = await fetch('/api/checkout/create-payment-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderId }),
});
if (!response.ok) {
throw new Error('Failed to create payment intent');
}
const { clientSecret } = await response.json();
// Step 2: Confirm payment with Stripe
setProcessing(true);
const result = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: {
return_url: `${window.location.origin}/checkout/success`,
},
});
if (result.error) {
setError(result.error.message || 'Payment failed');
} else if (result.paymentIntent?.status === 'succeeded') {
// Payment successful - redirect to success page
window.location.href = '/checkout/success';
}
} catch (err) {
setError(
err instanceof Error ? err.message : 'An error occurred'
);
} finally {
setLoading(false);
setProcessing(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Payment Element handles all payment methods */}
<PaymentElement />
{error && (
<div className="text-red-600 text-sm">
{error}
</div>
)}
<button
disabled={!stripe || processing || loading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-bold py-3 rounded-lg transition"
>
{processing ? 'Processing...' : 'Pay Now'}
</button>
</form>
);
}
Webhook Handling
Webhooks are critical for handling async payment events. Never rely solely on client-side confirmations:
// app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe';
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const body = await request.text();
const headersList = await headers();
const signature = headersList.get('stripe-signature');
if (!signature) {
return NextResponse.json(
{ error: 'No signature' },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return NextResponse.json(
{ error: 'Signature verification failed' },
{ status: 400 }
);
}
try {
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSucceeded(
event.data.object as Stripe.PaymentIntent
);
break;
case 'payment_intent.payment_failed':
await handlePaymentFailed(
event.data.object as Stripe.PaymentIntent
);
break;
case 'charge.refunded':
await handleRefund(event.data.object as Stripe.Charge);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdated(
event.data.object as Stripe.Subscription
);
break;
case 'invoice.payment_succeeded':
await handleInvoicePaymentSucceeded(
event.data.object as Stripe.Invoice
);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
} catch (err) {
console.error('Webhook processing failed:', err);
// Return 500 so Stripe retries
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
);
}
}
async function handlePaymentSucceeded(
paymentIntent: Stripe.PaymentIntent
): Promise<void> {
const orderId = paymentIntent.metadata?.orderId;
if (!orderId) {
console.error('No orderId in payment intent metadata');
return;
}
// Update payment status
await db.payment.update({
where: { orderId },
data: {
status: 'succeeded',
stripePaymentIntentId: paymentIntent.id,
stripeChargeId:
typeof paymentIntent.charges.data[0] === 'string'
? paymentIntent.charges.data[0]
: paymentIntent.charges.data[0]?.id,
confirmedAt: new Date(),
},
});
// Update order status
await db.order.update({
where: { id: orderId },
data: {
status: 'paid',
paidAt: new Date(),
},
});
// Send confirmation email
const order = await db.order.findUnique({
where: { id: orderId },
include: { customer: true, items: true },
});
if (order) {
await sendOrderConfirmationEmail(order);
}
// Trigger fulfillment
await startFulfillmentProcess(orderId);
}
async function handlePaymentFailed(
paymentIntent: Stripe.PaymentIntent
): Promise<void> {
const orderId = paymentIntent.metadata?.orderId;
if (!orderId) {
console.error('No orderId in payment intent metadata');
return;
}
// Update payment status
await db.payment.update({
where: { orderId },
data: {
status: 'failed',
failureReason:
paymentIntent.last_payment_error?.message || 'Unknown error',
},
});
// Update order status
await db.order.update({
where: { id: orderId },
data: {
status: 'payment_failed',
},
});
// Notify customer
const order = await db.order.findUnique({
where: { id: orderId },
include: { customer: true },
});
if (order) {
await sendPaymentFailedEmail(
order.customer.email,
order
);
}
}
async function handleRefund(charge: Stripe.Charge): Promise<void> {
// Find associated order through payment
const payment = await db.payment.findUnique({
where: { stripeChargeId: charge.id },
});
if (!payment) {
console.warn(`No payment found for charge ${charge.id}`);
return;
}
// Create refund record
await db.refund.create({
data: {
orderId: payment.orderId,
amount: charge.amount_refunded,
stripeRefundId: charge.refunded ? charge.id : null,
reason: charge.metadata?.reason || 'Customer request',
processedAt: new Date(),
},
});
// Update order if fully refunded
if (charge.refunded === charge.amount) {
await db.order.update({
where: { id: payment.orderId },
data: { status: 'refunded' },
});
}
}
Subscription Handling
For recurring payments:
interface CreateSubscriptionRequest {
customerId: string;
priceId: string;
paymentMethodId?: string;
trialDays?: number;
}
export async function createSubscription(
request: CreateSubscriptionRequest
): Promise<Stripe.Subscription> {
const subscription = await stripe.subscriptions.create({
customer: request.customerId,
items: [{ price: request.priceId }],
payment_settings: {
save_default_payment_method: 'on_subscription',
default_mandate_id: request.paymentMethodId,
},
trial_period_days: request.trialDays,
metadata: {
createdAt: new Date().toISOString(),
},
});
// Store subscription in database
await db.subscription.create({
data: {
stripeSubscriptionId: subscription.id,
customerId: request.customerId,
priceId: request.priceId,
status: subscription.status,
currentPeriodStart: new Date(
subscription.current_period_start * 1000
),
currentPeriodEnd: new Date(
subscription.current_period_end * 1000
),
},
});
return subscription;
}
export async function cancelSubscription(
subscriptionId: string,
immediate: boolean = false
): Promise<void> {
await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: !immediate,
});
await db.subscription.update({
where: { stripeSubscriptionId: subscriptionId },
data: {
status: 'canceled',
canceledAt: new Date(),
},
});
}
PCI Compliance
Stripe handles most PCI compliance, but you must:
- Never log sensitive data: No card numbers, CVVs, or authentication codes
- Use Stripe for payment processing: Never implement custom payment card handling
- Secure your API keys: Use environment variables, rotate regularly
- Implement HTTPS: All payment data must be encrypted in transit
- Validate data on backend: Don't trust client-side validation for payments
// Good: Never log sensitive payment data
const payment = await stripe.paymentIntents.retrieve(piId);
console.log('Payment status:', payment.status); // OK
// console.log('Card:', payment.payment_method); // NEVER do this
// Good: Validate amounts on backend before charging
const orderAmount = (await db.order.findUnique({ where: { id } }))
.totalAmount;
if (orderAmount * 100 !== paymentAmount) {
throw new Error('Amount mismatch - potential fraud');
}
Fraud Prevention
Leverage Stripe's fraud tools:
// Enable Radar for fraud detection
const paymentIntent = await stripe.paymentIntents.create({
amount: 5000,
currency: 'usd',
radar_options: {
session: sessionId, // Track customer session
},
// Risk assessment rules
statement_descriptor: 'MY STORE',
});
// Handle declined charges
if (
paymentIntent.last_payment_error?.decline_code ===
'declined'
) {
// Potential fraud or insufficient funds
// Notify fraud team and customer
}
Scaling Considerations
Rate Limiting: Stripe has rate limits. Implement exponential backoff:
async function stripeWithRetry<T>(
fn: () => Promise<T>,
maxRetries: number = 3
): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (
error instanceof Stripe.errors.RateLimitError &&
attempt < maxRetries - 1
) {
const delay = Math.pow(2, attempt) * 1000; // exponential backoff
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
throw new Error('Max retries exceeded');
}
Idempotency: Always use idempotency keys for critical operations:
const paymentIntent = await stripe.paymentIntents.create(
{
amount: 5000,
currency: 'usd',
},
{
idempotencyKey: `order-${orderId}-${Date.now()}`,
}
);
Analytics & Monitoring
Track payment metrics:
interface PaymentMetrics {
totalProcessed: number;
successRate: number;
averageAmount: number;
declinedTransactions: number;
refundRate: number;
avgProcessingTime: number;
}
async function getPaymentMetrics(
period: 'day' | 'week' | 'month'
): Promise<PaymentMetrics> {
const startDate = getStartDate(period);
const payments = await db.payment.findMany({
where: { createdAt: { gte: startDate } },
});
const successful = payments.filter(
(p) => p.status === 'succeeded'
).length;
const failed = payments.filter(
(p) => p.status === 'failed'
).length;
const refunded = payments.filter(
(p) => p.status === 'refunded'
).length;
return {
totalProcessed: successful + failed,
successRate: (successful / (successful + failed)) * 100,
averageAmount:
payments.reduce((sum, p) => sum + p.amount, 0) /
payments.length,
declinedTransactions: failed,
refundRate: (refunded / successful) * 100,
avgProcessingTime: 0, // Calculate from timestamps
};
}
Conclusion
Stripe provides the infrastructure for processing payments reliably and securely. By implementing proper architecture patterns—Payment Intents, webhook handling, idempotency—you can build payment systems that scale to millions in transactions while maintaining security and reliability.
The key is treating payments as a critical system: monitor it obsessively, test thoroughly, and always have fallback strategies for failures.