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)
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
-
nilis a valid value in a lot of Ruby code. Guards likereturn unless period_endturned a data problem into a silent no-op. - 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.
- 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
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.












