Skip to content
Chapter 64Lesson 2

Starting subscriptions with Checkout

Start a recurring subscription with a Stripe Checkout Session, the server-created hosted payment flow that lets the webhook, not the app, provision access.

A user is standing on your pricing page. They’ve read the comparison table, decided the free plan won’t cut it, and their cursor is hovering over a button that says Upgrade to Pro. They click. What has to happen next is the most important path in the whole product, the one that turns a visitor into a paying customer, and it has a surprising number of ways to go wrong.

Look at everything that click has to set in motion. The app has to move this user through a payment flow without its servers ever touching a card number, because handling raw card data yourself drops a compliance burden on your company that no early-stage team wants. It has to start a recurring Subscription bound to the right Customer at the right Price. It has to carry the user’s organization identity all the way through Stripe and back, so that when the flow finishes your system knows which org just became a Pro customer. And it has to land them on a success page that doesn’t get ahead of itself, one that doesn’t announce “you’re on Pro” a half-second before the entitlement that grants Pro actually exists.

One Stripe primitive solves all four at once: the Checkout Session. By the end of this lesson you’ll be able to sketch the server-side action that starts a subscription and say exactly where the provisioning happens, which, as you’ll see, is not on the success page and not in the action at all. You already built the other half of this in the last chapter: the webhook handler that lands the entitlement when Stripe says payment went through. This lesson builds the door the customer walks through to trigger it. It’s also the first method of a small billing.* interface you’ll formalize later in this chapter, but for now you can treat it as one action and one flow.

Before any code, get the primitive itself clear, because four properties of a Checkout Session carry the entire lesson.

It is server-created. A Checkout Session is created with your Stripe secret key, the same STRIPE_SECRET_KEY you locked behind import 'server-only' last lesson. The client never constructs one. Your server calls Stripe, Stripe hands back a session, and only then does anything reach the browser. That sequence is what makes the whole flow safe to expose: the thing the user interacts with was minted by a key the user can never see.

It is single-use and short-lived. What Stripe hands back is a URL, a one-time link to a payment page good for one checkout, which expires after about 24 hours. There is no reason to store it, cache it, or email it. By the time anyone clicked a stored link it would likely be dead, and even if it lived, it represents one specific in-flight checkout, not a reusable “pay here” page. You create a session, you redirect to it, and you forget it.

It is parameterized once, at creation. Everything about the checkout is decided in the single call that creates the session: which Customer is paying, which Price is the line item, where to send the user on success and on cancel, and the fact that this is a recurring subscription rather than a one-off charge. The user is redirected to a Stripe-hosted page carrying all of that, pays, and is redirected back.

The fourth property is the one this whole lesson turns on: a Checkout Session hands off two responsibilities your app should not own. Payment goes to Stripe’s hosted page, so the card number never touches your servers and the burden of PCI compliance shrinks to almost nothing. Provisioning, the act of actually granting the org its Pro access, goes to the webhook, the one writer you built last chapter. The app is at both ends of this flow: it creates the session at the start, and it reads the result at the end. But it is deliberately absent from the middle, where the money and the truth live on Stripe’s side.

That shape is the spine of everything that follows.

Your server creates the session
Stripe-hosted page the user pays
Your success page reads & polls

Your app is at both ends, creating the session and reading the result, but never in the middle, where the card is entered and the payment clears.

The strip above shows only the topology, who talks to whom. It says nothing yet about timing, and timing is where the interesting bugs hide. We’ll come back to it with a clock attached. First, the action that starts the whole thing off.

Here is the one piece of real code in this lesson: the action that runs when that Upgrade to Pro button is clicked. We’ll give it the name it will eventually have in the project. It lives at lib/billing/upgrade.ts and is called as billing.upgrade('pro'). Don’t read anything into that directory or that namespace yet. The architecture behind lib/billing/, why Stripe gets wrapped at all and what else lives in there, is a later lesson’s job. For now it’s just a file and a function, and what matters is its shape.

The action does four things, in order: resolve the org, make sure the org has a Stripe Customer, find the right Price, and create the session. Then it hands the resulting URL back to the caller. Walk through it one step at a time.

