DEV Community

Mueez Afzal
Mueez Afzal

Posted on AI-assisted

A Stripe API upgrade broke our webhooks, and nothing errored

We run subscription billing for a multi-tenant Rails SaaS: 2,000+ businesses, about $1M a year in subscription volume, plus Stripe Connect payouts on top. After we moved to a newer Stripe API version, the scariest kind of bug showed up: nothing broke loudly.

No exceptions. No failed jobs. Charges just stopped reconciling.

What changed

Newer Stripe API versions moved current_period_start and current_period_end off the Subscription object and onto each Subscription Item. Our webhook handlers, renewal logic and access checks all read the period from the subscription, like this:

period_end = Time.at(subscription.current_period_end)
Enter fullscreen mode Exit fullscreen mode

On the new API version that field is gone, so the code kept running with nil instead. Ruby didn't complain, Stripe didn't complain, and our reconciliation quietly skipped work.

Why we didn't notice

  1. nil is a valid value in a lot of Ruby code. Guards like return unless period_end turned a data problem into a silent no-op.
  2. Four different places read the billing period, each in its own way. We had no single place to fix it and no single place to alert on it.
  3. Webhooks fail politely. A handler that returns 200 after doing nothing looks exactly like a handler that worked.

The fix

1. One function for the billing period. Every read now goes through one function that prefers the item-level period, falls back to the legacy subscription fields for old payloads, and raises if neither exists:

window = StripeBillingPeriod.for(subscription)
window.starts_at  # Time
window.ends_at    # Time
window.source     # :item or :subscription
Enter fullscreen mode Exit fullscreen mode

I put a cleaned-up version of this pattern into a tiny open-source gem: stripe_billing_period.

2. Fail loudly on money paths. A missing period should be an exception, not a nil that slips through.

The result

Payment failures are now under 1% of charge volume, and there's exactly one place to change the next time Stripe moves a field.

What I'd tell anyone upgrading their Stripe API version

  • Grep for every read of current_period_*, plan, and other fields Stripe has moved. If it's read in more than one place, put it behind one function first.
  • Replay real webhook payloads against the new version in staging before switching.
  • Keep fixtures for both the old and new payload shapes of every event you handle.
  • Reconcile on a schedule instead of trusting webhooks: compare what Stripe says with what you stored.
  • Treat "handler returned 200 but changed nothing" as something you watch.

I'm a Rails engineer who mostly works on billing and payouts: Stripe Billing, Connect, and the webhooks in between. Happy to compare notes.

Top comments (0)