Skip to content
Chapter 64Lesson 1

The Stripe object graph

The four Stripe billing objects, Products, Prices, Customers, and Subscriptions, and how they connect to model a subscription SaaS.

In the last chapter you built the machinery that receives Stripe events safely: a verified, idempotent, single-writer webhook handler, backed by a processed_events ledger, with one firm rule that the webhook is the only thing allowed to write your app’s entitlement state. That handler works, but right now it’s a mail room with no idea what’s in the envelopes. The events it accepts describe objects you haven’t met yet.

So before you wire up a single payment, you need the map. Stripe’s API is enormous: hundreds of endpoints, dozens of object types, and an entire surface for marketplaces, connected accounts, and physical card issuing that a subscription SaaS will never touch. The experienced move is not to learn all of it. It’s to ask which small handful of objects a subscription product actually reads, which it writes to (always indirectly, it turns out), and how they connect to each other.

Here is that question made concrete. Picture the plan you’re going to sell: a Pro plan, offered monthly and yearly, with a 14-day free trial. That’s one English sentence a product manager would say without thinking. By the end of this lesson you’ll know exactly how that sentence decomposes into Stripe primitives: which part is a Product, which parts are Prices, and where the trial lives. You’ll also be able to open any Stripe event payload, recognize the four objects inside it, and know what your application should do with each one.

This is the dictionary every later lesson in this chapter quotes. Checkout, the Customer Portal, the entitlements row, subscription status, the billing interface: all of it refers back to four objects. Let’s install them.

Almost everything that matters for a subscription SaaS lives in four Stripe objects and the edges between them: Products, Prices, Customers, and Subscriptions. Before we drill into any one of them, look at the whole shape, because the relationships between these objects carry as much of the lesson as the objects themselves.

The Stripe subscription graph
tip Click any node or labelled edge

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.

Read that graph as a single sentence and it tells the whole story: a Customer has a Subscription, which is billed at a Price, which belongs to a Product. Walk it the other way and you’ve answered “is this org on Pro?”: follow the org’s Customer to its Subscription to its Price to its Product, and the Product is the plan. Two shapes are worth committing to memory. A Product has many Prices (one-to-many), and a Customer has one Subscription that simply references a Price. The Subscription doesn’t own the Price; it points at it.

One orientation before the detail. Your application mostly reads these objects, and even then it rarely calls Stripe directly. It reads a small local copy instead, which we’ll build in the lesson on plan entitlements. Your application writes to these objects only through Stripe-hosted flows: Checkout for starting a subscription, and the Customer Portal for changing or cancelling one. A change then makes its way back into your app through the webhook you already built. Hold onto one rule from this paragraph: you never call stripe.* on the request hot path. We’ll pay that off properly later, but plant the flag now.

A Product is the thing you sell, such as a “Pro plan” or a “Team plan”. It carries the human-facing identity of the plan (a name, a description, and marketing copy listing the features) and a stable id. What it conspicuously does not carry is a price. A Product answers “what is this?”, never “what does it cost?”.

Here is the decision that trips people up, so let’s settle it before anything else: one Product per plan tier, not per billing cycle. Pro is a single Product. Its monthly option and its yearly option are not two Products; they’re two Prices that hang off the one Pro Product, which the next section covers. Likewise, Team is its own single Product. So our running example is exactly two Products: Pro and Team.

Why insist on this? Imagine you modeled it the other way, with a “Pro Monthly” Product and a separate “Pro Yearly” Product. Now the plan’s identity is split across two objects. The question your whole application keeps asking, “is this org on Pro?”, becomes a two-value question, “are they on Pro Monthly or Pro Yearly?”, and every entitlement check, every feature gate, and every analytics query has to remember both. Keep the tier as the Product and the cycle as the Price, and “are they on Pro?” stays a single, clean lookup. Tier is identity, cycle is pricing, and you don’t want pricing to fragment identity.

You won’t create Products by hand. They’re defined in a seed script you run against your Stripe account, which we’ll come to at the end of the lesson. For now, just hold the shape: a Product is a plan tier.

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.

The amount deserves a half-second of care. Stripe stores it as an integer in the currency’s smallest currency unit , which for USD is cents. So “$20.00 per month” is stored as 2000, not 20.00. This is the same “store cents, not dollars” rule you met all the way back when we first modeled money: integers don’t have floating-point rounding errors, and a fraction of a cent is not something you can charge. Our Pro example becomes two Prices under the Pro Product: pro_monthly at 2000 ($20.00/month) and pro_yearly at, say, 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, such as pro_monthly or pro_yearly, and assign when you create the Price. The price_id, by contrast, is the price_xxxxxxxxxxxxxx identifier Stripe generates. Both identify a Price. The difference shows up when you go to production, and it causes the single most expensive Stripe mistake a beginner makes:

