Table of Contents

1. How Payments Work 2. Stripe Architecture 3. Setting Up Stripe 4. Payment Intents 5. Stripe Checkout (Hosted) 6. Stripe Elements (Embedded) 7. Subscriptions 8. Webhooks 9. Customers & Payment Methods 10. Error Handling 11. Security & PCI Compliance 12. Testing 13. Common Patterns 14. Full Integration Example

1. How Payments Work

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.

The Payment Flow

Customer -> Merchant -> Acquirer (merchant's bank) -> Card Network (Visa/MC) -> Issuer (customer's bank)

Response flows back the same path in reverse.

Authorization

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.

Capture

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.

Auth-and-Capture Use Case

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

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).

Refunds and Chargebacks

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.

Chargeback Rate

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.

2. Stripe Architecture

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.

API-First Design

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.

Test Mode vs Live Mode

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.

API Key Prefixes

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.

Secret vs Publishable Keys

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.

Never Commit API Keys

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 API Versioning

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.

3. Setting Up Stripe

Install the SDK

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

Environment Variables

.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

Initialize Stripe in Node.js

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;
TypeScript Benefits

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.

Project Structure

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

4. Payment Intents

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.

PaymentIntent Lifecycle

requires_payment_method -> requires_confirmation -> requires_action (optional, for 3DS) -> processing -> succeeded / requires_capture

Create a PaymentIntent (Server-Side)

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;

Confirm on the Client

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!');
  }
}
Why Client Secret?

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.

Manual Capture (Auth-then-Capture)

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
});

5. Stripe Checkout (Hosted)

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.

When to Use Checkout

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.

One-Time Payment Checkout Session

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 });
  }
});

Redirect on the Client

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
}

Subscription Checkout Session

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' },
  },
});
Checkout Session Modes

payment -- One-time charge

subscription -- Recurring billing

setup -- Save a card for later (no charge now)

6. Stripe Elements (Embedded)

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.

Setting Up Elements

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 Container

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>

Handle Form Submission

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;
  }
});
Never Collect Raw Card Numbers

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).

7. Subscriptions

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.

Create a Product and Price

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',
  },
});

Create a Subscription

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 });

Free Trials

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' },
  },
});

Proration

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'
});

Cancel a Subscription

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'
  },
});
Subscription Statuses

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

8. Webhooks

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.

Do Not Rely on Redirects

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.

Setting Up the Webhook Endpoint

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 });
  }
);
express.raw() is Required

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.

Signature Verification

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.

Handling Events: Example Handlers

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 },
  });
}

Idempotency

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(),
    },
  });
}
Key Webhook Events

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

9. Customers & Payment Methods

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.

Create a Customer

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;
}

Attach a Payment Method

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,
  },
});

Setup Intents (Save Card Without Charging)

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' },
  },
});

List a Customer's Payment Methods

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}`);
});
Customer Portal

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 });

10. Error Handling

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.

Stripe Error Types

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');
  }
}

Common Decline Codes

Decline Code Reference

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.

3D Secure / Strong Customer Authentication (SCA)

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();
}
Stripe.js Handles 3DS

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.

Retry Logic for API Errors

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' })
);

11. Security & PCI Compliance

PCI DSS Levels

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:

PCI Compliance Levels with Stripe

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.

Tokenization

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.

The Tokenization Flow

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

Security Best Practices

Stripe Security Checklist

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.

Idempotency Keys

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

Stripe Radar

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).

12. Testing

Test Card Numbers

Common Test Cards

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.

Stripe CLI for Local Testing

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
CLI Workflow

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.

Writing Automated Tests

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();
  });
});

Testing Webhooks with Mock Events

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);
  });
});

13. Common Patterns

Pattern 1: One-Time Payment (Digital Product)

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 },
  });
}

Pattern 2: SaaS Subscription

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 });
});

Pattern 3: Marketplace (Connect)

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
  },
});
Connect Account Types

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.

14. Full Integration Example

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.

Express App Setup

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}`);
});

Payment Routes

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;

Webhook Handler

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;

Database Schema (Prisma)

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())
}
Production Checklist

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.

Amount Validation

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.

Quick Reference: Stripe Object ID Prefixes

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