← All posts

2026-06-03

Stripe Subscription Checkout in Production: Auth, Price IDs, and Webhooks That Actually Work

StripeNext.jsSaaSWebhooksBizee

Bizee Stripe subscription checkout and webhook flow cover

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:

  1. Authenticate checkout session creation reliably in a Next.js App Router API.
  2. Map monthly/yearly Stripe Price IDs without hard-coding chaos.
  3. 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 priceId that 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.completed
  • customer.subscription.updated
  • customer.subscription.deleted

Always:

  1. Verify the Stripe signature with the raw body.
  2. Idempotently update your DB (stripeCustomerId, stripeSubscriptionId, tier, status).
  3. Return 200 quickly; 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 userId in 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.

References