i’m 12 and building an ai mentor on edge compute, so i’m not processing saas billing yet. but reading "no window in which the event is marked done but the business write is missing" just clicked something massive in my brain. 🤯
i use supabase + cloudflare workers for koda's backend. when i eventually add subscriptions, i was planning to just use supabase edge functions to catch the webhook and update the user row. but your point about the "check then process" race condition means i need to wrap the INSERT INTO processed_events and the UPDATE profiles in a single supabase rpc transaction with a unique constraint on the stripe event_id.
no background queue needed, just pure db-level atomicity on the edge.
thanks for the masterclass, benjamin. saving this for when koda gets its first paying user. 🐯
Senior dev, 25 years in. Fractional CTO and hands-on builder. Go, Vue, TS. I run tech projects but never leave the terminal. Health, edu, ecology. Bordeaux, 100% remote.
That's exactly the right takeaway, Harun. One RPC that inserts the event ID (with a unique constraint) and updates the profile in the same transaction means a retry either does nothing or does everything. One more tip for later: in that function, re-fetch the subscription from Stripe by ID instead of trusting the payload. Good luck with Koda, and congrats on that first paying user when it comes. 🐯
re-fetching the subscription state directly from stripe by id, rather than trusting the webhook payload blindly, completely eliminates the risk of payload tampering or replay attacks. it’s a small extra rpc call that buys massive peace of mind.
adding that exact pattern to the koda roadmap immediately. thanks for the validation and the warm wishes! 🐯💳
Cloud security practitioner. I write about building infrastructure that doesn't keep you up at night-microVMs, zero-trust networking, and the small architectural choices that matter more than firewall
"No window in which the event is marked done but the business write is missing" is the clearest statement of that pattern I've seen.
Two additions. The tolerance window and the dedupe table defend different threats , replay attacks versus the provider's own retries , and neither substitutes for the other. And ordering is the gap this doesn't close: Stripe doesn't guarantee delivery order, so if your logic branches on the transition rather than the state, you need to compare versions and discard the stale one.
processed_events also needs retention that exceeds the provider's maximum retry horizon.
Senior dev, 25 years in. Fractional CTO and hands-on builder. Go, Vue, TS. I run tech projects but never leave the terminal. Health, edu, ecology. Bordeaux, 100% remote.
Both good points. Agreed that signature tolerance and dedupe solve different problems. On ordering: that's one reason I re-fetch the object by ID. If the handler reacts to the current state rather than to the transition, an out-of-order event just reads the latest state and becomes a no-op. When you really do need to act on the transition, comparing versions as you describe is the way.
The re-fetch-by-ID rule is the one I would underline — I'm Chris, founder of SignalEDI, and trusting the payload is how a replayed EDI shipment notice resurrects a cancelled order days later, so we treat the inbound document as a pointer and re-read the authoritative order state before writing. Pairing the primary-key dedup in the same transaction with keeping processed events past the provider's retry window also closes the crash-between-steps window we have been bitten by. How long do you actually retain processed events before archiving, given dispute events can arrive months after checkout?
Senior dev, 25 years in. Fractional CTO and hands-on builder. Go, Vue, TS. I run tech projects but never leave the terminal. Health, edu, ecology. Bordeaux, 100% remote.
Good question. Disputes arrive as new events with their own IDs, so the dedupe table only has to outlive redeliveries of the same event. Stripe retries for up to 3 days and only keeps events retrievable for 30 days, so anything older can't come back. I keep [N days / everything] in processed_events [and archive after that]. Long-lived disputes are handled by re-fetching the charge, not by the dedupe table.
We're a place where coders share, stay up-to-date and grow their careers.
i’m 12 and building an ai mentor on edge compute, so i’m not processing saas billing yet. but reading "no window in which the event is marked done but the business write is missing" just clicked something massive in my brain. 🤯
i use supabase + cloudflare workers for koda's backend. when i eventually add subscriptions, i was planning to just use supabase edge functions to catch the webhook and update the user row. but your point about the "check then process" race condition means i need to wrap the
INSERT INTO processed_eventsand theUPDATE profilesin a single supabase rpc transaction with a unique constraint on the stripe event_id.no background queue needed, just pure db-level atomicity on the edge.
thanks for the masterclass, benjamin. saving this for when koda gets its first paying user. 🐯
That's exactly the right takeaway, Harun. One RPC that inserts the event ID (with a unique constraint) and updates the profile in the same transaction means a retry either does nothing or does everything. One more tip for later: in that function, re-fetch the subscription from Stripe by ID instead of trusting the payload. Good luck with Koda, and congrats on that first paying user when it comes. 🐯
benjamin, that is a pro-tier security tip.
re-fetching the subscription state directly from stripe by id, rather than trusting the webhook payload blindly, completely eliminates the risk of payload tampering or replay attacks. it’s a small extra rpc call that buys massive peace of mind.
adding that exact pattern to the koda roadmap immediately. thanks for the validation and the warm wishes! 🐯💳
"No window in which the event is marked done but the business write is missing" is the clearest statement of that pattern I've seen.
Two additions. The tolerance window and the dedupe table defend different threats , replay attacks versus the provider's own retries , and neither substitutes for the other. And ordering is the gap this doesn't close: Stripe doesn't guarantee delivery order, so if your logic branches on the transition rather than the state, you need to compare versions and discard the stale one.
processed_events also needs retention that exceeds the provider's maximum retry horizon.
Both good points. Agreed that signature tolerance and dedupe solve different problems. On ordering: that's one reason I re-fetch the object by ID. If the handler reacts to the current state rather than to the transition, an out-of-order event just reads the latest state and becomes a no-op. When you really do need to act on the transition, comparing versions as you describe is the way.
The re-fetch-by-ID rule is the one I would underline — I'm Chris, founder of SignalEDI, and trusting the payload is how a replayed EDI shipment notice resurrects a cancelled order days later, so we treat the inbound document as a pointer and re-read the authoritative order state before writing. Pairing the primary-key dedup in the same transaction with keeping processed events past the provider's retry window also closes the crash-between-steps window we have been bitten by. How long do you actually retain processed events before archiving, given dispute events can arrive months after checkout?
Good question. Disputes arrive as new events with their own IDs, so the dedupe table only has to outlive redeliveries of the same event. Stripe retries for up to 3 days and only keeps events retrievable for 30 days, so anything older can't come back. I keep [N days / everything] in processed_events [and archive after that]. Long-lived disputes are handled by re-fetching the charge, not by the dedupe table.