Skip to content
Chapter 64Lesson 2

Starting subscriptions with Checkout

Start a recurring subscription with a server-created Stripe Checkout Session, leaving the webhook to provision access.

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

That click has to do four things. Move the user through a payment flow without your servers ever touching a card number, since handling raw card data yourself lands a compliance burden no early-stage team wants. Start a recurring Subscription bound to the right Customer at the right Price. Carry the user’s organization identity through Stripe and back, so that when the flow finishes your system knows which org just became a Pro customer. And 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 it exists.

One Stripe primitive solves all four: the Checkout Session. The provisioning, though, happens neither on the success page nor in the action that starts the flow. It happens in the webhook handler you built last chapter, the one that lands the entitlement once Stripe confirms payment. This lesson builds the door the customer walks through to trigger it.

Four properties of a Checkout Session carry the rest of the lesson, so get the primitive clear first.

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

It is single-use and short-lived. What Stripe returns is a URL, a one-time link to a payment page good for a single checkout, expiring after about 24 hours. Don’t store it, cache it, or email it: a saved link would likely be dead by the time anyone clicked it, and even alive it represents one in-flight checkout, not a reusable “pay here” page. You create a session, redirect to it, and forget it.

It is parameterized once, at creation. The single create call decides everything: which Customer is paying, which Price is the line item, where to send the user on success and on cancel, and that this is a recurring subscription rather than a one-off charge. The user is redirected to a Stripe-hosted page carrying all of it, pays, and is redirected back.

The fourth property is the one this 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 your PCI compliance scope shrinks to almost nothing. Provisioning, granting the org its Pro access, goes to the webhook, the single writer you built last chapter. Your app sits at both ends, creating the session and reading the result, but is deliberately absent from the middle, where the money and the truth live on Stripe’s side.

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.

This is only the topology, who talks to whom; the timing, where the interesting bugs hide, comes later. First, the action that starts it all off.

This is the one piece of real code in the lesson: the action behind the Upgrade to Pro button. We’ll give it the name it will carry in the project, billing.upgrade('pro'), living at lib/billing/upgrade.ts; why Stripe gets wrapped at all is a later lesson, so for now read it as one file and one function and watch its shape.

It does four things in order, then hands the result to the caller: resolve the org, ensure the org has a Stripe Customer, find the right Price, and create the session.

'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, 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 with 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, or throws to the framework boundary. getOrganization(orgId) then loads the org 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. A null stripeCustomerId means 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, applying last lesson’s test-vs-live discipline where 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

Three lines in that action separate an integration that holds up from one that generates support tickets.

Lazy Customer creation, not create-per-session. The action calls stripe.customers.create only when stripeCustomerId is null, then writes the new id back to the org row so the next upgrade or portal visit reuses it. Creating a fresh Customer on every checkout is one fewer branch, but each call mints a new billing identity: a user who checks out twice becomes two Customers in your dashboard, with two invoice histories and two payment methods to reconcile. Look up and reuse; create only on first need.

Resolve the Price by lookup_key. The action never embeds a price_xxxxxxxxxxxxxx string, because those ids differ between your test and live worlds, so a hardcoded one works in development and 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 in either environment.

The third line is the one that ties this action to your webhook:

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

Metadata rides along on every webhook event for the object it’s attached to. Stamping it under subscription_data attaches it to the Subscription Stripe is about to create, so when Stripe fires customer.subscription.created to the handler you built last chapter, the payload carries organization_id on it. Your handler reads it straight off the event and knows which org’s row to provision, with 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.

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 what the next section is about.

The instinct is to think the user paid, got redirected back, the success page rendered, 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 independently, at roughly the same time. Stripe sends an event to your webhook, and Stripe redirects the browser to your success page. There’s no guarantee the webhook wins. Often the redirect, a fast direct hop, lands before the webhook is delivered and processed, so your success page can render while the entitlement it wants to show doesn’t exist yet.

Watch steps 4 and 5 in the timeline: the parallel branches, and which one 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.”

That timeline implies four rules, each a place beginners reliably trip.

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

That polling machinery is last chapter’s, not rebuilt here: the success page re-reads the entitlement on an interval (a router.refresh() against a small time budget) and shows a “Finalizing…” state until the row flips. It now lives under /billing/ as we gather the billing surface in one place, so update the route if you’re carrying that code forward. What this lesson owns is just the success_url.

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 app renders. The temptation is to send a fresh customer to a celebratory marketing page like /welcome-to-pro on your landing site. Do that and you’ve thrown away the 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 run.

?session_id=... is a polling handle, not proof of payment. Stripe substitutes the real session id into the {CHECKOUT_SESSION_ID} placeholder on redirect, and the page uses it to ask the server whether this checkout’s entitlement is ready. It must never treat the mere presence of a session_id as proof of payment and grant access: that parameter is just a string in a URL, and anyone can type one. The webhook is the only proof. Stripe does let you retrieve the session server-side to read its payment status as a faster confirmation, but this course doesn’t reach for it by default, because correctness through the webhook beats shaving a second off perceived latency.

The cancel URL means “no state change,” nothing more. cancel_url is where Stripe sends the user if they dismiss Checkout without paying, whether they hit back, close the tab, or change their mind. Nothing happened: no Subscription was created, and the user never had one. Treat the visit as a plain navigation event and send them back where they were. Do not log “user canceled subscription,” because they had nothing to cancel and that line will mislead whoever reads it later.

Reconstruct the ordering yourself, since that’s what separates “I get Checkout” from “I think the redirect means it’s done.”

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 have 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 does is a knob on that same checkout.sessions.create call. Here are the ones worth knowing, each with what it does, the course default, and when to 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. Pass 14 and the Subscription Stripe creates starts in status trialing instead of active, the 14-day Pro trial from our running example. What trialing means for access is a later lesson in this chapter.

Payment-method collection: payment_method_collection. This decides whether Checkout collects a card up front. 'always' (the default) collects a card even when nothing is due right now; 'if_required' skips it when there’s nothing to charge yet, a card-less trial. The course defaults to 'always' because a card-up-front trial converts higher and avoids a silent lapse: with no card on file, the day the trial ends there’s nothing to charge, so the user drops back to free before they ever felt the value.

Hosted vs. embedded. Stripe can render Checkout as a hosted page on a Stripe-owned URL (the redirect you’ve pictured all lesson) or as a form embedded on your own page. The course defaults to hosted by not configuring an alternative. Hosted needs no client-side Stripe.js, behaves the same in a browser and a mobile webview, and never collides with your page’s content-security policy . Reach for embedded only with a real reason; its wiring is out of scope here.

The last three are one-liners worth recognizing:

  • Promotion codes: allow_promotion_codes: true. Lets users apply a discount code you created in the Stripe dashboard at checkout.
  • Customer updates: customer_update: { address: 'auto', name: 'auto' }. Flows address or name edits made during checkout back onto the saved Customer. Use it when collecting a tax-relevant billing address.
  • Tax: automatic_tax: { enabled: true }. Hands sales-tax and VAT calculation to Stripe Tax. Prerequisite: configure your tax registrations in the Stripe dashboard first.

One of these decisions trips teams up more than the rest.

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.

With upgrade you can now turn a plan slug into the URL that starts a subscription: billing.upgrade(planSlug: 'pro' | 'team'): Promise<{ url: string }>. It’s the first of three billing.* methods, and it only starts a subscription. Once an org is paying, changing or cancelling it runs through the Customer Portal, which is the next lesson.

For Stripe’s own framing of this flow: the end-to-end subscription guide, why the webhook provisions access, and the parameter reference for the session object.