We run a small social media scheduler, and the events we care about send us a message in Telegram: a new sign-up, a new subscription, a plan change, a billing error. It's a cheap way to know what's happening without opening a dashboard.
Last Friday afternoon one of those messages said:
Plan changed: Creator, monthly
Nobody had changed anything. The subscription was on the same plan, at the same price, as the day before. What had actually happened was much more boring: a 7-day trial had ended and the first paid month had started.
The alert was wrong, and the reason is a detail of Stripe webhooks that is easy to miss.
What the alert code did
Plan changes come in as customer.subscription.updated. That event fires for a lot of reasons (status changes, renewals, metadata edits), so we only wanted to alert when the plan itself changed. Stripe sends previous_attributes with every update: the fields that changed, with their old values. So the check looked like this:
const previousAttributes = ( event.data as { previous_attributes?: Partial<Stripe.Subscription> } ).previous_attributes; // "items changed" = "the customer switched plans" if (previousAttributes?.items) { notifyOpsSubscription({ kind: "plan_change", /* ... */ }); } The reasoning: the plan lives on the subscription item's price, so if items is in previous_attributes, the plan must have changed.
What Stripe actually sent
When the trial ended, the update looked roughly like this (trimmed):
{ "type": "customer.subscription.updated", "data": { "object": { "status": "active", "items": { "data": [{ "price": { "id": "price_creator_monthly" } }] } }, "previous_attributes": { "status": "trialing", "items": { "data": [{ "price": { "id": "price_creator_monthly" } }] } } } } items is there, but the price inside it is the same. In recent Stripe API versions the billing period (current_period_start and current_period_end) lives on the subscription item, not on the subscription. When a trial ends, the period moves from the trial week to the first paid month. The item changed, so items shows up in previous_attributes, even though nobody touched the plan.
previous_attributes tells you which top-level field changed. It doesn't tell you what changed inside it.
The fix
Compare the value you care about instead of checking that the field exists:
const previousPriceId = previousAttributes?.items?.data?.[0]?.price?.id; // Alert only when the price itself changed (an upgrade or downgrade). if (previousPriceId && previousPriceId !== priceId) { notifyOpsSubscription({ kind: "plan_change", /* ... */ }); } Now a trial ending, a renewal or a period change is just an update. A real switch from one price to another still alerts. Billing itself was never affected: the plan we store comes from the current price on every update, so only the notification was wrong.
What I'd check in your own webhook
- Search for
previous_attributes?.somethingused as a boolean. Each one is "this field changed somehow", which is rarely the question you're asking. - Diff the exact value you care about: the price ID for plan changes,
statusfor activations,cancel_at_period_endfor cancellations. - Test the trial end, not just the checkout. Most of us test "subscribe" and "cancel" by hand and never sit through a trial. Stripe's test clocks let you move a test subscription past its trial end in a few seconds.
- Expect nested objects to change for reasons that aren't yours. Stripe moves fields between versions, and anything nested (items, discounts, invoice settings) can appear in
previous_attributesafter an API upgrade.
The alert that caught this was a false alarm, but it did its job: it made us look. If it had been an email to the customer saying "your plan changed", it would have been a support ticket instead.
We build Fuxux, a social media scheduler for creators. This bug came out of our own billing alerts last week.