2026-06-03
Stripe Subscription Checkout in Production: Auth, Price IDs, and Webhooks That Actually Work

Part of Building Bizee — Technical Notes. See also the Bizee case study.
Introduction
The hook
You wire up Stripe Checkout. Locally it “works.” You deploy. Suddenly: 401 on create session, wrong price IDs for yearly plans, and webhooks that never update your User row. The card charged. Your app still thinks they’re Free.
That was us shipping subscriptions for Bizee — a live SaaS for local service businesses.
The objective
By the end of this post you will know how to:
- Authenticate checkout session creation reliably in a Next.js App Router API.
- Map monthly/yearly Stripe Price IDs without hard-coding chaos.
- Verify webhooks and sync subscription state to your database.
Prerequisites
- Basic Next.js (App Router) and TypeScript
- A Stripe account (test mode is fine to start)
- Prisma (or any ORM) and a production Postgres (we use Neon)
- Familiarity with HTTP cookies / JWT auth patterns
Body
Step 1: Treat checkout as an authenticated API, not a public form post
Checkout session creation must know who is buying. On Bizee we resolve the user inside the route with a shared helper that reads the request in the Node.js runtime (not Edge middleware guessing cookies).
// Simplified pattern from our create-checkout-session route
export async function POST(request: NextRequest) {
const userId = await getUserIdFromRequest(request);
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
// validate priceId with Zod…
// ensureStripeCustomer({ userId, email, stripeCustomerId })
// stripe.checkout.sessions.create({ mode: 'subscription', … })
}
Lesson: If middleware can’t reliably see auth cookies in Edge, don’t invent a second auth path. Resolve identity where cookies are trustworthy — the route handler.
Step 2: Price IDs are product config, not vibes
We run multiple tiers and billing intervals. Mixing up price_… IDs between monthly and yearly is a silent production bug: Checkout succeeds for the wrong plan.
Practical approach:
- Store Price IDs in env (
STRIPE_PRICE_PRO_MONTHLY,STRIPE_PRICE_PRO_YEARLY, …). - Validate the client only sends a
priceIdthat exists in your allowlist. - Put
userId(and plan name) in session + subscription metadata so webhooks can reconnect the dots.
const stripeSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [{ price: validatedData.priceId, quantity: 1 }],
success_url: `${appUrl('/subscription/success')}?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: appUrl('/subscription/cancel'),
metadata: { userId, planName: validatedData.metadata?.planName },
subscription_data: {
metadata: { userId, planName: validatedData.metadata?.planName },
},
});
Step 3: Webhooks are the source of truth after payment
Checkout success URL is UX. Webhook events are truth.
Minimum events to handle for subscriptions:
checkout.session.completedcustomer.subscription.updatedcustomer.subscription.deleted
Always:
- Verify the Stripe signature with the raw body.
- Idempotently update your DB (
stripeCustomerId,stripeSubscriptionId, tier, status). - Return
200quickly; do heavy work after verify.
If the webhook never fires in prod: check endpoint URL on the Stripe Dashboard, signing secret for that endpoint (not the CLI secret), and that your deploy isn’t stripping the raw body.
Step 4: Prove it on the real domain
We validated Bizee subscriptions end-to-end against production (bizee.life) in test mode first: create checkout → pay test card → confirm webhook 200 → confirm UI shows the paid tier.
Local mocks are useful. Production smoke is required.
Architecture (mental model)
[Owner clicks Upgrade]
│
▼
[POST /api/stripe/create-checkout-session] ──auth──► userId
│
▼
[Stripe Checkout hosted page]
│
▼
[Stripe webhook] ──verify──► update User / Subscription in Neon
│
▼
[App reads tier from DB on next request]
Conclusion
Summary
- Authenticate checkout in the route handler; don’t rely on fragile Edge cookie reads.
- Treat Price IDs as config; put
userIdin Stripe metadata. - Webhooks sync state; success pages don’t.
- Smoke-test on the real deploy URL.
Call to Action
Try the live product and demo sandbox: bizee.life · bizee.life/demo.
If you ship Stripe for a SaaS, comment with the one webhook event that bit you hardest.