DEV Community

Alex
Alex

Posted on AI-assisted

Stripe told us a plan changed. It hadn't.

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", /* ... */ });
}
Enter fullscreen mode Exit fullscreen mode

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" } }] }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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", /* ... */ });
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (4)

Collapse
 
challan116ux profile image
challan116-ux •

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.

Collapse
 
fuxuxscheduler profile image
Alex •

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.

Collapse
 
abhinavs1920 profile image
Abhinav Sharma •

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.

Collapse
 
fuxuxscheduler profile image
Alex •

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 status going from trialing to active in 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.