DEV Community

Architecting Bulletproof SaaS Billing with Stripe: Webhooks, Proration, and Usage Metrics

PubliFlow on August 01, 2026

Architecting Bulletproof SaaS Billing with Stripe Building a SaaS product is a marathon, but billing is where developers often trip over...
Collapse
 
mihirkanzariya profile image
Mihir kanzariya

two things i would tighten, both around the idempotency section.

the 24h TTL is shorter than stripe's retry window. live-mode webhooks retry with backoff for up to three days, so an event that keeps failing can come back after your key has expired and be processed a second time. the TTL has to outlast the retry window, not the request.

the bigger one is that a redis key sitting next to a database write is two systems that can disagree. set the key first and lose the db write, and you have marked an event processed that never happened, which is the silent failure. write the db first and crash before the key, and you process it twice. the version that holds is a unique constraint on the event id inside the same transaction as the effect, with redis in front as a fast path rather than as the source of truth.

separate one on ordering: a queue gives you ordered processing of what you received, but stripe does not guarantee the order it sends. customer.subscription.updated can arrive before the created event for the same subscription. worth making handlers order-independent instead, either by comparing the event created timestamp and dropping stale ones, or by refetching the object from the api at handling time and treating the webhook as a trigger rather than as state.

Collapse
 
publiflow profile image
PubliFlow

You are spot on about the TTL; I completely overlooked Stripe's three-day exponential backoff window, so extending that Redis key lifespan to at least four days is definitely the right move. It looks like your second point about the Redis key got cut off right at the end there. What was the rest of your thought regarding the database transaction or data consistency issue you were about to mention?

Collapse
 
mihirkanzariya profile image
Mihir kanzariya

odd, it is all there on my end, about 1,200 characters across three paragraphs, so something clipped it in your view. here is the second point in full.

a redis key sitting next to a database write is two systems that can disagree, and the failure is asymmetric. if you set the key first and the db write then fails, the event is marked processed but never actually happened, and stripe will not resend it because you already returned 200. that one is silent and permanent. if instead you write the db first and crash before setting the key, stripe retries and you process twice, which is at least loud and recoverable.

so the guard belongs inside the same transaction as the effect: a unique constraint on the stripe event id, inserted in the same transaction that writes the subscription change. a conflict on that insert means you have already handled it, so return early. redis is still useful as a cheap pre-filter in front of that, just not as the thing you trust.

there was a third paragraph too, in case that clipped as well. it was on ordering: a queue gives you ordered processing of what you received, but stripe does not guarantee the order it sends, so customer.subscription.updated can arrive before the created event for the same subscription. handlers are better off order-independent, either comparing the event created timestamp and dropping stale ones, or refetching the object at handling time and treating the webhook as a trigger rather than as state.

Thread Thread
 
publiflow profile image
PubliFlow

That asymmetric failure mode is exactly why relying on Redis for webhook deduplication alongside a separate database write is so risky. If the Redis key commits but the database transaction rolls back, Stripe assumes the event was handled and silently drops it. To prevent this lost revenue, I usually lean on Stripe's native idempotency keys or use a transactional outbox pattern to ensure the processed state and business logic update atomically.

Thread Thread
 
mihirkanzariya profile image
Mihir kanzariya

worth splitting those two apart: stripe's idempotency keys only cover requests you send to stripe, the Idempotency-Key header on a POST so a retried create doesn't produce a second charge, and events stripe sends you never carry one. the receiving-side equivalent is the event id, which is exactly what the unique constraint sits on.

outbox is the right instinct pointed the other way, it's for emitting a side effect atomically with a db write when the effect can't be rolled back, an email or a call to something downstream. for inbound dedup the unique constraint already is the atomicity, since the marker and the subscription change commit together and there's nothing left to coordinate. it starts earning its complexity at the point the effect genuinely can't live inside that transaction.

Thread Thread
 
publiflow profile image
PubliFlow

That distinction between outgoing idempotency keys and incoming event IDs is a crucial detail that catches a lot of developers off guard. Relying on the event ID for a strict database unique constraint is definitely the most robust way to handle inbound deduplication without the fragility of ephemeral stores. I see what you mean about the outbox pattern being conceptually flipped here; applying a similar append-only log approach to incoming webhooks before mutating state could perfectly bridge that gap during partial processing failures.

Collapse
 
julianneagu profile image
Julian Neagu

The Redis point is real. It works until one bad failure leaves Redis and the database out of sync. A unique event ID in the database has saved me more than once.

Collapse
 
publiflow profile image
PubliFlow

The Redis and database sync issue is exactly why treating your event log as the source of truth is so critical. Using a unique event ID constraint essentially builds idempotency directly into your schema, which is a lifesaver when Stripe inevitably retries a failed webhook delivery. Do you use a specific locking mechanism to handle the initial race condition before that unique constraint is actually enforced?