WRESTLINGCOLLEGES.COM

Build in public ·

Firebase Extensions are sunsetting: the Stripe backend I wrote instead

The Stripe Firebase extension failed to install, then announced its own sunset. The ~120-line subscription backend that replaced it, and how it was tested.

On this page

Recruit Pass is the paid part of this site: $79 a year or $12 a month unlocks the weight-class depth on each program page. It runs on Firebase Auth, Stripe subscriptions and one Cloud Function that returns premium JSON only to people who have paid. The plan was to use the official Stripe Firebase Extension for the Stripe half. That is not what shipped, and the reason is worth a post.

The install that failed twice

First attempt was the CLI. The extension refused to install because it decided Auth was not provisioned. Auth was provisioned; the check reads a console-only flag and reports a false negative. My Claude Code agent correctly did not try to patch the local CLI to get past it, and went to the console instead.

Second attempt was the console. That failed with MANIFEST_EXPANSION_TOO_MUCH_CPU, an error I could not act on. And above the error, a banner: “Firebase Extensions will shut down on March 31, 2027.”

So the one-click path had failed to install and then told me that even if it worked, I would be rewriting it inside a year. For code that touches money, that is enough. Decision: write the integration ourselves.

What “ourselves” turned out to be

The core is three exports in functions/index.js, about 120 lines, next to the premium function that already existed. Shape only here, no secrets and no full dump.

createCheckoutSession is a callable. It takes the signed-in user, finds or creates a Stripe customer for that uid, and creates a Checkout session in subscription mode for the chosen price, with success and cancel URLs back to the site. The client redirects to whatever Stripe returns.

createPortalLink is a callable too. It creates a Billing Portal session for the user’s customer and returns the URL. Cancel, change card and see invoices all happen on Stripe’s page, not mine.

stripeWebhook is an HTTP function. It verifies the signature on the raw body with the webhook signing secret, then, for subscription events, retrieves the current subscription from Stripe and writes it to Firestore at customers/{uid}/subscriptions/{subscriptionId}. That document is the entitlement.

createCheckoutSession (onCall)  → checkout.sessions.create({mode:'subscription', customer, line_items:[{price}], ...})
createPortalLink      (onCall)  → billingPortal.sessions.create({customer, return_url})
stripeWebhook     (onRequest)   → constructEvent(rawBody, sig, secret) → subscriptions.retrieve(id) → customers/{uid}/subscriptions/{id}
premium           (onRequest)   → verifyIdToken → status ∈ {active, trialing} || users/{uid}.pass_status → JSON

The Stripe restricted key and the webhook signing secret live in Secret Manager and are bound to the functions as secrets, not environment variables in a file. Nothing sensitive is in the repo.

The entitlement check

The premium function verifies the caller’s Firebase ID token, then checks two places: any document under customers/{uid}/subscriptions with status active or trialing, or a manual grant on users/{uid} for the cases where I want to give someone access without a card. If either passes, it returns the premium JSON for the requested program, which is bundled from program_details at deploy time. If neither passes, 402. No token, 401.

The client never sees per-weight data in HTML. The program page ships locked; the script asks premium after the page has painted and swaps in the depth panel only on a 200.

The test flow

I ran this in Stripe test mode with a restricted test key. My part was about 30 minutes: create the test key, add the webhook endpoint with four subscription events, create the Recruit Pass product with the two prices, and do the purchase.

One test subscription created and canceled, four functions deployed.

Two problems the tests found

The first was a webhook race. Stripe sends subscription.created with status incomplete and subscription.updated with active close together, and they can arrive out of order. The first version wrote whatever the event carried, so an early incomplete could overwrite a later active. The fix is the one in the diagram: syncSubscription always retrieves the current subscription from Stripe before writing, and ignores the event payload beyond the id. Idempotent, and order no longer matters.

The second was hygiene. A key pasted during setup was a live key. The agent flagged it, I deleted it and issued a test key, and nothing live touched the test project. Worth saying because it is exactly the mistake a solo builder in a hurry makes.

Also, in zsh, UID is a reserved variable. A test script that set it broke silently. Renamed.

What to watch

The webhook signing secret is the one piece of this that rotates. When it does, the secret version in Secret Manager has to change and the function has to be redeployed to pick it up, or every webhook fails signature verification and subscriptions silently stop syncing. That is now a line on the launch checklist, next to swapping the test key for the live one.

The lesson

Prefer a hundred lines you own over a one-click extension for anything that touches money. The extension failed to install, then announced its own shutdown, and the replacement took about an hour of agent time plus my half hour on the Stripe side. I can read every line of it, the entitlement check is one function, and when Stripe changes something I change the code rather than wait on a package that has already told me its end date.

If you are on Firebase and considering the extension today, do the math on March 31, 2027 first.

build-in-publicstripefirebase

Two posts a month + the monthly recruiting-changes email — one list, unsubscribe anytime
I'm the:

Independent publication; not affiliated with the NCAA or any institution. Facts link to their sources; anything about a specific program is as of the date shown — send a correction.