So instead of carrying an opaque ID, your application asks Stripe “give me the Price whose lookup key is pro_monthly” and gets the Price back, mode and all.

const { data } = await stripe.prices.list({
lookup_keys: ['pro_monthly'],
limit: 1,
});

That single line is the whole idea: the key is the query handle. The resolver that turns “plan pro, cycle monthly” into the right Price is the project’s job to build in full. For now, just see that the application never has to know a Stripe-generated ID to find the right Price.

Customers: one billing account per organization

Section titled “Customers: one billing account per organization”

A Customer is the entity that gets billed. It owns the subscriptions, the saved payment methods, the invoice history, the billing address, and the tax IDs: everything that accumulates around an account that pays you money.

The course’s pinned rule is a decision you make once and never revisit: one Stripe Customer per organization, never per user.

You already have organizations as your tenancy unit, the boundary that owns members, roles, and invitations. Billing belongs to that same boundary, because the things billing accumulates are org-level facts, not personal ones. Seats are counted per org, invoices are issued to the org, and the tax ID and billing address are the org’s. A single user is just one member who happens to be holding the credit card today; the subscription belongs to the organization.

Here is what breaks if you ignore this:

Your application stores almost nothing of Stripe’s Customer, just a pointer to it. That’s a single column on the organizations table:

db/schema.ts
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
});

The column is nullable on purpose: a brand-new org has no Stripe Customer yet. The Customer gets created the first time the org goes through Checkout, and that lazy creation is the next lesson’s job. All you’re establishing here is the mapping (one org, one Customer) and where the pointer lives (this column).

There’s a pointer in the other direction too, and you’ll meet it shortly. Stripe lets you stamp organization_id onto the Customer itself, so that when a webhook arrives describing some Customer, your handler can read the org straight off the event. Hold that thought, because it’s the whole reason the metadata section exists.

The Subscription is where it all comes alive. It’s the object that joins a Customer to the Price they’re paying, on a recurring basis. A Subscription carries three things you’ll keep coming back to:

  • A status, a string like active or trialing that says where this subscription is in its lifecycle. Note that it’s a string with meaning, not a true/false. The full set of statuses and exactly how your app should treat each one is its own lesson; for now, just register that the field exists and that it carries more than a yes/no.
  • The current billing period, meaning when the paid interval started and when it 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 , where each item pairs a Price with a quantity. In our Pro example, the org’s Subscription has exactly one item, referencing pro_monthly, quantity 1, status active, renewing each month.

The Subscription also emits a webhook on every state change, and that is the through-line of the chapter. Those events your handler accepts in the last chapter were all about a Subscription. Stripe mutates the Subscription, fires an event, and your single-writer handler projects the new state into your app’s row. That loop is the spine of this whole chapter: Stripe changes the Subscription → fires an event → your webhook reads the new status, the plan, and the period off it → writes them to your database.

Next comes a detail that will bite you if you learn it from an older tutorial, so learn it correctly here.

So when you see current_period_end in this course, picture it on items.data[0], never on the Subscription root. Same field, new home.

What about the default of one item, one subscription? One active Subscription per Customer, with a single item, is the SaaS default, and keeping it crisp is what lets every other lesson say “the org’s subscription,” singular, without hedging. Multiple subscriptions, or multiple items on one subscription, are real but advanced: a billing org with separate B2C and B2B lines, a workspace mixing a monthly and a yearly add-on, or several workspaces invoiced together. Those earn a conversation the day you actually need them. Until then, assume one subscription and one item, and read items.data[0]. We’ll keep that assumption everywhere downstream.

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 just changed. The event hands you a Stripe Customer ID like cus_xxxxxxxx, but your handler needs to update one specific org’s row. How does it get from a Stripe Customer ID to your organization_id? You could keep a lookup table mapping one to the other and query it on every event, or you could 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 can attach to most Stripe objects. Stripe stores them and, crucially, echoes them back on every webhook event for that object. So anything you’ll need at the moment an event arrives, you stash in metadata, and it rides along on the event with no database round-trip to discover it.

In this stack, exactly 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 right on the Subscription, so the handler reads the plan directly instead of reverse-mapping from a Price back to a Product.
  • Price.lookup_key, which is not metadata in the strict sense (it’s a first-class field on the Price, as you saw) but carries the same idea: a stable, app-chosen handle so you find the Price by meaning rather than by a mode-specific ID. Metadata is the generic bag; lookup_key is the purpose-built version for Prices. Both serve the same goal of finding a value by app meaning.

Here is the discipline to keep straight: metadata is for what you need at webhook-receipt time and don’t want to round-trip the database to learn. It is not a general-purpose data store, and not a stand-in for your own tables. If a value belongs in your database, put it in your database; metadata is the thin carry-channel for the few facts an incoming event needs to be actionable on arrival.

