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
Top comments (6)
The thing I'd poke at is the stored renewal date doing double duty as the entitlement rule. A failed renewal doesn't cancel anything, it goes past_due and Stripe retries for about two weeks, so your stored date is already in the past while the customer is still in good standing and most of them recover. The inverse bites the same way: if nothing writes on cancellation, that date quietly keeps someone Pro. Letting status be what entitlement reads and the date be display-only means Stripe pushes you the change when it happens instead of you predicting the future at checkout time. It also gives you less surface to break across API versions, since you're depending on one coarse value rather than on where a particular timestamp lives.
this is a great poke, and honestly you've more or less described where i ended up after that post. the pending-merge fix in the article stopped the field from going missing, but you're pointing at the deeper design smell: the date was never supposed to be the entitlement rule in the first place.
i ripped that out. now the date isn't authoritative at all — on read i just ask Stripe for the customer's latest subscription and let its status decide Pro-or-not. the stored date is display-only, exactly like you said. checkout stops being me trying to predict the future; Stripe just tells me the truth whenever i ask. the past_due case you mention is the cleanest argument for it — a card that's mid-retry still reads active, and i'm not accidentally cutting off someone who's going to recover in a day.
the "less surface across API versions" point landed hard, because i got bitten by exactly that. in a recent version Stripe stopped flipping cancel_at_period_end for a period-end cancel and started setting cancel_at instead, AND moved current_period_end down into items.data[0]. two timestamps that quietly changed location. leaning on the coarse status instead of chasing where a specific field lives is the lesson i learned the expensive way. reading it back to me this cleanly is the version i wish i'd had before the refactor. thanks for this.
one nit that matters: a card mid-retry doesn't read
active, it readspast_due. so if the new check ends up asstatus === "active"you've quietly rebuilt the exact cutoff you just removed. i'd make entitlement a set instead:active,trialing,past_dueget in,incomplete,incomplete_expired,unpaid,canceleddon't. and thepast_dueone is a product call more than a code one, you're serving someone whose money hasn't landed yet, and your own retry settings decide how long that grace lasts before it terminates.ok, you got me — and you got me on my own comment, not the article this time. "a card mid-retry still reads active" is just wrong. it reads past_due. so i went and read the actual function, and yeah — my check was literally sub.status === "active" || "trialing". i'd quietly rebuilt the exact cutoff i bragged about deleting, just one level down. that's what i get for narrating a fix i hadn't re-read.
so i already changed it — the set framing was obviously right. it's now ["active", "trialing", "past_due"].includes(status); incomplete / incomplete_expired / unpaid / canceled fall out on their own. one coarse boolean was never going to carry that many states without lying about at least one of them.
but the part that actually stopped me is your last line — past_due being a product call, not a code one. you're right that my retry/dunning settings have been silently defining the grace window this whole time. i've basically let Stripe's default retry schedule make a customer-facing decision i never consciously made. for a $2.99 tool i'd rather err generous — keep serving someone whose card is just being slow, not punish them for their bank — but that should be a number i picked on purpose, not a default i inherited without noticing.
this is the second time you've pushed the design somewhere better than where i left it. genuinely, thank you.
one thing to check before you tune that grace window: in your stripe subscription settings, what happens when the retries run out. if it's set to leave the sub past_due, that's a terminal resting state, not a temporary one, and past_due sitting inside your entitlement set means a dead card keeps access forever. if it lands on unpaid or canceled, your set already excludes both and you're fine.
This is the one that actually made me go open the Stripe dashboard mid-read, because you just named a dependency my code has that isn't in my code at all. The set is only correct if past_due is transient. The moment it's a terminal resting state, past_due stops meaning "recovering" and starts meaning "dead card, still holding the door open" — and nothing in my worker would ever catch that, because from the code's side the status string never changes. Free Pro forever, and my logs would look totally healthy.
So I went and checked. My retry setting was on Smart Retries, and the "after all retries fail" action was already set to cancel the subscription — so it lands on canceled, which my set excludes, so I'm fine today. But "fine today" is doing a lot of work in that sentence, because I had no idea that dropdown was load-bearing until you said it. If a past-me had picked "leave it past_due" thinking that was the softer, more customer-friendly option, I'd have shipped a permanent free tier by accident and never known.
The thing that gets me is where the bug would have lived. It's not in the entitlement function — that code is correct. It's in a dropdown in a settings page in a different company's dashboard, and it silently changes what my correct code means. That's a genuinely nasty class of coupling and I don't have a great answer for it beyond "know it's there." And the Test/Live split makes it worse — that's two separate dashboards, so I have to go confirm the Live one says cancel too before launch, or I ship a setting I verified in a sandbox that never applied to a real customer. Adding that to the launch checklist right now. This thread has basically been a free code review from someone who's clearly stepped on all of these — thank you, genuinely.