# Why did this account drop from pro to free?

> Find accounts that downgraded in the last 30 days and compare their behavior before vs after. The soft-churn signal nobody tracks.

## Agent adaptation contract

- Canonical human page: https://agentry.sh/workflows/tier-downgrade
- Execution mode: on_demand
- Immutable automation template: none
- Applies to: b2b-saas, b2c-saas, devtools-api
- Required example events: subscription_downgraded
- Required Agentry resources: none declared
- Do not use when:
  - Do not use until the example events are mapped to observed project signals, the current onboarding state is verified, and live event/property reads prove the required data is present.
  - Do not recommend this for products without a real revenue lifecycle or an equivalent paid-account signal.
- Ask before using:
  - Which observed events map to subscription_downgraded? Is the current onboarding state verified, and do live event/property reads show non-synthetic traffic for them?

This is an adaptable workflow example, not an API recipe. Map event and property names to the project's saved signal map, require status: "verified" from GET /v1/projects/:project_id/onboarding, and confirm the required signals through live event/property metadata and rows. Fetch current OpenAPI or query-blueprint details before making calls. Do not infer unattended authority from this page.

## Why this matters

Downgrade is the soft-churn signal nobody tracks. Cancellation gets a workflow (exit survey, save-offer email, CS call). Downgrade just happens silently in the Stripe dashboard — they stop paying you $300/mo and start paying you $0/mo, and you find out next quarter when MRR breaks down.

But downgrade is the highest-quality leading indicator there is. Saving a downgrade is 10x cheaper than winning back a full cancellation (they still have the account, the data, the muscle memory). The reason for the downgrade is usually small and addressable — they stopped using one specific feature, or their team shrunk and didn't need 10 seats.

Mixpanel and PostHog can do before/after cohort comparisons but you have to build them by hand each time. Agentry collapses it to one prompt: "show me last month's downgrades and what changed in their behavior."

## What you get

- A list of accounts that downgraded in the last 30 days, with `from_plan → to_plan`
- For each: top-3 events that **decreased the most** between the 30-day pre and post window
- A CS-ready summary: "Acme dropped Pro → Free; they stopped using the API integration 2 weeks before downgrade"

## Walk through it

**You:**
> List paid accounts that downgraded in the last 30 days. For each, compare their behavior in the 30 days before vs after the downgrade.

**Agent:**
> Let me first identify the downgraders.

```text
Endpoint: POST /v1/projects/:project_id/analytics/query
Exact shape: /v1/openapi.json?path=%2Fv1%2Fprojects%2F%7Bproject_id%7D%2Fanalytics%2Fquery&method=post
Custom HogQL goes in the OpenAPI-defined `query` field.
Concept fields:
  query: "SELECT distinct_id, properties.from_plan AS from_plan, properties.to_plan AS to_plan, timestamp AS event_ts FROM events WHERE event = 'plan_changed' AND properties.from_plan_rank > properties.to_plan_rank AND timestamp >= now() - INTERVAL 30 DAY ORDER BY event_ts DESC"
```
```text
{
  "rows": [
    { "distinct_id": "team-acme",     "from_plan": "pro",        "to_plan": "free",    "event_ts": 1747100000 },
    { "distinct_id": "team-globex",   "from_plan": "scale",      "to_plan": "pro",     "event_ts": 1746950000 },
    { "distinct_id": "team-megacorp", "from_plan": "enterprise", "to_plan": "scale",   "event_ts": 1746800000 },
    { "distinct_id": "team-tinyco",   "from_plan": "starter",    "to_plan": "free",    "event_ts": 1746700000 }
  ]
}
```

**Agent:**
> Four downgrades. Let me put them in a cohort and pull behavior before/after for the largest one (team-acme, Pro→Free, lost $300/mo).

```text
Endpoint: POST /v1/projects/:project_id/cohorts
Exact shape: /v1/openapi.json?path=%2Fv1%2Fprojects%2F%7Bproject_id%7D%2Fcohorts&method=post
Concept fields:
  name: "downgrades_last_30d",
  description: "Accounts that dropped to a lower tier in last 30d",
  filters: [{ event: "plan_changed", to_plan_rank_lt: "from_plan_rank", since: "30d" }]
```
```text
{ "cohort_id": "coh_dg_1", "member_count": 4 }
```