'use server';
export async function upgrade(planSlug: 'pro' | 'team'): Promise<{ url: string }> {
const { orgId } = await requireOrgUser();
const org = await getOrganization(orgId);
let customerId = org.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: org.billingEmail,
metadata: { organization_id: org.id },
});
customerId = customer.id;
await db.update(organizations)
.set({ stripeCustomerId: customerId })
.where(eq(organizations.id, org.id));
}
const price = await resolvePrice(planSlug);
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: price.id, quantity: 1 }],
success_url: `${env.NEXT_PUBLIC_APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.NEXT_PUBLIC_APP_URL}/billing`,
subscription_data: { metadata: { organization_id: org.id } },
});
return { url: session.url! };
}

The directive and the signature. File-level 'use server' makes this a Server Action. It returns { url } rather than calling redirect() itself, deliberately, so the caller picks the navigation. It isn’t wired through <form action={...}>; it’s a plain async call fired from the upgrade button’s click handler, given the plan slug.

'use server';
export async function upgrade(planSlug: 'pro' | 'team'): Promise<{ url: string }> {
const { orgId } = await requireOrgUser();
const org = await getOrganization(orgId);
let customerId = org.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: org.billingEmail,
metadata: { organization_id: org.id },
});
customerId = customer.id;
await db.update(organizations)
.set({ stripeCustomerId: customerId })
.where(eq(organizations.id, org.id));
}
const price = await resolvePrice(planSlug);
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: price.id, quantity: 1 }],
success_url: `${env.NEXT_PUBLIC_APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.NEXT_PUBLIC_APP_URL}/billing`,
subscription_data: { metadata: { organization_id: org.id } },
});
return { url: session.url! };
}

Resolve the org. requireOrgUser() is the same server-side org check from the organizations work. It returns the trusted { user, orgId, role } from the session and throws to the framework boundary if there isn’t one. getOrganization(orgId), a helper the project ships, then loads the organization row, from which you read stripeCustomerId, the nullable pointer column from last lesson.

'use server';
export async function upgrade(planSlug: 'pro' | 'team'): Promise<{ url: string }> {
const { orgId } = await requireOrgUser();
const org = await getOrganization(orgId);
let customerId = org.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: org.billingEmail,
metadata: { organization_id: org.id },
});
customerId = customer.id;
await db.update(organizations)
.set({ stripeCustomerId: customerId })
.where(eq(organizations.id, org.id));
}
const price = await resolvePrice(planSlug);
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: price.id, quantity: 1 }],
success_url: `${env.NEXT_PUBLIC_APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.NEXT_PUBLIC_APP_URL}/billing`,
subscription_data: { metadata: { organization_id: org.id } },
});
return { url: session.url! };
}

Ensure the Customer, lazily. If stripeCustomerId is null this org has never paid, so create its Stripe Customer now, stamping organization_id into metadata, and persist the returned id onto the org row. If it’s already set, the block is skipped and the existing Customer is reused.

'use server';
export async function upgrade(planSlug: 'pro' | 'team'): Promise<{ url: string }> {
const { orgId } = await requireOrgUser();
const org = await getOrganization(orgId);
let customerId = org.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: org.billingEmail,
metadata: { organization_id: org.id },
});
customerId = customer.id;
await db.update(organizations)
.set({ stripeCustomerId: customerId })
.where(eq(organizations.id, org.id));
}
const price = await resolvePrice(planSlug);
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: price.id, quantity: 1 }],
success_url: `${env.NEXT_PUBLIC_APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.NEXT_PUBLIC_APP_URL}/billing`,
subscription_data: { metadata: { organization_id: org.id } },
});
return { url: session.url! };
}

Resolve the Price by lookup key. resolvePrice maps the plan slug to a lookup_key ('pro' becomes 'pro_monthly' for the default cycle; the monthly/yearly toggle picks the key) and fetches the matching Price. It resolves by key, never by a raw price_xxx id, which applies last lesson’s test-vs-live discipline at the place it matters most.

'use server';
export async function upgrade(planSlug: 'pro' | 'team'): Promise<{ url: string }> {
const { orgId } = await requireOrgUser();
const org = await getOrganization(orgId);
let customerId = org.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: org.billingEmail,
metadata: { organization_id: org.id },
});
customerId = customer.id;
await db.update(organizations)
.set({ stripeCustomerId: customerId })
.where(eq(organizations.id, org.id));
}
const price = await resolvePrice(planSlug);
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: price.id, quantity: 1 }],
success_url: `${env.NEXT_PUBLIC_APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.NEXT_PUBLIC_APP_URL}/billing`,
subscription_data: { metadata: { organization_id: org.id } },
});
return { url: session.url! };
}

