How Stripe Works
The object model every Stripe builder must understand before writing a single line of code
Stripe is not just a payment button. It is a billing platform with a precise object model. Every payment, subscription, and invoice involves several interconnected objects. Understanding those objects — and how they relate — is what separates builders who wire Stripe correctly the first time from those who discover edge cases after real money is flowing.
The core Stripe objects
- Customer — A reusable Stripe identity for each of your users. Customers hold payment methods, subscriptions, and invoice history. Create one per user at signup and store the Stripe customer ID in your database. Without a Customer object you cannot open portal sessions, retry failed payments, or link subscription history to a user.
- Product — What you are selling — a SaaS plan, a course, a one-time service. Products contain metadata and are the parent of Prices. Think of the Product as the row in your product catalogue.
- Price — How you charge for a Product: amount, currency, billing interval (one-time, monthly, annual, metered). One Product can have many Prices. Prices are immutable — you cannot change a price after creation, only archive it and create a new one.
- PaymentIntent — Represents a single payment attempt. Stripe uses it to track authorisation, capture, and SCA/3DS authentication. One-time payments flow through PaymentIntents. The status cycles from requires_payment_method → requires_confirmation → requires_action → processing → succeeded.
- Subscription — Ongoing billing relationship between a Customer and a Price. Stripe handles recurring charges, trials, proration, and renewals automatically. Subscriptions have a status field — always check it: active, trialing, past_due, canceled, unpaid.
- Invoice — A record of charges for a billing period. Subscriptions generate Invoices automatically at the start of each period. Invoices can be in draft, open, paid, or void status. An open invoice with a failed charge is your dunning starting point.
How the objects relate
Stripe objects are like a restaurant with a standing weekly reservation
The Customer is the diner — they have a tab history and a saved payment method on file. The Product is the dish on the menu. The Price is the listed cost of that dish in your currency. The PaymentIntent is the payment terminal transaction when they settle the bill. The Invoice is the itemised bill. The Subscription is the standing weekly reservation: the kitchen automatically starts their usual order each week and runs the card on file. If the card declines, the restaurant sends a reminder — that is dunning.
Test mode vs live mode
- Test mode is a completely isolated environment — Test API keys (sk_test_...) hit a separate data set. Test customers, payments, and subscriptions never affect live data. You can delete all test data from the dashboard without consequence.
- Switch modes via the Stripe dashboard toggle — Top-left corner of dashboard.stripe.com. Products, Prices, Customers, and webhook endpoints are all separate per mode — you must create them in both.
- Test card numbers are fixed — 4242 4242 4242 4242 succeeds. 4000 0000 0000 0002 is declined immediately. 4000 0000 0000 0341 fails on charge (used to test dunning). 4000 0025 0000 3155 triggers 3DS authentication. Any future expiry and any 3-digit CVC work.
- Webhook endpoints are separate per mode — A webhook registered in test mode does not fire for live events. Register and verify endpoints in both modes before going live — and use different signing secrets for each.
API keys
- Publishable key (pk_test_ / pk_live_) — Safe to expose in the browser — used by Stripe.js on the client to tokenise card details. Never used for server-side operations.
- Secret key (sk_test_ / sk_live_) — Server-only. Never expose in client code, never commit to git. Used to create customers, subscriptions, and PaymentIntents. If leaked, rotate immediately in the Stripe dashboard.
- Restricted keys — Scoped API keys for specific operations — use them for webhook handlers and microservices. A restricted key for your webhook handler that can only read events and update customers limits blast radius if compromised.
- Webhook signing secret (whsec_) — Used to verify that incoming webhook requests genuinely came from Stripe. Separate from API keys — see Lesson 4 for full details on signature verification.
Installing the Stripe SDK
Set up Stripe in a Next.js 15 project
Install the server-side Stripe Node.js library
npm install stripe→ added 1 package
Install the client-side Stripe.js React wrapper
npm install @stripe/stripe-js @stripe/react-stripe-js→ added 2 packages
Create a Stripe singleton for server-side use at lib/stripe.ts
Singleton pattern prevents creating a new Stripe instance per request in dev hot-reload, which would exhaust connections and produce warnings in your terminal.
Add the Stripe initialisation code
// lib/stripe.ts
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
typescript: true,
})Add your test keys to .env.local
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
NEXT_PUBLIC_BASE_URL=http://localhost:3000Verify the connection with a quick product list
// app/api/stripe-check/route.ts
import { NextResponse } from 'next/server'
import { stripe } from '@/lib/stripe'
export async function GET() {
const products = await stripe.products.list({ limit: 1 })
return NextResponse.json({ ok: true, count: products.data.length })
}GET /api/stripe-check should return { ok: true }. If you see AuthenticationError, the env var is not being read — check you have not accidentally prefixed STRIPE_SECRET_KEY with NEXT_PUBLIC_ (that would expose it in the browser bundle and Stripe would still reject it server-side).
Add .env.local to .gitignore if not already present
git check-ignore -v .env.localIf nothing prints, the file is not ignored — add it immediately. Leaked Stripe secret keys are rotated within minutes by automated scanners.
What breaks in production: the first-lesson mistakes
- Using pk_ (publishable) key on the server — The publishable key can only tokenise cards via Stripe.js. Calling stripe.customers.create() with a publishable key returns a 403 PermissionError. The secret key is required for all server-side API calls.
- Creating a new Stripe() instance per request — In Next.js API routes that are re-imported on every hot-reload, creating new Stripe() per import causes connection exhaustion in development. The singleton pattern in lib/stripe.ts prevents this — the instance is created once per server process.
- Hardcoding the API version string — The apiVersion string must match the version your code was written for. If Stripe releases a breaking change and you upgrade the SDK without updating apiVersion, request shapes change silently — a field that used to return a string may now return an object, breaking your TypeScript narrowing.
- Not storing stripe_customer_id at signup — If you create a Customer at checkout time instead of signup, a user who starts multiple checkout sessions ends up with multiple Customer records — fragmented payment history, duplicate subscriptions, and a billing portal that shows split invoice history.
- Confusing test-mode and live-mode Products — Products and Prices created in test mode do not exist in live mode. You must create them in both. The most common failure: the Price ID in your .env.local points to a test Price, you switch to live keys in production, and every checkout creates a "No such price" error.
Try this
Set up a Stripe test account for Launchpad — a multi-tier SaaS with Free, Pro ($29/mo), and Team ($99/mo) plans. In the Stripe dashboard (test mode), go to Product Catalogue and create three products: Free, Pro, and Team. For Pro, add a recurring monthly price of $29. For Team, add a recurring monthly price of $99. Copy both price IDs into .env.local. Then create a subscriptions table in Supabase with columns: id (uuid primary key), user_id (uuid references auth.users), stripe_customer_id (text), stripe_subscription_id (text unique), plan (text default free), status (text), current_period_end (timestamptz). Add a processed_webhook_events table: id (uuid), event_id (text unique), type (text), processed_at (timestamptz). Finally, on paper or in a doc, draw the webhook event flow for a new subscriber: which events fire, in which order, and what your handler must do for each.