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.
Top comments (4)
This is a perfect example of an event name promising more specificity than the event actually carries — 'updated' means 'something in this large object changed', and the subscriber inherits the job of diffing it. Checking previous_attributes is the right instinct, but the deeper lesson is to alert on the field you actually care about, compared explicitly, rather than on the presence of a neighbouring field that usually changes at the same time. Usually is where false alerts live.
Agreed, and the same thing shows up elsewhere in the payload: a paid invoice doesn't tell you on its own whether it was the first payment or a renewal, you have to read
billing_reason. "Compare the price ID" instead of "items changed" is exactly your point, alert on the value, not on the field showing up.previous_attributes tells you what changed but not why. Trial end, renewal and a real plan switch can all touch items, so the alert needs the status transition too, not just the diff.
Right, and that's the gap in our version. Comparing the price only answers "did the plan change". "Did the trial convert" is a different question, and the honest trigger for it is
statusgoing fromtrialingtoactivein the same update. We don't alert on that yet; it's probably the next one we add, as its own alert rather than folded into plan_change.