The Stripe object graph
The four Stripe billing objects, Products, Prices, Customers, and Subscriptions, and how they connect to model a subscription.
Last chapter you built a webhook handler that receives Stripe events safely: verified, idempotent, and the only thing allowed to write your app’s entitlement state. It’s a mail room that doesn’t yet know what’s in the envelopes. The events it accepts describe Stripe objects you haven’t met.
Stripe’s API is enormous, with an entire surface for marketplaces and card issuing that a subscription product never touches. The move isn’t to learn all of it, but to find the small handful of objects a subscription reads, the ones it changes (always indirectly), and how they connect.
Picture the plan you’re going to sell: a Pro plan, offered monthly and yearly, with a 14-day free trial — one sentence a product manager would say without thinking. By the end of this lesson you’ll know how it decomposes into Stripe primitives: which part is a Product, which parts are Prices, where the trial lives. You’ll be able to open any Stripe event payload, recognize the four objects inside, and know what your app should do with each.
The four objects and how they connect
Section titled “The four objects and how they connect”Almost everything a subscription SaaS needs lives in four Stripe objects and the edges between them: Products, Prices, Customers, and Subscriptions. Look at the whole shape before drilling into any one; the relationships carry as much of the lesson as the objects do.
Press 'Pro signup' to walk the chain: an org's Customer has a Subscription billed at the pro_monthly Price, which belongs to the Pro Product. Or click any node or labelled edge to read its role.
The thing you sell, like Pro or Team. It carries a name, a description, and marketing copy, but no price of its own. There is exactly one Product per plan tier, so “Pro” is a single Product no matter how many ways you bill for it.
Attaches a recurring billing cycle and an amount to a Product. “Pro billed monthly at $20” and “Pro billed yearly at $200” are two separate Prices under the one Pro Product: pro_monthly and pro_yearly.
The account that gets billed. It owns the payment methods, invoice history, and subscriptions. In this course there is exactly one Customer per organization, never per user.
The living relationship: it ties a Customer to the Price they pay, carries the current status, and tracks the next charge. Stripe emits a webhook every time it changes — how state gets back into your app.
A Product has no price of its own; one Product owns one or more Prices, so Pro’s monthly and yearly options are two Prices under the single Pro Product. This is where the “one Product per tier” rule lives: tier is identity, cycle is pricing.
A Customer owns its subscriptions, payment methods, and invoice history. The SaaS default is one active Subscription per Customer, which lets every later lesson say “the org’s subscription,” singular.
A Subscription points at the Price it’s billed on; it references the Price, doesn’t own it. The chosen Price sets the amount and how often it’s charged, so switching plans means pointing the Subscription at a different Price.
Read the graph as one sentence and it tells the whole story: a Customer has a Subscription, billed at a Price, which belongs to a Product. Walk it backward to answer “is this org on Pro?”: the org’s Customer leads to its Subscription, its Price, and finally the Product — the plan. Two shapes are worth memorizing: a Product has many Prices, and a Customer has one Subscription that points at a Price rather than owning it.
Your app mostly reads these objects, and even then rarely calls Stripe; it reads a small local copy instead, built in the lesson on plan entitlements.
It writes only through Stripe-hosted flows: Checkout to start a subscription, the Customer Portal to change or cancel one.
Each change returns to your app through the webhook you already built.
The rule to keep: never call stripe.* on the request hot path .
Products: one per plan tier
Section titled “Products: one per plan tier”A Product is the thing you sell, like a “Pro plan” or a “Team plan”. It carries the plan’s human-facing identity (name, description, marketing copy) and a stable id, but no price. A Product answers “what is this?”, never “what does it cost?”.
The rule that trips people up: one Product per plan tier, not per billing cycle. Pro is a single Product; its monthly and yearly options are two Prices hanging off it, covered next. Team is its own Product. So our running example is exactly two Products: Pro and Team.
Why? Model it the other way, with separate “Pro Monthly” and “Pro Yearly” Products, and the plan’s identity splits across two objects. The question your whole application keeps asking, “is this org on Pro?”, becomes “Pro Monthly or Pro Yearly?”, and every entitlement check, feature gate, and analytics query has to remember both. Keep the tier as the Product and the cycle as the Price, and the question stays a single clean lookup. Tier is identity; cycle is pricing.
You won’t create Products by hand. A seed script you run against your Stripe account defines them, covered at the end of this lesson.
Prices: interval, amount, and lookup_key
Section titled “Prices: interval, amount, and lookup_key”If the Product is what you sell, the Price is how it’s billed. A Price binds a Product to four things: a recurring interval (month or year), a currency, an amount, and a stable handle called a lookup_key.
Stripe stores the amount as an integer in the currency’s smallest currency unit , cents for USD. So “$20.00 per month” is stored as 2000, not 20.00 — the same store-cents-not-dollars rule you met when we first modeled money. Our Pro example becomes two Prices under the Pro Product: pro_monthly at 2000 ($20.00/month) and pro_yearly at 20000 ($200.00/year).
Now the rule that earns this whole section: your code references a Price by its lookup_key, never by its raw price_id.
A lookup_key is a string you choose, like pro_monthly, and assign when you create the Price. The price_id is the price_xxxxxxxxxxxxxx identifier Stripe generates. Both identify a Price, but they behave differently in production.
So your application asks Stripe “give me the Price whose lookup key is pro_monthly” and gets it back, mode and all.
const { data } = await stripe.prices.list({ lookup_keys: ['pro_monthly'], limit: 1,});The key is the query handle. Turning “plan pro, cycle monthly” into the right Price is a resolver the project builds in full; for now, just see that the application never needs a Stripe-generated ID to find one.
Customers: one billing account per organization
Section titled “Customers: one billing account per organization”A Customer is the account Stripe bills, and everything that accumulates around payment — subscriptions, payment methods, invoices, billing address, tax IDs — hangs off it.
The course’s pinned rule: one Stripe Customer per organization, never per user.
Organizations are already your tenancy unit, the boundary that owns members, roles, and invitations. Billing belongs to that same boundary, because what it accumulates is org-level: seats are counted per org, invoices are issued to the org, the tax ID and billing address are the org’s. A user is just the member holding the credit card today; the subscription belongs to the organization.
Your application stores almost none of the Customer, just a pointer: a single nullable column on the organizations table.
export const organizations = pgTable('organizations', { id: uuid().primaryKey().default(sql`uuidv7()`), // …existing org columns… stripeCustomerId: text('stripe_customer_id'), // nullable: no Customer until first checkout});It’s nullable because a brand-new org has no Customer yet; one is created the first time the org reaches Checkout, the next lesson’s job. Here you’re just fixing the mapping (one org, one Customer) and where the pointer lives.
The pointer runs the other way too: Stripe lets you stamp organization_id onto the Customer, covered next.
Subscriptions: the recurring relationship
Section titled “Subscriptions: the recurring relationship”The Subscription joins a Customer to the Price they pay on a recurring basis. It carries three things you’ll keep coming back to:
- A status: a string like
activeortrialingthat says where the subscription sits in its lifecycle. The full set of statuses is its own lesson; for now, just register that the field exists. - The current billing period: when the paid interval started and ends. This drives every “your plan renews on…” and “your access ends on…” line in your billing UI.
- A connection to one or more subscription items , each pairing a Price with a quantity. In our Pro example, the org’s Subscription has exactly one item:
pro_monthly, quantity 1, statusactive, renewing monthly.
The Subscription also emits a webhook on every state change, and that loop is the spine of this chapter: Stripe changes the Subscription → fires an event → your single-writer handler reads the new status, plan, and period off it → writes them to your database. The events your handler accepted last chapter were all about a Subscription.
So when you see current_period_end in this course, picture it on items.data[0], never on the Subscription root.
One active Subscription per Customer, with a single item, is the SaaS default — what lets every other lesson say “the org’s subscription,” singular. Multiple subscriptions or items are real but advanced, and earn a conversation the day you need them. Until then, assume one of each.
Metadata: the carry-channel back to your app
Section titled “Metadata: the carry-channel back to your app”Here’s a problem the four objects don’t solve on their own. A webhook event lands — say a Subscription changed — and hands you a Stripe Customer ID like cus_xxxxxxxx. But your handler needs to update one specific org’s row, so how does it get from that ID to your organization_id? You could keep a lookup table and query it on every event, or have Stripe just tell you, right there in the event.
That’s what metadata is for. Metadata is a bag of arbitrary key/value strings you attach to most Stripe objects. Anything you’ll need the moment an event arrives, you stash there, and it rides along with no database round-trip to discover it.
In this stack, three handles earn their keep:
Customer.metadata.organization_id, so a webhook describing a Customer maps straight back to your org with no lookup table.Subscription.metadata.plan, the canonical plan slug (pro,team) stamped on the Subscription, so the handler reads the plan directly instead of reverse-mapping from a Price back to a Product.Price.lookup_key, the purpose-built version for Prices: a first-class field, not metadata, but the same stable app-chosen handle for finding a value by meaning rather than by a mode-specific ID.
Keep the discipline straight: metadata is for what you need at webhook-receipt time and don’t want to round-trip the database to learn. It’s not a general-purpose data store or a stand-in for your own tables. If a value belongs in your database, put it there; metadata is the thin carry-channel for the few facts an incoming event needs to be actionable on arrival.
You stamp it in one line at Customer-creation time (the real call, with lazy creation and storing the returned ID, is the Checkout lesson’s):
const customer = await stripe.customers.create({ email: org.billingEmail, metadata: { organization_id: org.id },});When an event lands, event.data.object.metadata.organization_id is how your single-writer handler knows which org’s row to update.
What the app stores vs. what Stripe stores
Section titled “What the app stores vs. what Stripe stores”The most important decision in this chapter isn’t about any single object; it’s about a line. Stripe is the source of truth for billing facts. Your database owns only the small, derived slice of those facts your app reads on every request.
- The Customer, Subscription, and Price catalog
- Invoices and payment methods
- The full billing state machine every status transition
-
organizations.stripe_customer_idthe pointer to the Customer -
plan_entitlementsthe derived row plan, status, period — built in a later lesson - Immutable invoice records pulled down for reporting — named, not built here
The rule for that table: mirror only what your app reads on the hot path, not Stripe’s full schema. Every column earns its place because some request path needs it without calling Stripe. Your middleware asking “is this org on Pro and not past due?” runs on a huge fraction of requests, so it can’t round-trip to Stripe each time; the answer has to live in your database. Stripe’s Customer carries dozens of fields you almost never read, so you don’t copy them.
That derived slice is just enough to answer “what can this org do, and until when”:
// The shape the entitlements lesson builds — a projection, not a copy:{ plan, // 'pro' | 'team' — from Subscription.metadata.plan status, // 'active' | 'trialing' | ... — from Subscription.status currentPeriodEnd, // from subscription.items.data[0].current_period_end cancelAtPeriodEnd, // whether it lapses at period end}Note that currentPeriodEnd is derived from subscription.items.data[0].current_period_end, the same item-level location, now feeding a projected column. Four or five fields, each present because a request reads it. The full table, column by column and how it’s written and read, is the entitlements lesson’s job.
The webhook events the application listens to
Section titled “The webhook events the application listens to”You already know how to receive an event safely: verify the signature, dedupe on the processed_events ledger, write once. What you haven’t seen is which events a subscription SaaS cares about and what each one means.
checkout.session.completed customer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deleted invoice.paid invoice.payment_failed That’s the entire event vocabulary for a subscription product, four or five types rather than the hundreds Stripe can emit. The handler code lands in the project chapter; this lesson just names the surface so the events stop being opaque.
When an org subscribes for the first time, these events arrive in a particular order. Put the happy path in sequence:
Order the events fired when an org subscribes to Pro for the first time, earliest first. Drag the items into the correct order, then press Check.
checkout.session.completed — the hosted Checkout flow finishes customer.subscription.created — Stripe creates the Subscription invoice.paid — the first invoice is paid and the period begins The Stripe Node SDK: one client, server-only
Section titled “The Stripe Node SDK: one client, server-only”Talking to Stripe means the official stripe npm package, a class-based SDK you instantiate once with your secret key and hide behind a single module.
That module is lib/stripe.ts, the same singleton you stood up last chapter to verify webhook signatures, now refined for the wider billing surface with a server-only guard and a pinned apiVersion.
import 'server-only';import Stripe from 'stripe';import { env } from '@/env';
export const stripe = new Stripe(env.STRIPE_SECRET_KEY, { apiVersion: '2025-03-31.basil',});A firewall against bundling this module into client code. If anything in the browser bundle imports lib/stripe.ts, the build fails, which keeps your secret key off the client.
import 'server-only';import Stripe from 'stripe';import { env } from '@/env';
export const stripe = new Stripe(env.STRIPE_SECRET_KEY, { apiVersion: '2025-03-31.basil',});The secret comes from env, your validated environment module, not raw process.env, so a missing or malformed key fails the build instead of surfacing at 2am.
import 'server-only';import Stripe from 'stripe';import { env } from '@/env';
export const stripe = new Stripe(env.STRIPE_SECRET_KEY, { apiVersion: '2025-03-31.basil',});Pinning the version makes every upgrade an intentional, reviewable bump, and it’s why field locations like the item-level current_period_end depend on which version you’re on.
import 'server-only';import Stripe from 'stripe';import { env } from '@/env';
export const stripe = new Stripe(env.STRIPE_SECRET_KEY, { apiVersion: '2025-03-31.basil',});One configured instance, exported once: the single place a stripe.* call originates in the entire codebase.
Everything else reaches Stripe through a thin billing.* interface built later in this chapter, but the rule that every stripe.* call originates here starts now.
Test mode and live mode: two parallel universes
Section titled “Test mode and live mode: two parallel universes”Hardcoded IDs and key mix-ups, two of the worst Stripe mistakes, are the same mistake in disguise: forgetting that every Stripe account is two separate universes.
A test universe and a live universe share nothing. Keys, Customers, Subscriptions, Prices, and webhooks are all disjoint. A Customer you create in test doesn’t exist in live, and your test and live Pro Prices carry different price_ids. Objects never cross the boundary, and IDs differ across it. That’s exactly why you reference Prices by lookup_key: the key is the one handle you author identically in both worlds.
- API keys
sk_test_… - Customers
cus_… (test) - Subscriptions
sub_… (test) - Prices
price_… (test) - Webhooks
whsec_… (test)
- API keys
sk_live_… - Customers
cus_… (live) - Subscriptions
sub_… (live) - Prices
price_… (live) - Webhooks
whsec_… (live)
lookup_key, the one handle you author
identically in both.
Which universe runs where is a convention, not a guess: dev, CI, and staging run against test; only production runs against live. The key is loaded per environment from your validated env module. The Stripe CLI, which forwards webhooks to your machine, defaults to test mode unless you pass --live.
Two keys are in play, and naming them apart matters:
# server-only — authenticates SDK calls, never client-bundledSTRIPE_SECRET_KEY=sk_test_... # sk_live_... in production
# safe to ship to the browser — used by Stripe.js to mount payment UISTRIPE_PUBLISHABLE_KEY=pk_test_... # pk_live_... in production
# verifies incoming webhook signaturesSTRIPE_WEBHOOK_SECRET=whsec_...The prefix is the universe: sk_test_/pk_test_ outside production, sk_live_/pk_live_ only in it. Because that prefix is machine-readable, a boot-time assertion can refuse to start the app when the key doesn’t match the environment. A production deploy carrying an sk_test_ key fails loudly instead of quietly charging no one; a dev run carrying an sk_live_ key fails loudly instead of charging real cards.
The STRIPE_SECRET_KEY authenticates your SDK calls and can charge cards and read every customer’s data, so it must never reach the browser. The STRIPE_PUBLISHABLE_KEY is its public counterpart: safe to ship, used by Stripe’s client-side library to render payment UI. Two watch-outs, both incident-grade.
Pricing as code: the catalog seed script
Section titled “Pricing as code: the catalog seed script”You have two ways to get your Products and Prices into Stripe: click through the dashboard by hand, or define the catalog in code and apply it with a script, pnpm seed:stripe, that creates or updates them through the API.
The course default is code as the source of truth for the catalog, on the same single-source-of-truth principle running throughout. Code produces diffs you review in a pull request, and gives you test/live parity for free: run the same seed against each universe and your test and live Prices match, lookup keys and all. Dashboard clicks leave no diff, no review trail, and no guarantee that test mirrors live.
// The catalog, as reviewable code — one Product, its two Prices:const catalog = [ { product: { name: 'Pro' }, prices: [ { lookup_key: 'pro_monthly', unit_amount: 2000, interval: 'month' }, // $20.00/mo { lookup_key: 'pro_yearly', unit_amount: 20000, interval: 'year' }, // $200.00/yr ], },];That fragment is the whole point: your pricing lives in a file you can read, review, and diff, one Pro Product with two Prices keyed by pro_monthly and pro_yearly. The seed script upserts each entry — creating it the first time, updating it on later runs — and applying the same file to test and live keeps the two universes in sync. The full script is the project’s to ship; here you only adopt the convention.
Tracing the example through the four objects
Section titled “Tracing the example through the four objects”Walk the original sentence back through what you now have. “A Pro plan, offered monthly and yearly, with a 14-day trial” decomposes like this.
The Pro plan is one Product. Its monthly and yearly options are two Prices under it, pro_monthly and pro_yearly, which your code resolves by lookup_key, never by a Stripe-generated ID. The org that subscribes has one Customer, pointed at by organizations.stripe_customer_id and tagged with metadata.organization_id so webhooks route back to the right org. That Customer has one Subscription whose single item references the chosen Price; Stripe owns its status, and the item tracks the billing period. Every change Stripe makes to the Subscription fires a webhook that your single-writer handler projects into the entitlements row the rest of your app reads. The 14-day trial is a property on the Subscription, which the next lesson wires up.
Two checks. First, which object owns which fact:
Drag each fact to the object that owns it. Drag each item into the bucket it belongs to, then press Check.
lookup_keyorganization_id carry-value for webhooksstatus (active / trialing / …)organizations.stripe_customer_id (the pointer)Second, why you resolve Prices by lookup_key:
Your app resolves Prices by lookup_key. What goes wrong the day you promote the exact same code from your test environment to production if you’d hardcoded a price_id instead?
price_id, so the lookup resolves to nothing.stripe.prices.* only accepts a lookup_key, never a raw price_id.price_id is a deprecated field that Stripe removes in its newer API versions.price_id in each. A hardcoded test ID points at nothing in live, so checkout silently breaks on promotion. A lookup_key is one you author and assign identically in both worlds, so the same code resolves the right Price everywhere — test-to-live becomes a non-event.From here the chapter builds outward, starting with how a subscription is created at Checkout.
External resources
Section titled “External resources”To see these objects from Stripe’s own angle, a few official pages are worth a read. Keep it to these; the rest of Stripe’s docs are a rabbit hole you don’t need yet.