2
2 Comments

My billing worked. My expiry dates didn't. Here's why.

I paid $2.99 to my own product this week. That's how I found out my subscriptions had no expiry date.

Quick context: my extension has a Pro tier. No login servers, no license keys — you pay through Stripe, and a tiny Cloudflare Worker listens for Stripe's webhook and writes a little record: this email is Pro, and here's when the subscription renews. That renewal date is the whole point — it's how I know when to stop giving someone Pro if they cancel or their card fails.

So I did the thing every indie dev should do more often: I became my own customer. Test card, real checkout flow, $2.99. Payment went through. Then I opened the database to admire my handiwork.

The record was there. Email, active: true, the Stripe customer id. But the renewal date — the one field the whole system exists to track — was just… missing. Not wrong. Missing. The field wasn't even there.

Here's the part that made it confusing: nothing had failed. Stripe's dashboard showed every webhook delivered, every one a green 200 OK. My worker said "yep, got it, all good" to everything. And still, no date.

I went to Stripe's event log and lined the events up by timestamp. When you pay, Stripe doesn't send one event — it sends a burst. checkout.session.completed, invoice.paid, subscription.created, a dozen others, all within the same second or two. And that's when I saw it: invoice.paid was delivered at 2:55:57. checkout.session.completed came at 2:55:58. One second later.

That one second was the whole bug.

My code had an unspoken assumption baked into it: that "checkout completed" always arrives first. That event is where I build the lookup table connecting a Stripe customer to their email. Every other event — including the one carrying the renewal date — uses that table to figure out whose record to update. So when invoice.paid showed up first, it went looking for an email that didn't exist yet, found nothing, and quietly moved on. No error. Just a shrug. The date it was carrying got dropped on the floor, and checkout.session.completed arrived a second later to build a record that would now never learn its own expiry date.

Webhooks don't promise order. I knew that in the abstract, the way you know a tornado is theoretically possible. I just never built for it, because in every test I'd ever run, the events happened to arrive in the tidy order I expected. It took a real payment, with real network timing, to shuffle the deck the other way.

The fix wasn't dramatic. Now, when an event arrives and can't find its email yet, instead of dropping the data it stashes it in a "pending" slot keyed by customer id. When checkout.session.completed finally lands, it merges whatever was waiting. Order stops mattering — whoever gets there first leaves a note for the others. I also found a second landmine while I was in there: Stripe had quietly moved the renewal-date field to a new location in a recent API version, so even the events I was handling were reading an empty spot. Fixed that too.

Bought Pro again after deploying. Opened the database. There it was — renewal date, plan, everything. A month out, exactly right.

Two things I'm taking from this. One: buy your own product. Not a mock, not a test harness — the actual flow with actual money moving. Half the bugs that matter only show up when the timing is real. Two: any time your code assumes A happens before B, and you don't own the thing deciding the order, you don't have a guarantee — you have a coin flip that's been landing heads in testing.

Anyone else have a bug that only appeared the first time real money went through? I'd bet those are a special category.

— building NotebookBloom in public, #13

on August 3, 2026
  1. 1

    Mine was a wrong Stripe price ID at checkout. Not a crash, not a 500 — the session just built against a price that didn't correspond to anything I was actually selling, and the flow looked completely normal right up until it wasn't. I only found it because I did what you did and paid my own product with real money instead of trusting the test-mode run.

    What makes these expensive is that the failure mode is silence. Yours returned 200s all the way down. Mine returned a working-looking checkout page. Neither system had any way to say "the thing I exist to do did not happen." I've stopped treating a green webhook log as evidence of anything except that a request arrived — the only check that counts is reading the record afterward and asserting the field you care about is populated.

    Your pending-slot fix is the right shape and I'd push it one step further: make it complain if pending data is still sitting unclaimed after some window. Right now an out-of-order event gets rescued, but a genuinely orphaned one goes back to being invisible. A cheap cron flagging active subscriptions with no renewal date would have caught the original bug in a day instead of a payment.

    The API-version drift you found second is the one that comes back, by the way. That one moves without you touching anything.

    1. 1

      the wrong-price-ID one is nasty precisely because everything downstream is honest — Stripe did build a real session, it just built the wrong real thing. and yeah, "a working-looking checkout page" is the same disease as my 200s: the system had no vocabulary for "the one job didn't happen."

      your "green log only proves a request arrived" line is going in my notes verbatim. that was the exact trap — every webhook was a green 200 and i took that as done.

      on the pending slot, funny enough your push-one-step-further nudged me somewhere i actually already went, just from the other side. i ended up ripping the merge-pending machinery out entirely. instead of trying to assemble the truth from a burst of webhooks, i reconcile at read time: when /status runs i pull the customer's latest subscription straight from Stripe and write the whole record back. webhooks now do basically nothing except keep a customer-id-to-email map warm. that sidesteps the orphan problem you describe — there's no half-built record waiting to be claimed, because every read rebuilds the full thing from the source of truth. i did add a syncedAt timestamp for exactly your reason: so i can tell "this record was actually reconciled" apart from "this record merely exists."

      your cron idea is still the right instinct though, and i'd frame it as: assert the invariant, don't trust the pipeline. "active subscription with no renewal date" is an impossible state, so something should scream when it exists. i get a weaker version of that for free now because a bad record self-heals on the next read, but a proactive check would catch it before a user does.

      and you're dead right that the API-version drift is the one that comes back. mine already did — a period-end cancel stopped flipping cancel_at_period_end and started setting cancel_at, and current_period_end moved down into items.data[0], all without me changing a line. that's the one i can't "fix" so much as stay paranoid about. great comment — thank you.