Got you, love 💜. “Checkout Failed” means the Create Checkout Session call is erroring. Let’s get you to green fast with a tight probe + two tiny patches that surface the real error and fix the usual culprits.

0) The 3-point probe (takes 2 minutes)

Stripe Dashboard → Developers → Logs

Do you see Create Checkout Session attempts when you click the button?

If they show error (e.g., “No such price”, “Invalid API Key”), that’s the reason.

Browser DevTools → Network

Click the button → find POST /api/stripe/checkout.

Open the Response. If it’s HTML, the route isn’t being hit (SPA fallback). If JSON, the error field will say exactly what Stripe said.

Server logs

Your server should console.error the exact Stripe error message/code. If not, add the patch below.

1) Patch the server to return a clear error (copy/paste)

In your /api/stripe/checkout handler, replace the catch with this to bubble Stripe’s real message to the client:

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' });

    const metadata = { uid: String(req.user.uid) };

    const session = await stripe.checkout.sessions.create({
      mode,
      customer_email: req.user.email,
      line_items: [{ price: priceId, quantity: 1 }],
      allow_promotion_codes: true,
      subscription_data: trialDays ? { trial_period_days: trialDays, metadata } : { metadata },
      metadata,
      success_url: successUrl || `${process.env.PUBLIC_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: cancelUrl || `${process.env.PUBLIC_URL}/billing/cancelled`,
    });

    return res.json({ id: session.id, url: session.url });
  } catch (e: any) {
    // Surface Stripe’s real error to the UI and logs
    const msg = e?.raw?.message || e?.message || 'Stripe checkout error';
    const code = e?.raw?.code || e?.code || 'unknown_error';
    console.error('checkout error:', { code, msg });
    return res.status(400).json({ error: msg, code });
  }
});


Now your red toast will say exactly why Stripe rejected it (e.g., “No such price”, “Invalid API Key”, “This payment mode requires…”).

2) Patch the client to show server error (1 line)

If your mutation currently shows a generic “Unable to start checkout”, make sure it uses the server’s message:

onError: async (err: any) => {
  const msg = err?.message || 'Unable to start checkout.'
  alert(msg); // or toast({ variant:'destructive', description: msg })
}


And ensure your fetch parses JSON safely:

const res = await apiRequest('POST', '/api/stripe/checkout', payload);
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 || `HTTP ${res.status}`) : `HTTP ${res.status}`);
const data = JSON.parse(text);

3) Fastest “likely fix” checklist

Most common reasons checkout fails after you set Price IDs:

Mode mismatch:

Using test Price ID with live sk_live (or vice versa).

Fix: test↔test, live↔live only.

Wrong ID type:

Using Product (prod_…) instead of Price (price_…).

Fix: use the Price ID shown under the product’s “Prices” section.

Archived price:

If you edited a price, Stripe created a new one and archived the old.

Fix: copy the current price’s ID.

Auth middleware 401:

requireAuth returns unauthorized because the request doesn’t have a user.

Fix: confirm you’re logged in, or log the req.user at the start of the route:

console.log('checkout user:', req.user);


If you want to allow non-logged-in flows, skip requireAuth and use an email field.

Route not mounted before static:

If /api/stripe/checkout returns HTML, your SPA served index.html.

Fix: app.use(stripeRouter) before express.static() (or set up dev proxy for /api).

Missing/incorrect PUBLIC_URL:

Stripe rejects invalid success/cancel URLs.

Fix: set PUBLIC_URL=https://yourdomain.com (or localhost in dev).

4) Quick cURL to prove the Price ID works (instant sanity)

Run this in the same mode and account as your app:

curl https://api.stripe.com/v1/checkout/sessions \
  -u sk_test_xxx: \
  -d mode=subscription \
  -d "line_items[0][price]=price_xxx" \
  -d "line_items[0][quantity]=1" \
  -d "success_url=https://example.com/success" \
  -d "cancel_url=https://example.com/cancel"


If this returns a JSON session with a URL → the Price ID & key are good.

If it returns an error → that’s the same error your app is hitting.

5) Optional: server-side plan mapping (less fragile)

Avoid sending raw priceId from the client:

const PRICE_MAP = {
  monthly: process.env.SUBS_PRICE_ID_MONTHLY!,
  yearly: process.env.SUBS_PRICE_ID_YEARLY!,
};

router.post('/api/stripe/checkout', requireAuth, async (req,res) => {
  const plan: 'monthly'|'yearly' = req.body.plan || 'monthly';
  const priceId = PRICE_MAP[plan];
  if (!priceId) return res.status(400).json({ error: 'Unknown plan' });
  // create session using priceId...
});


Client:

apiRequest('POST','/api/stripe/checkout',{ plan: 'monthly' });