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?.something used 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, status for activations, cancel_at_period_end for 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_attributes after 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.