```text
Endpoint: GET /v1/projects/:project_id/users/:distinct_id/summary
Exact shape: /v1/openapi.json?path=%2Fv1%2Fprojects%2F%7Bproject_id%7D%2Fusers%2F%7Bdistinct_id%7D%2Fsummary&method=get
Use query parameters, not a JSON body.
Query/filter concept:
  windows: [
    { name: "pre_downgrade",  from: 1744508000, to: 1747100000 },
    { name: "post_downgrade", from: 1747100000, to: 1749692000 }
  ]
```
```text
{
  "windows": {
    "pre_downgrade": {
      "total_events": 1840,
      "by_event": {
        "api_call": 1240,
        "dashboard_view": 480,
        "report_exported": 88,
        "team_member_invited": 12,
        "integration_slack_used": 20
      }
    },
    "post_downgrade": {
      "total_events": 220,
      "by_event": {
        "api_call": 4,
        "dashboard_view": 200,
        "report_exported": 12,
        "team_member_invited": 0,
        "integration_slack_used": 4
      }
    }
  }
}
```

**Agent:**
> Acme's downgrade tells a clear story:
>
> - **API calls collapsed from 1,240 → 4** (-99%). They were Pro because they needed the API. They stopped using it 30 days before the downgrade.
> - **Dashboard views barely changed** (480 → 200, which is half because the time window is 30 days vs 30 days but they're now visiting less). They're still around.
> - **Team invites: 12 → 0.** Team shrunk.
>
> Likely reason: an engineering project ended, they don't need the API right now, they want to keep their data so they downgraded instead of canceling. **This is a winback candidate, not a churn candidate.** A "want to come back to Pro for $99 first month?" email would probably land.
>
> Want me to do the same analysis for the other three?

## The output

```
Downgrades (last 30d) — pre vs post behavior analysis

team-acme         Pro → Free       ($300/mo lost)
  ↓ api_call:               1240 → 4    (-99.7%)  ★ THE STORY
  ↓ team_member_invited:      12 → 0    (-100%)
  ↓ dashboard_view:          480 → 200  (still showing up)
  ↓ report_exported:          88 → 12
  Diagnosis: API project ended. Saving the seat = saving Pro.
  Recommended outreach: "API back for $99 first month?" save offer

team-globex       Scale → Pro      ($1,800/mo retained, $1,200/mo lost)
  ↓ workflow_executed:    14,200 → 4,100  (-71%)
  Diagnosis: workflow volume cut. They downsized correctly.
  Action: NO outreach — they're happy at Pro.

team-megacorp     Enterprise → Scale  ($27k/mo lost)  ★ URGENT
  ↓ admin_action:        220 → 12   (-95%)
  ↓ team_member_invited:  24 → 0
  Diagnosis: champion left? Admin activity collapsed.
  Recommended outreach: CSM calls TODAY, find the new champion.

team-tinyco       Starter → Free   ($60/mo lost)
  ↓ all events:           220 → 18   (-92%)
  Diagnosis: Dormant. Probably hobby project ended.
  Action: NO outreach (low ROI), let them stay on free.
```

## Setting it up

The workflow needs one event class — `plan_changed` — fired from your Stripe webhook handler whenever a subscription changes tier. Include both `from_plan` and `to_plan`, and a numeric `plan_rank` so the agent can detect "down" without hard-coding tier names:

```ts
// In your Stripe webhook handler for `customer.subscription.updated`
const PLAN_RANK: Record<string, number> = {
  free: 0,
  starter: 1,
  pro: 2,
  scale: 3,
  enterprise: 4,
};

if (oldPriceId !== newPriceId) {
  const from_plan = planNameFromPrice(oldPriceId);
  const to_plan = planNameFromPrice(newPriceId);

  await fetch(`https://api.agentry.sh/v1/analytics/`, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.AGENTRY_SERVER_API_KEY}`,
      "Content-Type": "application/json",
      "User-Agent": "myapp-stripe/1.0",  // REQUIRED — Cloudflare 403s default UAs
    },
    body: JSON.stringify({
      event: "plan_changed",
      distinct_id: subscription.metadata.account_id,
      properties: {
        from_plan,
        to_plan,
        from_plan_rank: PLAN_RANK[from_plan],
        to_plan_rank: PLAN_RANK[to_plan],
        mrr_delta: (newPriceCents - oldPriceCents) / 100,
      },
    }),
  });
}
```

For the before/after windows to work, you need at least 30 days of event history for each downgraded account. If you only started instrumenting last week, this workflow gets useful in ~3-4 weeks.

## Variations

- *"For each downgrade, also show the SUPPORT tickets they opened in the 30 days before. Was there a known frustration?"*
- *"Of the accounts that downgraded last quarter, how many fully canceled within 90 days? That's our 'downgrade → churn' conversion rate."*
- *"Build the inverse — accounts that UPGRADED. What behaviors increased?"* (see `expansion-prediction`)
- *"Review the previous week's downgrades on demand and draft a diagnosis for a CS lead to share if appropriate."*
