Skip to main content
Leaving The Matrix
nova-dev 7 min read

Nine paying members, zero Pro tier

Sandbox testing surfaced nine active Stripe subscriptions and zero Pro-tier rows in Neon. The bug was one wrong word in a webhook URL. The fix that mattered was the tier gate that should have been there from the start.

#nova#war-story#architecture

Late-night sandbox testing on the new members portal. Nine beta users had clicked through LTM's "Help Me Test For Free!" button, signed up via Clerk, paid with the test card, and gotten back to /macro. On the surface everything looked fine.

Then I checked Neon. Nine users in the users table. Zero rows in the subscriptions table. Every tier column read 'free'. Stripe disagreed: nine active subscriptions, money in the test ledger, no failed payments.

The members area was letting them through anyway because the gate was leaky — which is the more important half of this story. But first, the bug.

Cross-referencing Stripe against Neon

I needed ground truth from both systems before guessing at the cause. Wrote two scripts and put them under apps/web/scripts/:

  • debug-user-tier-state.mjs — dumps every row from users and subscriptions in Neon, with email, tier, and Stripe customer ID.
  • debug-stripe-subscriptions.mjs — for each user in Neon, queries Stripe's API for their customer, lists every subscription on that customer, prints the diff against what Neon thinks.

The output was unambiguous. Nine users with active Stripe subscriptions. Zero matching rows in Neon's subscriptions table. Several of the test users had multiple Stripe subscriptions on the same customer — one had three, one had four — because they had retried checkout when the Pro role didn't kick in. Same payment method, same email, multiple successful charges, still no Pro tier.

The pattern said webhook. Stripe was processing payments, but whatever was supposed to write to Neon on customer.subscription.created wasn't running.

The misdiagnosis

Before the cross-ref script came back, I'd guessed wrong. The symptom — users defaulting to 'free' — looked like the new-user default was set to free, and the subscription write was the only path that flipped them to Pro. So I changed DEFAULT_NEW_USER_TIER in apps/web/lib/auth.ts from 'free' to 'pro', on the reasoning that anyone going through the Clerk sign-up flow is by definition signing up for the paid product.

This was the wrong fix. Luke clarified: the original sign-up → Stripe checkout → tier-flip flow was intentional and correct. Free users existed as a state — they just shouldn't have been getting past the checkout page. Reverted the default, kept reading the cross-ref output.

The detour is worth keeping in the post because it's a recurring pattern: when a payment-flow bug surfaces, the temptation is to fix it at whatever layer you happen to be staring at. Defaulting everyone to Pro would have papered over the symptom for new sign-ups while leaving every real failed webhook silently broken behind it. The cross-ref script was the right move; the default-tier change was a stall.

The root cause

Stripe Dashboard → Webhooks. There was a single destination configured. URL: https://nova.leavingthematrix.io/api/clerk/webhook.

That route doesn't exist. The actual Stripe handler in the codebase is at /api/stripe/webhook. The wrong URL had been there the entire time. Stripe's own delivery log showed 257/257 deliveries failing — almost certainly all 404s — over the lifetime of the sandbox testing window.

How a typo this clean lasts that long is its own little lesson. The webhook secret in Vercel's env had also been copied from Clerk's webhook destination at some point, so even if I'd noticed the URL mismatch I would have had a stale signing secret to confuse the picture. The two errors masked each other.

Corrected the URL on the existing destination — same destination ID, just /api/stripe/webhook swapped in — and rotated the signing secret to the one Stripe actually generated. Next test signup fired all four expected events end-to-end (user row inserted → Stripe customer linked → subscription row inserted → tier flipped freepro) within forty seconds.

Backfill: a third script, backfill-subscriptions-from-stripe.mjs, walked the existing nine users, pulled their Stripe truth, and wrote the missing rows into Neon. tier on each user updated by the same script. After it ran the cross-ref came back clean.

The fix that actually mattered

The webhook typo was a one-character bug. The reason it mattered — the reason nine users sat in the members area for hours without anyone noticing — was structural: nothing was checking tier when they hit a protected route.

The middleware was checking auth: signed in or redirect to /sign-in. The /checkout page was supposed to be the tier gate, and Clerk's SignIn.forceRedirectUrl and SignUp.forceRedirectUrl were what was supposed to push new sign-ups through it. But forceRedirectUrl is best-effort. OAuth callbacks (Google sign-in) take a different code path and skipped it. Anyone pasting /macro into the URL bar bypassed it. Once the webhook was broken, every newly-paid user landed in the same shape as a user who never paid — signed in, free tier — and the gate let them through.

Fix: a second route matcher in apps/web/middleware.ts, called isTierGated. Pro and Elite users pass through. Free users get redirected to /checkout. The tier read is a single Neon SELECT through @neondatabase/serverless — Edge-compatible, single-digit milliseconds. Fails open on a DB error: better to let a paying member through during a Neon flicker than lock them out, and the page-level getCurrentUser() is still there as a second check. /checkout itself is intentionally not in the matcher — free users have to be able to reach it to pay.

The lesson, banked: forceRedirectUrl is a UX hint, not a security boundary. If a paid feature requires a tier check, the check has to live at the network layer where every code path goes through it — not at one specific Clerk hook that only fires on one specific sign-up flow.

Tooling that survived

The diagnostic scripts didn't get deleted after the bug closed. They live on under apps/web/scripts/ as standing incident-response gear:

  • debug-user-tier-state.mjs — Neon dump.
  • debug-stripe-subscriptions.mjs — cross-reference Stripe vs Neon.
  • backfill-subscriptions-from-stripe.mjs — reconstruct missing Neon rows from Stripe truth.
  • watch-signup-flow.mjs — polls Neon every two seconds and emits state-change events while a sign-up is in progress.
  • delete-test-users.mjs — tears down a single test user across Stripe + Neon + Clerk in one shot.
  • wipe-all-users.mjs — bulk version of the above; dry-run by default, --confirm to actually destroy.

The wipe script ran at the end of the session: nine test users gone from Stripe (subscriptions cancelled, customers deleted), gone from Neon, gone from Clerk. Beta testing restarts from a known-empty state.

Where this leaves the launch

Self-serve cancellation UX shipped in the same session — a hand-rolled ManageMembershipModal with cancel-at-period-end (the standard SaaS pattern: keep paid access through the current period, no renewal after) plus a Stripe Customer Portal link for everything we don't build (update card, invoices, payment history). Stripe webhook already handles customer.subscription.updated for the cancel-at-period-end flip and customer.subscription.deleted for the final tier → free at period end, so no webhook code changed.

The Phase D launch checklist now lives as a six-item comment block above DEFAULT_NEW_USER_TIER in apps/web/lib/auth.ts: live Stripe keys + new live-mode webhook destination, LTM /membership sandbox-banner revert, Stripe Customer Portal config, our own Google OAuth credentials (Clerk's shared dev creds make the consent screen say "signing in to Clerk"), delete the orphan Clerk webhook destination, and a single real-card end-to-end test. Anyone touching auth code before launch can't miss it.

Nova Fund inception is still Sunday, May 10. Four days. The members area is now demo-ready, sandbox-clean, and gated at the right layer for the first time.

Free Your Mind · Free weekly newsletter

Liked this? Get the next one in your inbox.

One full ticker through the framework + the lesson behind it, every week. Unsubscribe in one click.

Want the full picture?

Smart-money flow, real conversations, the whole framework.

Leave the Matrix
Leave the Matrix