Why PostHog Revenue Analytics Says a Canceled Stripe Customer Is Still Active
Because PostHog never asked Stripe again. The Stripe subscription sync that feeds Revenue Analytics is append-only. It fetches rows by their created timestamp, and once a subscription exists in PostHog's copy, it doesn't go back and re-check that row for changes. When a customer cancels, Stripe populates ended_at on the subscription object, but PostHog's stored copy still has ended_at as null, because nothing told the sync to look at that subscription again. The customer reads as active in Revenue Analytics until something forces a re-fetch, which by default is nothing.
No, you can't trust the churn or LTV numbers until you've either applied the workaround below or PostHog ships a real fix. This is a confirmed, currently open bug, tracked at PostHog/posthog#58148. It's also specific. MRR and gross revenue are unaffected, because those are driven by invoices, and a canceled subscription stops generating them without needing to be re-synced. Active subscription counts, churn counts, and LTV are the numbers that go wrong, because all three depend on reading ended_at off a row that stopped updating the day it was first synced.
What's actually broken, mechanically
The GitHub issue lays out the chain precisely, and it's worth following because it explains why the bug is invisible until you go looking. PostHog's Stripe data warehouse source syncs the Subscription schema with supports_incremental=False, supports_append=True, meaning each sync run asks Stripe for subscriptions with a created value newer than the last run and appends them. It never issues a request for "give me updates to subscriptions I already have." Revenue Analytics then reads ended_at straight off that stored row to decide whether a subscription counts as active for a given period, and the query logic treats a null ended_at as still active. Once a subscription is synced once, cancels, and never gets re-fetched, that's permanent. Subscription and customer counts inflate as canceled accounts pile up, the churn check never fires because it depends on ended_at landing inside the period being measured, and the LTV chart, which is calculated from the churned-customer count, ends up empty.
This lines up with how Stripe's own Subscription object is documented. created is a timestamp set once, and ended_at is "if the subscription has ended, the date the subscription ended," populated only at cancellation. A sync that only reads new created values by design will never see a change to a field that only changes after creation.
How to check it's this and not something else
Before assuming your churn number is wrong for this exact reason, confirm it against Stripe directly:
- Open a customer in your Stripe Dashboard who you know canceled, and note the subscription's status and
ended_atdate. - In PostHog, look up the same customer in Revenue Analytics or query the underlying warehouse table for that subscription's
ended_at. - If Stripe shows canceled with a real
ended_atand PostHog's copy still shows the subscription active orended_atas null, you've hit this bug, not a data-mapping issue or a webhook you forgot to enable.
Kettleworth's fourteen phantom "active" accounts
Recording studios and rehearsal spaces book their time on Kettleworth, billed monthly through Stripe. Growth there belongs to Anezka Brannigan, and every month she pulls the Revenue Analytics churn report to build a win-back list of studios who canceled in the last 90 days and might respond to a discount offer.
In August, the report showed two cancellations. Support had closed five cancellation tickets the previous week alone, so Anezka checked the Stripe Dashboard directly. Eleven studios that support had already confirmed as canceled over the past two months were still showing as active subscriptions in PostHog.
Anezka, in the team's #growth channel: "Support closed five cancellation tickets last week. The churn report says two. Somebody explain that gap to me."
Priyam, on the engineering side, pulled up the warehouse table for one of the eleven and found Stripe's ended_at set three weeks earlier while PostHog's copy still read null. The pattern held for all eleven, and the count kept growing the longer anyone looked. Fourteen studios in total had canceled since their subscriptions were first synced, and every one of them was still counted as an active, paying customer in every chart that read from that table.
The win-back list Anezka had been about to send was addressed to eleven studios that had already told Kettleworth they were gone. She caught it by cross-checking against Stripe first, which is what sent Priyam to the warehouse table in the first place.
Kettleworth's fix was the one PostHog's own issue describes as the workaround. In the data warehouse settings, they triggered a manual full refresh of the Stripe Subscription schema, which re-downloads every row rather than only new ones, and the fourteen accounts corrected within the next sync. They now put a recurring full refresh on the calendar rather than waiting to notice the count looked wrong again.
The workaround, and what it costs
PostHog documents three ways to sync Stripe data into the data warehouse source, and the choice matters more than it looks like it should:
- Webhook sync, the mode PostHog recommends, is event-driven instead of scheduled. Stripe sends each change over the moment it happens, so inserts, updates, and deletes all show up within seconds rather than waiting for the next pull. This is the mode that doesn't have this bug, because a cancellation event arrives and updates the row directly instead of waiting to be re-fetched.
- Append-only (incremental) sync periodically pulls new rows by
createdtimestamp, which is fast and cheap but, as this bug demonstrates, can't detect changes to rows it already has. - Full refresh sync re-downloads every row on each run, which does eventually reflect cancellations, but gets more expensive as your Stripe account grows, since every sync repeats the entire history.
If your Subscription schema is set to append-only, either switching it to webhook sync or scheduling a recurring full refresh closes the gap. Neither is free. Webhook sync requires configuring the Stripe webhook correctly, and full refresh is a cost tradeoff that gets worse the longer you've been billing customers.
Where this stops being a five-minute fix
A recurring full refresh works if you're stuck on append-only sync for a reason (an existing pipeline, a webhook config nobody's gotten around to), but it's a workaround, not a fix, and it has real edges:
- You're paying to re-download data you already have, on every single run, just to catch the small fraction of rows that actually changed. That cost curve only points one direction as your subscriber count grows.
- Between refreshes, the number is still wrong. If you run a monthly refresh and check the churn number on day fifteen, you're looking at data that's current only as of the last refresh, not as of right now.
- It only fixes this one schema. The same append-only assumption can apply to other Stripe objects synced the same way, and nothing in PostHog's UI currently flags which schemas are stale versus current.
If none of that applies to you because you're already on webhook sync, the freshness problem this guide describes is closed for you. What's left, for anyone on any sync mode, is a separate question. Once Revenue Analytics has the right answer, does anything happen when it changes, or does it just sit there until someone opens the chart?
Where Modem fits, and where it doesn't
Modem doesn't fix PostHog's Revenue Analytics, and it isn't a revenue analytics or billing dashboard. If churn charts and LTV curves are what you need, that's still PostHog's product to get right, and the workaround above is the one to use until they do.
What Modem's Stripe integration does sits after that fix, not instead of it. It matches Stripe customers to the people and companies in your feedback, and it can trigger automations on billing events (new subscriptions, cancellations, trials about to end, failed payments) as they happen. That's not a faster version of PostHog's sync. It's using the event for something PostHog's chart doesn't do. You can point a cancellation at the priority on a Linear issue tied to that account, so an open feature request stops being weighted like it came from a paying customer once they've left. Paired with the PostHog integration, the Modem agent can also pull PostHog's analytics and feedback data into the same view.
Full disclosure. We build Modem. Read this section as us describing our own product, not a neutral review of the market, and judge it against your own stack accordingly. It doesn't replace Revenue Analytics for the metrics it's actually built to report, like MRR, which isn't affected by this bug at all.
Two related pieces worth reading alongside this one: how to route Stripe customer portal cancellation reasons to your product team covers the other half of a cancellation, the reason behind it, and best tools to pair PostHog data with customer feedback looks at the wider question of joining PostHog's product data to what customers are actually saying.
Confirm it on one canceled account
Open Revenue Analytics, pick one customer you know canceled more than a month ago, and check whether they still read as active. If they do, check your Subscription schema's sync mode in the data warehouse settings. If it's append-only, that's the bug, not a fluke, and a full refresh or a switch to webhook sync is the fix until PostHog closes the issue.