Stamping it is one line at Customer-creation time. This is illustrative; the real call, with lazy creation and storing the returned ID, is the Checkout lesson’s job:

const customer = await stripe.customers.create({
email: org.billingEmail,
metadata: { organization_id: org.id },
});

And here’s the payoff, tying it back to the handler you already built: when an event lands, event.data.object.metadata.organization_id is exactly how your single-writer handler knows which org’s row to update. That one expression is why this section exists. (The Customer create call above is illustrative; the real one, with lazy creation, is the next lesson’s.)

What the app stores vs. what Stripe stores

Section titled “What the app stores vs. what Stripe stores”

Step back from the objects for a moment, because the most important decision in this whole chapter isn’t about any single object. It’s about a line. Stripe is the source of truth for billing facts, and your database is the source of truth for the small, derived slice of those facts your app reads on every request. Drawing that line wrong is how billing integrations rot.

The ownership line. Stripe is the source of truth for billing facts; the app keeps only the thin, derived slice it reads on the hot path — don’t mirror Stripe’s schema, mirror only what your app reads.

The rule that governs that table is to mirror only what your app reads on the hot path, not Stripe’s full schema. Every column you keep should exist because some request path needs it without calling Stripe. Your middleware checking “is this org on Pro and not past due?” runs on a huge fraction of requests, so it cannot afford a network round-trip to Stripe each time, which is why the answer lives in your database. But Stripe’s Customer has dozens of fields (tax IDs, shipping addresses, balance, currency, delinquency flags) and you read almost none of them on a request, so you don’t copy them.

What does that derived slice look like? 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
}

That’s the destination sketched, not the contract. The full table, column by column, plus how it gets written and read, is the entitlements lesson’s job. Notice only that currentPeriodEnd is derived from subscription.items.data[0].current_period_end, the same item-level location from before, now showing up as the source of one projected column. Four or five fields, each present because a request reads it. That’s the whole projection.

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, and write once. What you haven’t seen is which events a subscription SaaS actually cares about and what each one means for your app. Here’s the surface.

The events that matter, and what each one tells your app to do — four or five types, not the hundreds Stripe can emit. The full handler code lands in the project chapter; here you only meet the surface.

That’s the entire event vocabulary for a subscription product, four or five types rather than the hundreds Stripe can emit. To be clear about the boundary: the full handler code that responds to these lands in the project chapter, not here. This lesson names the surface and the meaning so the events stop being opaque; it doesn’t re-implement the ingestion machinery you already built.

One quick check on the through-line. When an org subscribes for the first time, those events arrive in a particular order. Try putting 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”

Time for the one piece of real code in this lesson. Talking to Stripe from your application means the official stripe npm package, a class-based SDK you instantiate once with your secret key. This is the natural place for one of the carve-outs you met when we drew the module boundaries: an SDK adapter is exactly the kind of class that earns its weight, because you instantiate it a single time and then hide the instance behind a module so nothing else has to think about it.

That module is lib/stripe.ts, the same singleton you stood up in the last chapter to verify webhook signatures, now refined for the wider billing surface with two additions: a server-only guard and a pinned apiVersion. Every line in it is load-bearing.

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 server-only guard. Importing this package makes the module impossible to bundle into client code, so if anything in the browser bundle ever imports lib/stripe.ts, the build fails loudly. That’s the firewall that 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. A missing or malformed key is caught at build time, not at 2am in production.

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 pinned API version. Pinning it explicitly makes any upgrade an intentional, reviewable bump rather than a drift under you, and it’s exactly why field locations like the item-level current_period_end are version-dependent. The string itself rolls over time; the discipline of pinning is what matters.

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. This is the single place a stripe.* call originates in the entire codebase.

1 / 1

Read that as a set of rules, not just code. The server-only import is a firewall: it makes it a build error for any client bundle to pull this module in, which is the one guarantee that keeps your secret key out of the browser. The secret itself comes from your validated env module, so a missing key fails the build instead of surfacing as a mysterious 500. The pinned apiVersion turns Stripe upgrades into deliberate, reviewable bumps, and it’s the very reason field locations like the item-level current_period_end depend on which version you’re on. And the export is a single configured instance: this module is the one and only place a stripe.* call is allowed to originate. Everything else in your app will go through a thin billing.* interface that we’ll build later in this chapter, but the rule starts here.

Test mode and live mode: two parallel universes

Section titled “Test mode and live mode: two parallel universes”

Several of the worst Stripe mistakes, hardcoded IDs and key mix-ups, are really the same mistake wearing different costumes: forgetting that every Stripe account is two completely separate universes.