Create the session and return its URL. mode: 'subscription' makes it recurring; customer and line_items bind it to this org at this Price; the two URLs say where Stripe sends the browser on success and on cancel. return { url } hands session.url to the client to redirect to.

1 / 1

A few things in that action deserve a second look, because they’re the difference between an integration that holds up and one that generates support tickets.

Lazy Customer creation, not create-per-session. Notice that the action only calls stripe.customers.create when stripeCustomerId is null, and immediately writes the new id back to the org row. The next time this org upgrades, opens the billing portal, or does anything Stripe-shaped, that stored id is reused. The tempting shortcut is to create a fresh Customer every time you start a checkout, because it’s one fewer branch to write. Resist it. Each customers.create call mints a brand-new billing identity, so a user who checks out twice ends up as two Customers in your Stripe dashboard: two invoice histories, two payment methods, and a reconciliation headache that lands in your support queue. Look up and reuse; create only on the first need.

Resolve the Price by lookup_key. The action never embeds a price_xxxxxxxxxxxxxx string. Those ids differ between your test world and your live world, so a hardcoded one works in development and then breaks the instant you ship. The lookup key is the stable handle you author identically in both worlds, so resolvePrice('pro') returns the right Price no matter which environment it runs in.

Now the most important line in the whole action, the one that connects this lesson to the last chapter:

subscription_data: { metadata: { organization_id: org.id } }

That organization_id is the thread that ties this action to your webhook. Recall from last lesson that metadata rides along on every webhook event for the object it’s attached to. By stamping it under subscription_data, you’re telling Stripe to attach it to the Subscription it’s about to create. So when Stripe later fires customer.subscription.created to the very webhook handler you built last chapter, that event payload carries organization_id right on it. Your handler reads it straight off the event and knows exactly which org’s row to provision. There’s no lookup table and no reverse-mapping from a Customer id back to an org. The metadata is the carry-channel, and this is where you load it.

That’s the action: resolve the org, ensure the Customer, resolve the Price, create the session, return the URL. Everything that actually changes state, the Subscription coming into existence and the entitlement being granted, happens after this function returns, on Stripe’s side and through your webhook. That is exactly what the next section is about.

This is the part beginners get wrong, so we’re going to slow it right down. The instinct is to think the user paid, got redirected back, the success page rendered, and so they’re on Pro. That mental model has a bug, and the bug is a race.

When the user finishes paying, two things happen at roughly the same time, independently. Stripe sends an event to your webhook, and Stripe redirects the user’s browser to your success page. These are two separate channels racing each other, and there is no guarantee the webhook wins. Quite often the browser redirect, a fast and direct hop, lands before the webhook event has been delivered and processed. So your success page can render at a moment when the entitlement it wants to show does not exist yet.

Scrub through the timeline below to watch it play out. Notice especially steps 4 and 5: the parallel branches, and which one actually writes the entitlement.

Stripe payments + events
Browser success page
Webhook your server
Your DB entitlements free
The user submits payment on the Stripe-hosted page.
Stripe payments + events
Browser success page
Webhook your server
Your DB entitlements free

Stripe creates the Subscription, with status trialing if there’s a trial, otherwise active (or incomplete if the first charge fails). Nothing has reached your app yet.

Stripe payments + events
Browser success page
Webhook your server
Your DB entitlements free

Stripe fires checkout.session.completed, then customer.subscription.created, to the webhook you built last chapter.

Stripe payments + events
Browser success page Finalizing…
Webhook your server
Your DB entitlements free

In parallel, Stripe redirects the browser to /billing/success. The page renders and reads the entitlement, and may still see free. This is the race.

Stripe payments + events
Browser success page Finalizing…
Webhook your server
Your DB entitlements pro

The webhook arrives and upserts plan_entitlements, the single writer, with last chapter’s ordering rule. This is the only step that writes the entitlement.

Stripe payments + events
Browser success page You're on Pro
Webhook your server
Your DB entitlements pro

The success page’s poll re-reads, now sees the new entitlement, and swaps “Finalizing…” into “You’re on Pro.”

The whole lesson rests on what you just watched, so here are the rules it implies, each one a place beginners reliably trip.

