et’s get subscriptions flowing. When a “signup to buy” page breaks, it’s almost always one of these: missing/incorrect Stripe keys or price IDs, the API route returning HTML (not JSON), wrong success/cancel URLs, or the webhook not upgrading the user after purchase.

Here’s a tight, copy-pasteable path to green.

Quick triage checklist (2 mins)

Open DevTools → Network while clicking “Buy/Subscribe”.

Does a call hit POST /api/stripe/checkout (or similar)?

Is the status 200 + JSON (not HTML)?

If 4xx/5xx, copy the error message.

Stripe Dashboard → Developers → Logs.

Do you see requests for Create Checkout Session?

Any errors like “No such price” / “Invalid API Key” / “Permission…”?

Env sanity:

Using live keys in prod domain and test keys in dev:

STRIPE_SECRET_KEY=sk_live_... or sk_test_...

NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_... or pk_test_...

Correct Price ID: price_123… (not the product ID).

Minimal, proven setup (drop-in)
1) Server: create Checkout Session

server/routes/stripe.ts

import express from 'express';
import Stripe from 'stripe';

const router = express.Router();

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
  apiVersion: '2023-10-16',
});

function requireAuth(req: any, res: any, next: any) {
  // TODO: replace with your real auth (Firebase/JWT). Expect req.user.uid, req.user.email
  if (!req.user) return res.status(401).json({ error: 'Unauthorized' });
  next();
}

router.post('/api/stripe/checkout', requireAuth, async (req, res) => {
  try {
    const { priceId, mode = 'subscription', successUrl, cancelUrl, trialDays } = req.body;

    if (!priceId) return res.status(400).json({ error: 'Missing priceId' });

    // Attach the Firebase UID so webhook can link purchase → user
    const metadata = { uid: String(req.user.uid) };

    const session = await stripe.checkout.sessions.create({
      mode, // 'subscription'
      customer_email: req.user.email, // or attach an existing customer id if you store it
      line_items: [{ price: priceId, quantity: 1 }],
      allow_promotion_codes: true,
      subscription_data: trialDays ? { trial_period_days: trialDays, metadata } : { metadata },
      metadata, // duplicate on session for redundancy
      success_url: successUrl || `${process.env.PUBLIC_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: cancelUrl || `${process.env.PUBLIC_URL}/billing/cancelled`,
    });

    res.json({ id: session.id, url: session.url });
  } catch (e: any) {
    console.error('checkout error', e);
    res.status(500).json({ error: e.message || 'Stripe checkout error' });
  }
});

export default router;


Mount early in server (before static):

app.use(express.json());
app.use(stripeRouter); // before SPA/static


.env

STRIPE_SECRET_KEY=sk_test_******        # or sk_live_******
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_******   # or pk_live_******
PUBLIC_URL=https://ibrandbiz.com        # your prod root
SUBS_PRICE_ID=price_1234567890abcdef     # your monthly/yearly price

2) Client: button to start checkout

Where your signup/subscribe CTA lives:

import { useMutation } from '@tanstack/react-query';
import { apiRequest } from '@/lib/queryClient';
import { Button } from '@/components/ui/button';

const createCheckout = async (priceId: string) => {
  const res = await apiRequest('POST', '/api/stripe/checkout', {
    priceId,
    mode: 'subscription',
    successUrl: `${window.location.origin}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
    cancelUrl: `${window.location.origin}/billing/cancelled`,
    trialDays: 7, // optional
  });

  // robust parse to avoid HTML error pages
  const text = await res.text();
  const isJson = (res.headers.get('content-type') || '').includes('application/json');
  if (!res.ok) throw new Error(isJson ? (JSON.parse(text).error || 'Checkout failed') : `HTTP ${res.status}`);
  const data = isJson ? JSON.parse(text) : null;
  return data as { id: string; url: string };
};

const SubscribeButton = ({ priceId }: { priceId: string }) => {
  const mutation = useMutation({
    mutationFn: () => createCheckout(priceId),
    onSuccess: (data) => {
      if (data?.url) window.location.href = data.url;
    },
    onError: (e: any) => alert(e.message || 'Failed to start checkout'),
  });

  return (
    <Button disabled={mutation.isPending} onClick={() => mutation.mutate()}>
      {mutation.isPending ? 'Redirecting…' : 'Start Subscription'}
    </Button>
  );
};


If clicking the button does nothing or toasts red, check Network for the /api/stripe/checkout response. It must be 200 JSON with a url. If you see HTML, your route isn’t being hit (SPA is), or an upstream error is returning an HTML page.

3) Webhook: mark user Pro after payment

Stripe should tell your server immediately. Update the user’s role → pro.

server/routes/stripe-webhook.ts

import express from 'express';
import Stripe from 'stripe';
// import { setUserRolePro } from './yourUserStore'; // implement this

const router = express.Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, { apiVersion: '2023-10-16' });
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!; // from Dashboard

// IMPORTANT: raw body for signature verification
router.post('/api/stripe/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  let event: Stripe.Event;
  try {
    const sig = req.headers['stripe-signature'] as string;
    event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
  } catch (err: any) {
    console.error('Webhook signature verify failed:', err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Handle relevant events
  switch (event.type) {
    case 'checkout.session.completed': {
      const s = event.data.object as Stripe.Checkout.Session;
      const uid = (s.metadata && (s.metadata as any).uid) || (s.subscription ? (s as any).subscription_metadata?.uid : '');
      if (uid) setUserRolePro(uid).catch(console.error);
      break;
    }
    case 'invoice.paid': {
      const inv = event.data.object as Stripe.Invoice;
      const uid = inv.metadata?.uid || (inv.subscription_details as any)?.metadata?.uid;
      if (uid) setUserRolePro(uid).catch(console.error);
      break;
    }
    // Optionally handle 'customer.subscription.updated' / 'deleted' for downgrades/cancellations
  }

  res.json({ received: true });
});

export default router;


Express app must allow raw on that route only. Don’t put express.json() before the webhook without excluding it.

.env

STRIPE_WEBHOOK_SECRET=whsec_***********


setUserRolePro(uid) (Firestore example):

import { doc, setDoc } from 'firebase-admin/firestore';
// …
async function setUserRolePro(uid: string) {
  await setDoc(firestore.doc(`users/${uid}`), { role: 'pro', proSince: new Date().toISOString() }, { merge: true });
}

4) Success page (optional confirm)

On /billing/success, you can poll your user profile until role === 'pro' or show “You’re Pro!” if the webhook already flipped it.

Common breakpoints to check

“No such price” → you used a Product ID (prod_…) instead of Price ID (price_…).

HTML on /api/stripe/checkout → route not mounted before static, or wrong dev proxy; the SPA returned your index.html.

401 from Stripe → wrong key (test vs live). Match pk_test + sk_test in dev, pk_live + sk_live in prod.

Webhook 400 signature error → using JSON middleware; switch to express.raw for that route only and use the correct whsec_….

User not pro after paying → webhook not firing or not handling; check Stripe Dashboard → Webhooks → your endpoint → recent attempt logs.

If you paste the server route + client button above and confirm the env vars + webhook, your signup-to-subscribe flow will work. If you can share the exact error/toast you see or a Network screenshot, I’ll pinpoint the one line to tweak.