Everything you need to accept payments on the web. From understanding how card payments actually work to building production-ready Stripe integrations with Node.js and TypeScript -- payment intents, checkout, subscriptions, webhooks, error handling, and security.
Before writing a single line of Stripe code, you need to understand what happens when a customer swipes, taps, or types their card number. The payment lifecycle involves multiple parties and distinct phases.
Customer -> Merchant -> Acquirer (merchant's bank) -> Card Network (Visa/MC) -> Issuer (customer's bank)
Response flows back the same path in reverse.
When a customer submits payment, an authorization request travels from the merchant through the acquiring bank, through the card network (Visa, Mastercard, Amex), to the issuing bank. The issuer checks if the card is valid, has sufficient funds, passes fraud checks, and then sends back an approval or decline. This entire round-trip happens in under 2 seconds. No money has moved yet -- the issuer has only placed a hold on the funds.
After authorization, the merchant captures the payment. This tells the system "yes, I want this money." In many integrations, authorization and capture happen simultaneously (called an auto-capture). But some businesses (hotels, car rentals, marketplaces) authorize first and capture later -- this is called auth-and-capture or manual capture.
A hotel authorizes $500 at check-in but only captures $350 at checkout (the actual stay cost). Authorizations expire after ~7 days, so you must capture within that window or re-authorize.
Settlement is when money actually moves between banks. The card network batches up all the day's captured transactions and orchestrates fund transfers between issuing and acquiring banks. This typically takes 1-3 business days. Stripe abstracts this -- you see payouts to your bank account on a rolling basis (usually 2 business days after the charge).
A refund is merchant-initiated: you return funds to the customer. A chargeback (or dispute) is customer-initiated: they contact their bank to reverse the charge. Chargebacks cost you the transaction amount plus a fee ($15 on Stripe). Too many chargebacks and your account gets flagged or terminated.
Card networks flag merchants with dispute rates above 0.9% (Visa) or 1.0% (Mastercard). If you cross this threshold, you enter a monitoring program with escalating fines. Track your dispute rate in the Stripe Dashboard under Radar.
Stripe is an API-first payment processor. Everything -- charges, customers, subscriptions, payouts -- is a REST API call. The Dashboard is just a UI on top of the same API you use.
Every Stripe resource is a JSON object with a unique ID (e.g., pi_1234abc for a PaymentIntent, cus_xyz for a Customer). You create, read, update, and delete these via HTTP requests. The Node.js SDK wraps these calls into clean async methods.
Stripe gives you two completely isolated environments. Test mode uses fake card numbers, generates no real charges, and has its own Dashboard view. Live mode processes real money. Each mode has its own API keys. You toggle between them in the Dashboard with a single switch.
sk_test_... -- Secret key (test mode). Server-side only.
pk_test_... -- Publishable key (test mode). Safe for client-side.
sk_live_... -- Secret key (live mode). NEVER expose this.
pk_live_... -- Publishable key (live mode). Client-side.
The secret key can do anything: create charges, read customer data, issue refunds. It lives exclusively on your server. The publishable key can only do limited operations like tokenizing card details. It is safe to include in frontend JavaScript. Leaking your secret key is a catastrophic security incident.
Store Stripe keys in environment variables. Never hardcode them. Never commit .env files. Use Stripe's restricted keys in production to limit permissions to only what each service needs.
Stripe versions its API by date (e.g., 2024-06-20). Your account is pinned to the version you signed up with. You can override per-request with the Stripe-Version header, or set a default in your SDK initialization. Stripe never removes fields from existing versions -- breaking changes only appear in new versions.
bash
# Install Stripe Node.js SDK
npm install stripe
# If using TypeScript (types are included in the stripe package)
npm install stripe typescript @types/node
# Install dotenv for environment variables
npm install dotenv
.env
# Stripe API Keys
STRIPE_SECRET_KEY=sk_test_51ABC123...
STRIPE_PUBLISHABLE_KEY=pk_test_51ABC123...
STRIPE_WEBHOOK_SECRET=whsec_abc123...
# App config
PORT=3000
CLIENT_URL=http://localhost:5173
TypeScript
import Stripe from 'stripe';
import dotenv from 'dotenv';
dotenv.config();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
typescript: true,
});
export default stripe;
The Stripe SDK has excellent TypeScript support. Every API response is fully typed, every parameter has autocomplete, and you get compile-time errors for invalid fields. Always use TypeScript with Stripe -- the type safety alone prevents countless bugs.
text
my-stripe-app/
src/
config/
stripe.ts // Stripe instance
routes/
payments.ts // Payment endpoints
webhooks.ts // Webhook handler
subscriptions.ts // Subscription endpoints
services/
stripe.service.ts // Business logic
middleware/
auth.ts // Authentication
index.ts // Express app
.env
package.json
tsconfig.json
PaymentIntent is Stripe's core payment object. It represents the lifecycle of a single payment from creation to confirmation to completion. It handles 3D Secure authentication, retries, and state management automatically.
requires_payment_method -> requires_confirmation -> requires_action (optional, for 3DS) -> processing -> succeeded / requires_capture
TypeScript
import express from 'express';
import stripe from '../config/stripe';
const router = express.Router();
// POST /api/create-payment-intent
router.post('/create-payment-intent', async (req, res) => {
try {
const { amount, currency = 'usd', metadata } = req.body;
// Validate amount (Stripe uses smallest currency unit -- cents for USD)
if (!amount || amount < 50) {
return res.status(400).json({ error: 'Minimum amount is $0.50' });
}
const paymentIntent = await stripe.paymentIntents.create({
amount, // e.g., 2000 = $20.00
currency,
metadata: metadata || {},
automatic_payment_methods: {
enabled: true, // Accept cards, wallets, etc.
},
});
// Send the client_secret to the frontend
res.json({
clientSecret: paymentIntent.client_secret,
paymentIntentId: paymentIntent.id,
});
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;
TypeScript
// Frontend: React or vanilla JS
import { loadStripe } from '@stripe/stripe-js';
const stripePromise = loadStripe('pk_test_...');
async function handlePayment() {
const stripe = await stripePromise;
if (!stripe) return;
// 1. Create PaymentIntent on the server
const response = await fetch('/api/create-payment-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 2000 }),
});
const { clientSecret } = await response.json();
// 2. Confirm with Stripe.js (handles 3D Secure automatically)
const { error, paymentIntent } = await stripe.confirmCardPayment(clientSecret, {
payment_method: {
card: cardElement, // From Stripe Elements
billing_details: {
name: 'Sean',
},
},
});
if (error) {
console.error(error.message);
} else if (paymentIntent.status === 'succeeded') {
console.log('Payment succeeded!');
}
}
The client_secret lets the frontend confirm the payment without exposing your secret key. It is scoped to one PaymentIntent and cannot be used to create new payments or access other data. The server creates the intent, the client confirms it -- this split is the core Stripe security model.
TypeScript
// Create with manual capture
const paymentIntent = await stripe.paymentIntents.create({
amount: 50000, // $500 hold
currency: 'usd',
capture_method: 'manual',
automatic_payment_methods: { enabled: true },
});
// Later: capture a different (lower) amount
const captured = await stripe.paymentIntents.capture(paymentIntent.id, {
amount_to_capture: 35000, // Only capture $350
});
Stripe Checkout is a pre-built, hosted payment page. You redirect customers to Stripe's domain, they pay, and Stripe redirects them back. Zero frontend payment UI code. It handles card input, validation, 3D Secure, Apple Pay, Google Pay, and localization automatically.
Use Checkout when you want the fastest integration, highest conversion rates (Stripe A/B tests it constantly), and minimal PCI scope. It is the recommended approach for most businesses unless you need a fully custom payment UI.
TypeScript
// POST /api/create-checkout-session
router.post('/create-checkout-session', async (req, res) => {
try {
const session = await stripe.checkout.sessions.create({
mode: 'payment',
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: 'usd',
product_data: {
name: 'Pro License',
description: 'Lifetime access to all features',
images: ['https://example.com/product.png'],
},
unit_amount: 4999, // $49.99
},
quantity: 1,
},
],
success_url: `${process.env.CLIENT_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.CLIENT_URL}/cancel`,
metadata: {
userId: req.body.userId,
},
});
res.json({ url: session.url });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
TypeScript
// Frontend: redirect to Stripe Checkout
async function goToCheckout() {
const response = await fetch('/api/create-checkout-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: 'user_123' }),
});
const { url } = await response.json();
window.location.href = url; // Redirect to Stripe's hosted page
}
TypeScript
// Checkout for recurring payments
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [
{
price: 'price_monthly_pro', // Pre-created Price ID
quantity: 1,
},
],
success_url: `${process.env.CLIENT_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.CLIENT_URL}/pricing`,
customer_email: 'user@example.com',
subscription_data: {
trial_period_days: 14,
metadata: { plan: 'pro' },
},
});
payment -- One-time charge
subscription -- Recurring billing
setup -- Save a card for later (no charge now)
Stripe Elements lets you embed payment inputs directly in your page with full control over styling. The PaymentElement is the modern, recommended component -- it renders all payment methods (cards, wallets, bank transfers) in a single, adaptive UI.
TypeScript
// Frontend: initialize Stripe Elements
import { loadStripe } from '@stripe/stripe-js';
const stripe = await loadStripe('pk_test_...');
// Fetch client secret from your server
const response = await fetch('/api/create-payment-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 2999 }),
});
const { clientSecret } = await response.json();
// Create Elements instance with appearance customization
const elements = stripe.elements({
clientSecret,
appearance: {
theme: 'stripe', // or 'night', 'flat', 'none'
variables: {
colorPrimary: '#0570de',
borderRadius: '8px',
fontFamily: 'Inter, sans-serif',
},
},
});
// Mount the PaymentElement
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
HTML
<form id="payment-form">
<div id="payment-element">
<!-- Stripe injects the PaymentElement here -->
</div>
<button id="submit" type="submit">Pay now</button>
<div id="error-message"></div>
</form>
TypeScript
const form = document.getElementById('payment-form')!;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = document.getElementById('submit') as HTMLButtonElement;
submitBtn.disabled = true;
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: 'http://localhost:5173/success',
},
});
// This point is only reached if there is an immediate error
// (e.g., card declined). Otherwise, the customer is redirected.
if (error) {
const errorDiv = document.getElementById('error-message')!;
errorDiv.textContent = error.message || 'An unexpected error occurred.';
submitBtn.disabled = false;
}
});
Always use Stripe Elements or Checkout. If you collect raw card numbers yourself, you must be PCI DSS Level 1 certified -- an extremely expensive, time-consuming audit. Elements and Checkout handle card data on Stripe's servers, keeping you at PCI SAQ-A (the lightest level).
Stripe's subscription system is built on two core objects: Products (what you sell) and Prices (how much it costs). A Product can have many Prices (monthly, yearly, different tiers). A Subscription ties a Customer to one or more Prices.
TypeScript
// Usually you create these in the Dashboard, but here's the API way
const product = await stripe.products.create({
name: 'Pro Plan',
description: 'All features, unlimited projects',
});
// Monthly price
const monthlyPrice = await stripe.prices.create({
product: product.id,
unit_amount: 1999, // $19.99/month
currency: 'usd',
recurring: {
interval: 'month',
},
});
// Yearly price (with discount)
const yearlyPrice = await stripe.prices.create({
product: product.id,
unit_amount: 19990, // $199.90/year (~$16.66/month)
currency: 'usd',
recurring: {
interval: 'year',
},
});
TypeScript
// Create subscription for an existing customer
const subscription = await stripe.subscriptions.create({
customer: 'cus_abc123',
items: [
{ price: 'price_monthly_pro' },
],
payment_behavior: 'default_incomplete',
payment_settings: {
save_default_payment_method: 'on_subscription',
},
expand: ['latest_invoice.payment_intent'],
});
// Send client_secret to frontend to confirm the first payment
const clientSecret =
(subscription.latest_invoice as Stripe.Invoice)
.payment_intent as Stripe.PaymentIntent;
res.json({ clientSecret: clientSecret.client_secret });
TypeScript
// 14-day free trial -- no charge until trial ends
const subscription = await stripe.subscriptions.create({
customer: 'cus_abc123',
items: [{ price: 'price_monthly_pro' }],
trial_period_days: 14,
});
// Trial with no card required (collect card later)
const trialSub = await stripe.subscriptions.create({
customer: 'cus_abc123',
items: [{ price: 'price_monthly_pro' }],
trial_period_days: 14,
payment_settings: {
save_default_payment_method: 'on_subscription',
},
trial_settings: {
end_behavior: { missing_payment_method: 'cancel' },
},
});
When a customer upgrades or downgrades mid-cycle, Stripe calculates the prorated amount automatically. If a customer on a $10/month plan upgrades to $20/month halfway through the cycle, they get credited $5 for the unused portion and charged $10 for the remaining half on the new plan.
TypeScript
// Upgrade a subscription (proration happens automatically)
const subscription = await stripe.subscriptions.retrieve('sub_abc123');
await stripe.subscriptions.update('sub_abc123', {
items: [
{
id: subscription.items.data[0].id,
price: 'price_yearly_pro', // Switch to yearly
},
],
proration_behavior: 'create_prorations', // or 'none' or 'always_invoice'
});
TypeScript
// Cancel at end of current billing period (most common)
await stripe.subscriptions.update('sub_abc123', {
cancel_at_period_end: true,
});
// Cancel immediately (prorated refund)
await stripe.subscriptions.cancel('sub_abc123', {
prorate: true,
});
// Pause instead of cancel (resume later)
await stripe.subscriptions.update('sub_abc123', {
pause_collection: {
behavior: 'mark_uncollectible', // or 'keep_as_draft', 'void'
},
});
trialing -- In trial period
active -- Paid and current
past_due -- Payment failed, retrying
unpaid -- All retries exhausted
canceled -- Terminated
incomplete -- First payment not yet confirmed
incomplete_expired -- First payment window expired
paused -- Collection paused
Webhooks are how Stripe tells your server about events: a payment succeeded, a subscription was cancelled, a dispute was created. They are not optional -- they are essential. You cannot build a reliable Stripe integration without webhooks.
The success_url redirect after Checkout is NOT reliable. The customer might close the tab, lose connection, or their browser might crash. The ONLY reliable way to know a payment succeeded is via the checkout.session.completed webhook. Always fulfill orders in your webhook handler, not on the success page.
TypeScript
import express from 'express';
import Stripe from 'stripe';
import stripe from '../config/stripe';
const router = express.Router();
// CRITICAL: This route must use express.raw(), NOT express.json()
router.post(
'/webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: any) {
console.error(`Webhook signature verification failed: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle the event
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data.object as Stripe.PaymentIntent);
break;
case 'payment_intent.payment_failed':
await handlePaymentFailure(event.data.object as Stripe.PaymentIntent);
break;
case 'checkout.session.completed':
await handleCheckoutComplete(event.data.object as Stripe.Checkout.Session);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdate(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCanceled(event.data.object as Stripe.Subscription);
break;
case 'invoice.payment_failed':
await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
// Always return 200 quickly -- do heavy processing async
res.json({ received: true });
}
);
Signature verification needs the raw request body. If you use express.json() on the webhook route, the body gets parsed and the signature check fails. Apply express.raw() specifically to the webhook route, and express.json() to everything else.
Every webhook request includes a Stripe-Signature header. You verify it using your webhook secret (whsec_...). This prevents attackers from sending fake webhook events to your endpoint. Never skip signature verification in production.
TypeScript
async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId;
if (!userId) {
console.error('No userId in session metadata');
return;
}
if (session.mode === 'payment') {
// One-time payment: grant access
await db.users.update({
where: { id: userId },
data: { hasPro: true, stripeCustomerId: session.customer as string },
});
} else if (session.mode === 'subscription') {
// Subscription: store subscription ID
await db.users.update({
where: { id: userId },
data: {
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
plan: 'pro',
},
});
}
}
async function handleSubscriptionCanceled(subscription: Stripe.Subscription) {
const customerId = subscription.customer as string;
await db.users.update({
where: { stripeCustomerId: customerId },
data: { plan: 'free', stripeSubscriptionId: null },
});
}
Stripe may send the same webhook event more than once (network issues, retries). Your handler must be idempotent -- processing the same event twice should produce the same result. Use the event ID (event.id) to deduplicate.
TypeScript
// Idempotent webhook handler with event tracking
async function handleWebhookEvent(event: Stripe.Event) {
// Check if we already processed this event
const existing = await db.stripeEvents.findUnique({
where: { eventId: event.id },
});
if (existing) {
console.log(`Event ${event.id} already processed, skipping`);
return;
}
// Process the event
await processEvent(event);
// Record that we processed it
await db.stripeEvents.create({
data: {
eventId: event.id,
type: event.type,
processedAt: new Date(),
},
});
}
checkout.session.completed -- Customer completed Checkout
payment_intent.succeeded -- Payment confirmed
payment_intent.payment_failed -- Payment declined
customer.subscription.created -- New subscription
customer.subscription.updated -- Plan change, renewal, etc.
customer.subscription.deleted -- Subscription ended
invoice.payment_failed -- Subscription renewal failed
charge.dispute.created -- Chargeback initiated
A Stripe Customer object stores payment methods, billing info, and links to subscriptions and invoices. Always create a Customer for users who will pay more than once -- it enables saved cards, subscription management, and better analytics.
TypeScript
// Create a Stripe customer when a user signs up
async function createStripeCustomer(user: { id: string; email: string; name: string }) {
const customer = await stripe.customers.create({
email: user.email,
name: user.name,
metadata: {
userId: user.id, // Link back to your database
},
});
// Store the Stripe customer ID in your database
await db.users.update({
where: { id: user.id },
data: { stripeCustomerId: customer.id },
});
return customer;
}
TypeScript
// Attach a payment method to a customer
const paymentMethod = await stripe.paymentMethods.attach(
'pm_card_visa', // Payment method ID from the frontend
{ customer: 'cus_abc123' }
);
// Set as default payment method
await stripe.customers.update('cus_abc123', {
invoice_settings: {
default_payment_method: paymentMethod.id,
},
});
TypeScript
// Create a SetupIntent to save a card for later
router.post('/create-setup-intent', async (req, res) => {
const { customerId } = req.body;
const setupIntent = await stripe.setupIntents.create({
customer: customerId,
payment_method_types: ['card'],
});
res.json({ clientSecret: setupIntent.client_secret });
});
// Frontend: confirm the SetupIntent
const { error } = await stripe.confirmCardSetup(clientSecret, {
payment_method: {
card: cardElement,
billing_details: { name: 'Sean' },
},
});
TypeScript
const paymentMethods = await stripe.paymentMethods.list({
customer: 'cus_abc123',
type: 'card',
});
paymentMethods.data.forEach((pm) => {
console.log(`${pm.card?.brand} ending in ${pm.card?.last4}`);
console.log(`Expires: ${pm.card?.exp_month}/${pm.card?.exp_year}`);
});
Stripe offers a pre-built Customer Portal where customers can update their payment method, switch plans, cancel subscriptions, and view invoices -- zero UI code on your end. Enable it in Dashboard > Settings > Customer Portal, then create a portal session:
TypeScript
// Create a Customer Portal session
const portalSession = await stripe.billingPortal.sessions.create({
customer: 'cus_abc123',
return_url: `${process.env.CLIENT_URL}/dashboard`,
});
res.json({ url: portalSession.url });
Payment failures are not bugs -- they are expected. Cards get declined, banks flag transactions, 3D Secure challenges time out. Your integration must handle every failure gracefully.
TypeScript
try {
const paymentIntent = await stripe.paymentIntents.create({
amount: 2000,
currency: 'usd',
});
} catch (error) {
if (error instanceof Stripe.errors.StripeCardError) {
// Card was declined
console.log(`Card declined: ${error.decline_code}`);
// e.g., 'insufficient_funds', 'lost_card', 'stolen_card'
} else if (error instanceof Stripe.errors.StripeRateLimitError) {
// Too many requests -- back off and retry
console.log('Rate limited, retrying...');
} else if (error instanceof Stripe.errors.StripeInvalidRequestError) {
// Invalid parameters (your bug, not the customer's)
console.error(`Invalid request: ${error.message}`);
} else if (error instanceof Stripe.errors.StripeAPIError) {
// Stripe's servers had an error (rare)
console.error('Stripe API error');
} else if (error instanceof Stripe.errors.StripeConnectionError) {
// Network issue between your server and Stripe
console.error('Connection to Stripe failed');
} else if (error instanceof Stripe.errors.StripeAuthenticationError) {
// Invalid API key
console.error('Invalid Stripe API key');
}
}
insufficient_funds -- Not enough money. Ask to try another card.
card_declined -- Generic decline. The issuer did not give a reason.
expired_card -- Card is past its expiration date.
incorrect_cvc -- Wrong CVC/CVV number.
processing_error -- Temporary issue. Retry once.
lost_card / stolen_card -- Do NOT retry. Do NOT tell the customer the specific reason (security risk).
fraudulent -- Stripe Radar flagged it. Review in Dashboard.
European regulations (PSD2) require Strong Customer Authentication for online payments. This usually means a 3D Secure challenge -- a popup or redirect where the customer confirms the payment with their bank (via SMS code, fingerprint, etc.).
TypeScript
// PaymentIntent may require action (3D Secure)
const { error, paymentIntent } = await stripe.confirmCardPayment(clientSecret, {
payment_method: { card: cardElement },
});
if (error) {
if (error.type === 'card_error' || error.type === 'validation_error') {
// Show error to customer
showError(error.message);
} else {
// Unexpected error
showError('An unexpected error occurred.');
}
} else if (paymentIntent.status === 'requires_action') {
// Stripe.js handles the 3DS popup automatically when you use
// confirmCardPayment. This branch usually is not reached.
} else if (paymentIntent.status === 'succeeded') {
showSuccess();
}
If you use confirmCardPayment or confirmPayment from Stripe.js, 3D Secure is handled automatically. Stripe opens the authentication modal, waits for the customer to complete it, and resolves the promise. You do not need to build 3DS handling manually.
TypeScript
// Simple retry wrapper for transient Stripe errors
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
delay = 1000
): Promise<T> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
const isRetryable =
error instanceof Stripe.errors.StripeConnectionError ||
error instanceof Stripe.errors.StripeRateLimitError ||
(error instanceof Stripe.errors.StripeAPIError);
if (!isRetryable || attempt === maxRetries) {
throw error;
}
await new Promise(r => setTimeout(r, delay * attempt));
}
}
throw new Error('Unreachable');
}
// Usage
const customer = await withRetry(() =>
stripe.customers.create({ email: 'user@example.com' })
);
The Payment Card Industry Data Security Standard (PCI DSS) governs how cardholder data is handled. Your compliance level depends on how you collect card data:
SAQ-A (lightest) -- You use Stripe Checkout or Stripe Elements. Card data never touches your server. You fill out a short self-assessment questionnaire annually. This is where you want to be.
SAQ A-EP -- Your page controls the payment form but card data goes directly to Stripe (Elements). Slightly more requirements.
SAQ D (heaviest) -- You handle raw card numbers on your server. Full audit required. Avoid this at all costs.
When a customer enters their card number in Stripe Elements, the data goes directly from the browser to Stripe's servers via an iframe. Your server never sees the card number. Instead, Stripe gives you a token (a PaymentMethod ID like pm_1234) that represents the card. You use this token for all operations.
Customer enters card in Elements iframe -> Browser sends card to Stripe -> Stripe returns token (pm_xxx) -> Your JS sends token to your server -> Your server uses token to create charges
1. NEVER log or store raw card numbers, CVVs, or full card data.
2. ALWAYS use HTTPS in production (Stripe rejects HTTP requests).
3. ALWAYS verify webhook signatures.
4. Store API keys in environment variables, never in code.
5. Use restricted API keys in production (limit permissions per service).
6. Enable Stripe Radar for fraud detection.
7. Validate amounts server-side (never trust the client).
8. Use idempotency keys for critical operations.
TypeScript
// Use idempotency keys to prevent duplicate charges
const paymentIntent = await stripe.paymentIntents.create(
{
amount: 2000,
currency: 'usd',
customer: 'cus_abc123',
},
{
idempotencyKey: `payment_${orderId}_${Date.now()}`,
}
);
// If you retry with the same idempotency key,
// Stripe returns the original result instead of creating a duplicate
Radar is Stripe's built-in machine learning fraud detection system. It analyzes every payment across all Stripe merchants and blocks suspicious ones. It is enabled by default. You can add custom rules in the Dashboard (e.g., block payments from specific countries, require 3DS for transactions over $100).
4242 4242 4242 4242 -- Visa, always succeeds
4000 0000 0000 3220 -- Requires 3D Secure authentication
4000 0000 0000 9995 -- Always declined (insufficient funds)
4000 0000 0000 0002 -- Always declined (generic)
4000 0000 0000 0069 -- Declined, expired card
4000 0000 0000 0127 -- Declined, incorrect CVC
5555 5555 5555 4444 -- Mastercard, always succeeds
Any future expiry date works. Any 3-digit CVC works. Any 5-digit zip works.
bash
# Install Stripe CLI
# macOS
brew install stripe/stripe-cli/stripe
# Login to your Stripe account
stripe login
# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/api/webhook
# Output: Ready! Your webhook signing secret is whsec_abc123...
# Use this secret in your .env for local development
# Trigger a specific event for testing
stripe trigger payment_intent.succeeded
stripe trigger checkout.session.completed
stripe trigger customer.subscription.created
Run stripe listen --forward-to localhost:3000/api/webhook in one terminal and your server in another. Every Stripe event (from test payments in the Dashboard or your app) gets forwarded to your local endpoint. This is the fastest way to develop and debug webhook handlers.
TypeScript
// Using Jest to test Stripe integration
import stripe from '../config/stripe';
describe('Stripe Integration', () => {
it('should create a payment intent', async () => {
const paymentIntent = await stripe.paymentIntents.create({
amount: 2000,
currency: 'usd',
});
expect(paymentIntent.id).toMatch(/^pi_/);
expect(paymentIntent.amount).toBe(2000);
expect(paymentIntent.status).toBe('requires_payment_method');
});
it('should create a customer', async () => {
const customer = await stripe.customers.create({
email: 'test@example.com',
name: 'Test User',
});
expect(customer.id).toMatch(/^cus_/);
expect(customer.email).toBe('test@example.com');
// Clean up
await stripe.customers.del(customer.id);
});
it('should create a checkout session', async () => {
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [
{
price_data: {
currency: 'usd',
product_data: { name: 'Test Product' },
unit_amount: 1000,
},
quantity: 1,
},
],
success_url: 'https://example.com/success',
cancel_url: 'https://example.com/cancel',
});
expect(session.id).toMatch(/^cs_test_/);
expect(session.url).toBeTruthy();
});
});
TypeScript
// Mock webhook event for unit testing
import { Request, Response } from 'express';
function createMockEvent(type: string, data: any): Stripe.Event {
return {
id: `evt_test_${Date.now()}`,
type,
data: { object: data },
api_version: '2024-06-20',
created: Math.floor(Date.now() / 1000),
livemode: false,
object: 'event',
pending_webhooks: 0,
request: null,
} as any;
}
describe('Webhook Handlers', () => {
it('should handle checkout.session.completed', async () => {
const mockSession = {
id: 'cs_test_123',
mode: 'payment',
customer: 'cus_test_123',
metadata: { userId: 'user_1' },
};
const event = createMockEvent('checkout.session.completed', mockSession);
await handleCheckoutComplete(event.data.object as any);
// Assert database was updated
const user = await db.users.findUnique({ where: { id: 'user_1' } });
expect(user?.hasPro).toBe(true);
});
});
Selling a course, template, or license. Customer pays once, gets access forever.
TypeScript
// Server: create Checkout session for one-time purchase
router.post('/buy-course', async (req, res) => {
const { userId, courseId } = req.body;
const course = await db.courses.findUnique({ where: { id: courseId } });
if (!course) return res.status(404).json({ error: 'Course not found' });
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{
price_data: {
currency: 'usd',
product_data: {
name: course.title,
description: course.description,
},
unit_amount: course.priceInCents,
},
quantity: 1,
}],
metadata: { userId, courseId },
success_url: `${process.env.CLIENT_URL}/courses/${courseId}?purchased=true`,
cancel_url: `${process.env.CLIENT_URL}/courses/${courseId}`,
});
res.json({ url: session.url });
});
// Webhook: grant access when payment completes
async function handleCourseCheckout(session: Stripe.Checkout.Session) {
const { userId, courseId } = session.metadata!;
await db.purchases.create({
data: { userId, courseId, stripeSessionId: session.id },
});
}
Monthly/yearly billing with multiple tiers. The most common Stripe use case.
TypeScript
// Pricing page: fetch prices from Stripe
router.get('/pricing', async (req, res) => {
const prices = await stripe.prices.list({
active: true,
expand: ['data.product'],
type: 'recurring',
});
const plans = prices.data.map((price) => ({
id: price.id,
name: (price.product as Stripe.Product).name,
amount: price.unit_amount,
interval: price.recurring?.interval,
}));
res.json(plans);
});
// Subscribe endpoint
router.post('/subscribe', async (req, res) => {
const { userId, priceId } = req.body;
const user = await db.users.findUnique({ where: { id: userId } });
// Ensure customer exists in Stripe
let customerId = user?.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user!.email,
metadata: { userId },
});
customerId = customer.id;
await db.users.update({
where: { id: userId },
data: { stripeCustomerId: customerId },
});
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.CLIENT_URL}/dashboard`,
cancel_url: `${process.env.CLIENT_URL}/pricing`,
});
res.json({ url: session.url });
});
Platforms where customers pay sellers (like Uber, Etsy). Use Stripe Connect to split payments between your platform and sellers.
TypeScript
// Create a connected account for a seller
const account = await stripe.accounts.create({
type: 'express', // Stripe handles onboarding UI
country: 'US',
email: 'seller@example.com',
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
});
// Create an onboarding link for the seller
const accountLink = await stripe.accountLinks.create({
account: account.id,
refresh_url: `${process.env.CLIENT_URL}/seller/retry`,
return_url: `${process.env.CLIENT_URL}/seller/dashboard`,
type: 'account_onboarding',
});
// Payment with platform fee (destination charge)
const paymentIntent = await stripe.paymentIntents.create({
amount: 10000, // $100 total
currency: 'usd',
application_fee_amount: 1500, // $15 platform fee
transfer_data: {
destination: 'acct_seller123', // Seller gets $85
},
});
Express -- Stripe handles onboarding and identity verification. Best for most marketplaces. The seller gets a Stripe Dashboard with limited access.
Standard -- The seller has their own full Stripe account. You redirect them to connect it to your platform via OAuth.
Custom -- You control everything including onboarding UI. Most work, most flexibility. Only for large platforms.
Here is a complete Express + Stripe integration with Checkout, webhooks, and database persistence. This is a real-world SaaS billing backend you can adapt for production.
TypeScript
// src/index.ts
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import paymentRoutes from './routes/payments';
import webhookRoutes from './routes/webhooks';
dotenv.config();
const app = express();
// IMPORTANT: webhook route must use raw body parser
// Register it BEFORE the JSON parser
app.use('/api/webhook', express.raw({ type: 'application/json' }));
// JSON parser for all other routes
app.use(express.json());
app.use(cors({ origin: process.env.CLIENT_URL }));
// Routes
app.use('/api', paymentRoutes);
app.use('/api', webhookRoutes);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
TypeScript
// src/routes/payments.ts
import { Router } from 'express';
import stripe from '../config/stripe';
import { db } from '../db';
const router = Router();
// Create a Checkout session for subscription
router.post('/create-checkout', async (req, res) => {
try {
const { userId, priceId } = req.body;
const user = await db.users.findUnique({ where: { id: userId } });
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Create or retrieve Stripe customer
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
name: user.name,
metadata: { userId: user.id },
});
customerId = customer.id;
await db.users.update({
where: { id: userId },
data: { stripeCustomerId: customerId },
});
}
// Prevent duplicate subscriptions
if (user.stripeSubscriptionId) {
const existingSub = await stripe.subscriptions.retrieve(user.stripeSubscriptionId);
if (existingSub.status === 'active' || existingSub.status === 'trialing') {
return res.status(400).json({ error: 'Already subscribed' });
}
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
subscription_data: {
trial_period_days: 7,
metadata: { userId: user.id },
},
success_url: `${process.env.CLIENT_URL}/dashboard?subscribed=true`,
cancel_url: `${process.env.CLIENT_URL}/pricing`,
});
res.json({ url: session.url });
} catch (error: any) {
console.error('Checkout error:', error);
res.status(500).json({ error: 'Failed to create checkout session' });
}
});
// Create a Customer Portal session
router.post('/customer-portal', async (req, res) => {
try {
const { userId } = req.body;
const user = await db.users.findUnique({ where: { id: userId } });
if (!user?.stripeCustomerId) {
return res.status(400).json({ error: 'No billing account' });
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.CLIENT_URL}/dashboard`,
});
res.json({ url: portalSession.url });
} catch (error: any) {
console.error('Portal error:', error);
res.status(500).json({ error: 'Failed to create portal session' });
}
});
// Get subscription status
router.get('/subscription-status/:userId', async (req, res) => {
try {
const user = await db.users.findUnique({
where: { id: req.params.userId },
});
if (!user?.stripeSubscriptionId) {
return res.json({ status: 'none', plan: 'free' });
}
const subscription = await stripe.subscriptions.retrieve(
user.stripeSubscriptionId
);
res.json({
status: subscription.status,
plan: user.plan,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
});
} catch (error: any) {
res.status(500).json({ error: 'Failed to get subscription status' });
}
});
export default router;
TypeScript
// src/routes/webhooks.ts
import { Router } from 'express';
import Stripe from 'stripe';
import stripe from '../config/stripe';
import { db } from '../db';
const router = Router();
router.post('/webhook', async (req, res) => {
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: any) {
console.error(`Webhook signature failed: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Idempotency check
const processed = await db.stripeEvents.findUnique({
where: { eventId: event.id },
});
if (processed) {
return res.json({ received: true, duplicate: true });
}
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId;
if (session.mode === 'subscription' && userId) {
await db.users.update({
where: { id: userId },
data: {
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
plan: 'pro',
},
});
}
break;
}
case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription;
const customerId = sub.customer as string;
if (sub.status === 'active') {
await db.users.update({
where: { stripeCustomerId: customerId },
data: { plan: 'pro' },
});
} else if (sub.status === 'past_due') {
// Notify user that payment failed
const user = await db.users.findFirst({
where: { stripeCustomerId: customerId },
});
if (user) {
await sendEmail(user.email, 'Payment Failed',
'Your subscription payment failed. Please update your payment method.'
);
}
}
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
const customerId = sub.customer as string;
await db.users.update({
where: { stripeCustomerId: customerId },
data: {
plan: 'free',
stripeSubscriptionId: null,
},
});
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
const customerId = invoice.customer as string;
console.error(`Invoice payment failed for customer ${customerId}`);
// Stripe automatically retries failed invoices
// You can configure retry schedule in Dashboard > Settings > Billing
break;
}
default:
console.log(`Unhandled event: ${event.type}`);
}
// Record the processed event
await db.stripeEvents.create({
data: { eventId: event.id, type: event.type, processedAt: new Date() },
});
} catch (error) {
console.error(`Error processing ${event.type}:`, error);
// Return 500 so Stripe retries the webhook
return res.status(500).json({ error: 'Webhook handler failed' });
}
res.json({ received: true });
});
export default router;
prisma
// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
name String
plan String @default("free") // "free" | "pro"
stripeCustomerId String? @unique
stripeSubscriptionId String? @unique
hasPro Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model StripeEvent {
id String @id @default(cuid())
eventId String @unique // Stripe event ID for idempotency
type String
processedAt DateTime
}
model Purchase {
id String @id @default(cuid())
userId String
courseId String
stripeSessionId String @unique
createdAt DateTime @default(now())
}
Before going live: (1) Switch to live API keys, (2) Set up live webhook endpoint in Dashboard (not just CLI), (3) Enable Stripe Radar, (4) Test with real cards in live mode (refund immediately), (5) Set up alerts for failed payments and disputes, (6) Configure retry schedule for failed subscription payments, (7) Add logging and monitoring for your webhook handler, (8) Set the STRIPE_WEBHOOK_SECRET for your production webhook endpoint.
ALWAYS validate payment amounts on the server. A malicious client could send amount: 1 to your create-payment-intent endpoint and pay $0.01 for a $100 product. Calculate the amount from your database on the server side, never from the client request. Use Checkout with pre-defined Prices when possible -- it eliminates this attack vector entirely.
pi_ -- PaymentIntent
cs_ -- Checkout Session
cus_ -- Customer
sub_ -- Subscription
pm_ -- PaymentMethod
in_ -- Invoice
price_ -- Price
prod_ -- Product
seti_ -- SetupIntent
evt_ -- Event
ch_ -- Charge
re_ -- Refund
acct_ -- Connected Account