There’s a test universe and a live universe, and they share nothing. Test-mode keys, Customers, Subscriptions, Prices, and webhooks are all wholly disjoint from live. A Customer you create in test does not exist in live, and the price_id your test Pro Price got is not the one your live Pro Price has. Objects never cross the boundary, and IDs differ across it. That, to close the loop from earlier, is exactly why you reference Prices by lookup_key: the key is the one handle you author identically in both worlds.

Two disjoint worlds — same shapes, zero shared objects. Every object type exists in both, but a test-mode object and its live twin share only a name; their IDs differ — which is exactly why your code resolves Prices by lookup_key, the one handle you author identically in both.

Which universe runs where is a convention, not a guess: dev and CI run against test, staging runs against test, and only production runs against live. The key is loaded from your validated env module and chosen per environment. The Stripe CLI, which you’ll use to forward webhooks to your local machine, defaults to test mode unless you explicitly pass --live, a safe default that’s hard to fumble.

Two keys are in play, and naming them apart matters:

.env
# server-only — authenticates SDK calls, never client-bundled
STRIPE_SECRET_KEY=sk_test_... # sk_live_... in production
# safe to ship to the browser — used by Stripe.js to mount payment UI
STRIPE_PUBLISHABLE_KEY=pk_test_... # pk_live_... in production
# verifies incoming webhook signatures
STRIPE_WEBHOOK_SECRET=whsec_...

The prefix is the universe: sk_test_/pk_test_ in dev, CI, and staging, and sk_live_/pk_live_ only in production. Because that prefix is machine-readable, a boot-time assertion can refuse to start the app when it doesn’t match the environment. A production deploy carrying an sk_test_ key then fails loudly instead of quietly charging no one, and a dev run carrying an sk_live_ key fails loudly instead of charging real cards.

The STRIPE_SECRET_KEY authenticates your SDK calls and must never reach the browser, because it’s the key that can charge cards and read every customer’s data. The STRIPE_PUBLISHABLE_KEY is its public counterpart: safe to ship, and used by Stripe’s client-side library to render payment UI. Two watch-outs, both incident-grade:

One last convention before we put it together. You have two ways to get your Products and Prices into Stripe. You can click around the dashboard creating them by hand, or you can define the catalog in code and apply it with a script, pnpm seed:stripe, that creates or updates the Products and Prices through the API.

The course default is code as the source of truth for the catalog, and the reasoning is the same single-source-of-truth principle that’s run through this whole course. A catalog defined in code produces diffs you can review in a pull request, and it gives you test/live parity for free: you run the same seed against each universe, so your test Prices and live Prices are guaranteed to match, lookup keys and all. Dashboard clicks leave no diff, no review trail, and no guarantee that test mirrors live.

stripe/catalog.ts
// 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 entire point: your pricing lives in a file you can read, review, and diff, namely one Pro Product with its 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 the same file applied against test and against live keeps the two universes in sync. The full script is the project’s to ship; here you only need to adopt the convention.

Let’s walk the original sentence back through everything 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 finds by lookup_key, never by a Stripe-generated ID. The org that subscribes has one Customer (pointed at by organizations.stripe_customer_id, and stamped with metadata.organization_id so webhooks can find their way home). That Customer has one Subscription with a single item referencing the chosen Price; its status is driven entirely by Stripe, and its billing period is tracked on the item. Every time Stripe touches that Subscription, it fires a webhook that your single-writer handler projects into the entitlements row that the rest of your app reads, carrying the status, the plan, and the item’s current_period_end. The 14-day trial? That’s just a property on the Subscription, which the next lesson wires up.

That’s the four-object model, the conventions that ride on top of it, and the seam between Stripe and your database: the whole vocabulary the rest of this chapter speaks. Two checks to lock it in.

First, the core question this lesson exists to answer, which is 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.

Product the plan tier
Price cycle + amount
Customer the billing account
Subscription the recurring link
The app's DB the thin projection
Plan name & marketing description
Billing interval & amount in cents
lookup_key
Saved payment method & invoice history
organization_id carry-value for webhooks
status (active / trialing / …)
The single item referencing a Price
organizations.stripe_customer_id (the pointer)

Second, the load-bearing rule of the Prices section:

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?

Checkout breaks: the hardcoded ID was minted in the test universe, and the matching live Price has a different price_id, so the lookup resolves to nothing.
Nothing breaks, but every Price lookup runs slower because resolving an opaque ID is more expensive than resolving a short string key.
The SDK rejects the call at runtime — stripe.prices.* only accepts a lookup_key, never a raw price_id.
The build fails because price_id is a deprecated field that Stripe removes in its newer API versions.

You can now read any of the four objects in a Stripe payload and know what your app should do with it. From here the chapter builds outward: starting a subscription with Checkout, handing plan changes to the Customer Portal, projecting all of this into the entitlements row, making sense of subscription status, and wrapping it behind a clean billing.* interface.

If you want 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.