The success page reads and polls. It never writes the entitlement. This is the single-writer rule from last chapter, and it’s worth restating because the success page is the most tempting place in the entire app to break it. You have the session_id right there in the URL, so it would be easy to look up the session, see that it’s paid, and write the Pro entitlement from the page. Don’t. The moment the success page can write the entitlement, you have two writers, the page and the webhook, racing to write the same row, which is precisely the dual-writer hazard you engineered away last chapter. The page’s only job is to read the entitlement and, if it’s not ready yet, poll until it is.

How does it poll? That’s last chapter’s machinery, and we’re not rebuilding it here, but in one sentence: the success page is a route the app controls, it re-reads the entitlement on an interval (a router.refresh() against a small time budget), and it shows a “Finalizing…” state until the row flips. It’s the very page you built last chapter, now mounted under /billing/ as we gather the billing surface in one place, so update the route if you’re carrying that code forward. The piece this lesson is responsible for is just the success_url you saw in the action: that it points at a route inside your app and carries the session id.

success_url: `${env.NEXT_PUBLIC_APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,

Two details on that URL earn a rule each.

success_url must stay inside the app shell. It points at /billing/success, a route your application renders and controls. The temptation is to send a freshly converted customer to a celebratory marketing page, like /welcome-to-pro on your landing site. Do that and you’ve thrown away the entire polling story, because a static marketing page can’t read your entitlements row or re-render when the webhook lands. Keep the user on a route the app owns, so the poll can actually run.

?session_id=... is a polling handle, not proof of payment. Stripe substitutes the real session id into that {CHECKOUT_SESSION_ID} placeholder on redirect, and the success page can use it to ask the server whether this checkout’s entitlement is ready yet. What it must never do is treat the mere presence of a session_id in the URL as proof the user paid and grant them access. That parameter is just a string in a URL, and anyone can type one, so it’s forgeable. The proof of payment is the webhook, full stop; the session id is only a handle for polling. Stripe does offer a way to retrieve the session server-side and read its payment status as a faster confirmation, which last chapter mentioned as a conditional fast path. This course deliberately doesn’t reach for it by default, because correctness through the webhook beats shaving a second off perceived latency. The webhook is the source of truth.

One more, on the other URL:

The cancel URL means “no state change,” nothing more. cancel_url is where Stripe sends the user if they dismiss the Checkout page without paying, whether they hit back, close the tab, or change their mind. The key point is that nothing happened. No Subscription was created, and the user never had a subscription. So treat a cancel-url visit as a plain navigation event and send them back to where they were. Do not log “user canceled subscription,” because they had nothing to cancel, and that line will mislead whoever reads it later. They might well come back and upgrade in five minutes; a bounce off the checkout page is not worth treating as an event.

Now fix the timeline in place, because reconstructing this ordering correctly is what separates “I get Checkout” from “I think the redirect means it’s done.” Try ordering it.

A user just paid for Pro. Put these events in the order they actually happen — and watch the trap: writing the entitlement on the success page is not one of these steps, the webhook owns that write. Drag the items into the correct order, then press Check.

The user submits payment on the Stripe-hosted page.
Stripe creates the Subscription and fires its events.
The browser is redirected to /billing/success and the page reads the entitlement — possibly still free.
The webhook receives the event and upserts the plan_entitlements row.
The success page polls again, reads the new entitlement, and shows “You’re on Pro.”

If you placed the redirect (step 3) before the webhook write (step 4), you’ve got the core insight: the page can render before the entitlement exists, which is why it polls instead of trusting the redirect.

Trials, payment methods, and other session options

Section titled “Trials, payment methods, and other session options”

So far you’ve seen the minimal session: mode, customer, line_items, two URLs, and the metadata. Everything else a Checkout Session can do is configuration layered onto that same checkout.sessions.create call. These aren’t separate features or separate machinery; they’re knobs on one object. Here are the ones worth knowing, each with what it does, what the course defaults to, and when you’d change it.

lib/billing/upgrade.ts
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: price.id, quantity: 1 }],
success_url: `${env.NEXT_PUBLIC_APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.NEXT_PUBLIC_APP_URL}/billing`,
subscription_data: {
metadata: { organization_id: org.id },
trial_period_days: 14,
},
payment_method_collection: 'always',
allow_promotion_codes: true,
customer_update: { address: 'auto', name: 'auto' },
automatic_tax: { enabled: true },
});

Trial: subscription_data.trial_period_days. A trial is a property of the Subscription, set at creation. Pass trial_period_days: 14 and the Subscription Stripe creates starts in status trialing instead of active, exactly the 14-day Pro trial from our running example. What trialing means for access (does a trialing org get the full Pro feature set, and what does the billing screen show?) is its own lesson later in this chapter. Here, just know that the trial is a number you pass on the session and it lands as a status on the Subscription.

Payment-method collection: payment_method_collection. This one trips teams up, so it gets a full answer. The field decides whether Checkout collects a card up front. It has two values: 'always' collects a card even when nothing is due right now (a trial that asks for the card before it begins), and 'if_required' skips the card when there’s nothing to charge yet (a card-less trial). The course default is 'always'. A trial that takes a card converts noticeably better, and it avoids a sharp drop-off: with no card on file, the day the trial ends there’s nothing to charge, so the user silently lapses to free and is gone before they ever felt the value. The trade-off is real and worth naming: a card-less trial has lower friction at signup, but it loses far more never-converting signups. Collecting the card at trial start is the stance that protects conversion.

Hosted vs. embedded: the 2026 default. Stripe ships two ways to render the checkout: a hosted page (the redirect you’ve been picturing all lesson, on a Stripe-owned URL) and an embedded experience (the payment form mounted directly on your own page). The course defaults to hosted, and it does so by simply not configuring an alternative. The call above has no ui_mode field, and that omission is choosing hosted. Hosted wins for an early-stage team on every axis that matters: there’s no client-side Stripe.js to wire up, it behaves identically in a web browser and inside a mobile webview, and it never collides with your page’s content-security policy because it isn’t on your page. You’d reach for embedded only when you have a real reason, such as retention data showing the redirect itself is costing you conversions, or a brand requirement for a single-domain experience. Even then it’s a meaningful chunk of client wiring (Stripe.js, a client secret, a mounted component) that’s out of scope here. Know that embedded exists, then default to hosted and move on.

The last three are one-liners. Know the name, know it exists, and reach for it when the feature calls for it.

  • Promotion codes: allow_promotion_codes: true. Lets users type in a discount code you created in the Stripe dashboard and have it applied at checkout. Turn it on when you ship a discount-code feature; leave it off otherwise.
  • Customer updates: customer_update: { address: 'auto', name: 'auto' }. Lets edits the user makes to their address or name during checkout flow back onto the saved Customer. Pass it when you’re collecting a tax-relevant billing address you want to persist.
  • Tax: automatic_tax: { enabled: true }. Hands sales-tax and VAT calculation to Stripe Tax, which computes the right tax on the session automatically. It has a prerequisite: you must have configured your tax registrations in the Stripe dashboard first. Know the toggle and the prerequisite; the compliance details are beyond this course.

Of all of these, one decision tends to actually trip a team up in practice. Make sure it’s the one you’ve internalized.

You’re shipping the 14-day Pro trial and you want the highest trial-to-paid conversion. Which payment_method_collection value do you set, and what does it buy you?

'always' — the card is captured at signup, so when day 14 arrives Stripe charges it and the subscription continues with nothing for the user to do.
'if_required' — dropping the card requirement lowers signup friction, which guarantees every trial user converts to paid.
'always' — and the upside is that Stripe runs the first charge the moment the trial starts, locking in revenue on day one.
Either value works the same here — payment_method_collection only governs one-time payments, so it has no effect on a subscription’s trial.

Step back and look at what you can now do. Given a plan slug, you can produce the URL that starts a subscription, and you can explain every hop of what happens after the user follows it. The reference signature, the one the project implements, is:

billing.upgrade(planSlug: 'pro' | 'team'): Promise<{ url: string }>

Read it as the flow you walked: it resolves the org, ensures the org has a Customer (lazily, reusing one if it exists), creates the Checkout Session, and returns the URL. It returns the URL rather than redirecting on the server so the client picks the navigation, whether full-page or new tab; the action doesn’t care.

This is the first of three methods in a small billing.* interface. The other two, openPortal for managing an existing subscription and requirePlan for gating features behind a plan, come later in this chapter, along with the reasoning for why Stripe earns a wrapper at all. For now you only need the one you built: upgrade is how a subscription starts. Notice the boundary it draws: upgrade is for new subscriptions, and once an org is paying, changing or cancelling that subscription is a different door entirely, the Customer Portal, which is the next lesson.

If you want Stripe’s own framing of this flow, a few official docs are worth the time. One is the end-to-end guide for exactly what you built; another is the parameter reference for the session object you’ve been configuring.