Dev.to WebDev 🛠 Dev 👁 0 📖 2 min read

stripe told me my webhook was broken. i found the email 17 days later.

this one's a bit embarrassing. Akiko has a premium tier that runs through Stripe, and for about ten weeks every webhook Stripe sent her dashboard failed. i had no idea. i only found out because of an email from Stripe t

this one's a bit embarrassing. Akiko has a premium tier that runs through Stripe, and for about ten weeks every webhook Stripe sent her dashboard failed. i had no idea.

i only found out because of an email from Stripe that had been sitting unread for seventeen days.

what happened

i retired the old Express backend on the dashboard and moved everything into Next.js. the Stripe keys lived in the root .env. Next runs from the web/ folder under pm2, so it reads web/.env instead, and that file had no Stripe variables at all.

so every delivery hit the webhook route, found no secret, and threw. the catch block turned any error with "Stripe" in the message into a 400. a missing config looked exactly like a bad signature, to Stripe and to me poking at it with curl.

roughly: last event stored June 3. Express retired June 11. first failed delivery July 22. Stripe disabled the endpoint on July 31 after 10 attempts over 9 days. i found it August 16.

there was a second bug in the same file. six variables were written NAME = value with spaces around the equals, which dotenv reads as a variable called "NAME " with a trailing space. even with the right file loaded they'd have come back undefined.

then it got worse

while i was checking what the dead webhook had broken, i looked at how premium ends. it ends one way. a customer.subscription.deleted event flips active to false, and that's the only code in the repo that does. no sweep, nothing else.

so with the webhook dead, nobody's premium could end. it would just run forever.

the data to catch this was right there. expires_at was written in four places and read in none. there was even an index built for a query nobody had written.

the two date columns also disagreed. expires_at and current_period_end are supposed to mirror each other, but old rows never got mirrored. two lapsed subscriptions had a past expires_at and a NULL current_period_end. the AI limits code checked current_period_end, saw NULL, took that to mean a lifetime grant, and gave premium quotas to a subscription that ended in May.

the fix

both gates now deny if either date is in the past. NULL in both still means lifetime, because comps and staff grants are made outside Stripe with no expiry and i didn't want to cut those off. an unparseable date fails open, since a malformed value is my bug and i'd rather not lock out someone who's paying.

i also added a daily sweep that clears active on anything whose own dates have passed. i ran it against a clone of the real table first. it revoked the two lapsed rows, left the lifetime grants alone, and the second run touched nothing.

if you give people access because of an event, also give them an end date the gate actually reads. otherwise one missed webhook turns into free forever.

go read your Stripe emails (i didn't :3)

Akiko's free to add if you want to watch what i break next: https://i.hep.gg/akiko

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.