Intercom and Stripe: which conversations came from customers who are about to churn
It is Tuesday standup. Priya, who runs support at a 40 person SaaS company, has the Intercom inbox open: 63 open conversations, sorted oldest first, most of them tagged either Billing or Bug. Dan, who owns RevOps, has the Stripe dashboard on the shared screen, filtered to subscriptions with cancellation scheduled at period end. That filter returns 11 accounts this month.
The question takes four seconds to ask and nobody in the room can answer it. Are any of Priya's 63 conversations from Dan's 11 accounts?
If they are, those threads are not tickets any more. They are the last conversation the company will have with a paying customer, and they are currently sitting in a queue ordered by age. The same question shows up in a slower form at the end of every quarter, when someone asks what the churned accounts were writing in about before they left. Both versions need the same join, and the join does not exist in either tool.
What Intercom shows on its own
Intercom's model is conversations, contacts and companies, decorated with tags, custom attributes, topics and AI summaries. Its reporting is built around that model and is good at it: conversation volume over time, first response time, resolution rate, CSAT, broken out by team, by tag, by channel.
There is also a Stripe app in Intercom's App Store, and it does something genuinely useful. Open a conversation, and the sidebar shows that contact's plan, subscription status and MRR while you are reading their message. If you have connected Intercom and Stripe this way and your records line up on email, a support rep answering a ticket can see who they are talking to commercially.
Where it stops is the shape of that feature. It is a lookup, one conversation at a time. It does not become a filter you can apply across the inbox, and it does not become a dimension in Intercom's reports. You can go further and push billing fields into Intercom as custom attributes on the company through the API, and some teams do exactly that. It works, and it gives you filterable views. But be clear about what you have built: a sync you now maintain, carrying only the fields you thought to carry, at the freshness of your last update. And it leaves the harder half untouched. Intercom now knows the account is leaving. It still cannot tell you which of the 63 threads is the reason.
What Stripe shows on its own
Stripe knows the money precisely. A subscription carries cancel_at_period_end, canceled_at, and, if you collected it, cancellation_details with a reason and a free text comment. Invoices carry their failure state and where each one sits in the retry schedule. Scheduled downgrades are visible before they take effect, and so are quantity changes when a customer quietly removes four seats. Billing analytics gives churn rate and MRR movement. Sigma lets you write SQL straight over the billing objects, so producing an exact list of every subscription ending this quarter with its plan and its MRR is a solved problem.
Where it stops is why. cancellation_details.feedback is an enum with a handful of options plus a comment box, collected once, at the moment of leaving, from whoever happened to click the button. It is not the six weeks of back and forth in which the customer explained that SSO provisioning never worked for their contractors. Stripe holds none of that, and should not. It is a billing system of record and the record is the point.
Why the gap is structural
Half of this evidence is a record. The other half is a sentence somebody wrote.
Everything on the Stripe side has a defined shape. cancel_at_period_end is a boolean. status is one of a fixed set of strings. amount is an integer. Fields with defined domains can be filtered, joined, counted and aggregated, and the result is either correct or incorrect.
Nothing on the Intercom side has that shape at the point where it matters. The load-bearing evidence is a person typing at 11pm: "honestly, if the export still doesn't include line items by the time we renew, we'll have to look at something else." There is no field for that sentence. It sets no status. You can tag the conversation afterwards, but the tag is a human summary applied at reading time, and it only exists if someone read the thread and chose to apply it.
That is why this is not a report either product forgot to ship. For Stripe to answer it, Stripe would have to hold conversation text and interpret it. For Intercom to answer it, Intercom would need billing state as a first class dimension, and even then somebody would still have to read the sentence and decide whether it means anything.
It is also why the tooling that sits around the two does not close it. Automation platforms are trigger based. You can wire Zapier or Make to fire on customer.subscription.updated and tag the Intercom contact, and that is a sensible thing to build. But the shape of that tool is "when X happens, do Y", and this question is about a body of things that already happened, with no trigger sitting there at the time. BI tools have the opposite constraint. Pipe Stripe into a warehouse and Looker's conversational analytics or Power BI Copilot will chart churn accurately and let you interrogate it in plain English. But they connect to databases and modelled sources, and a sentence a customer wrote is not in a modelled source. Dump the raw text into a column and "does this indicate churn risk" is still not a SQL predicate. These are all good products. The gap is between them, which is usually what people are actually looking for when they search for Intercom Stripe reporting.
What the join actually requires
Two things: a key, and a judgement.
The key is the boring half, and it is where most attempts quietly fail.
| Join key | In Stripe | In Intercom | Fails when |
|---|---|---|---|
| Email address | customer.email | contact.email | Billing goes to finance@, the ticket comes from an engineer with a different address |
| Internal account ID | customer.metadata.account_id | Company company_id or a custom attribute | Never backfilled on customers created before you started setting it |
| Email domain | derived from customer.email | derived from contact.email | Gmail and Outlook customers collapse together, agencies and multi-brand groups split apart |
The right key is the account, not the person. Email matching feels natural and produces confident looking output that misses precisely the accounts where the person writing in is not the person paying, which is most of the accounts that matter.
The judgement is the harder half, because "about to churn" means two different things at once. One is declared: cancel_at_period_end is true, a downgrade is scheduled, an invoice has failed twice. That is a match, and matching is cheap. The other is undeclared: the customer has not cancelled, but the conversation is the kind that comes before cancelling. A fourth follow-up on the same unfixed bug. A question about export formats. A question about when the contract term ends. Someone asking how to remove seats. None of those are tags, and no boolean anywhere records them. Reading them is a judgement call, and it has to happen after the join, on the text itself.
How you would answer it with Skopx
Skopx connects to both sides, along with nearly 1,000 other tools, and you ask in chat. The realistic sentence is close to how you would say it out loud:
Go through every open Intercom conversation from the last 30 days, match them to Stripe by our account ID, and show me the ones from accounts that are set to cancel at period end, have a scheduled downgrade, or have a failed invoice on retry. For each one, tell me what the customer is actually asking for.
What comes back is a list with the reasoning attached: the account, the billing signal that flagged it, the conversation, and a plain summary of the open ask, each with links back to the Intercom thread and the Stripe subscription so you can check the source rather than trust the summary. Accounts that could not be matched on a key are shown as unmatched rather than dropped, because a silently shorter list is worse than an honest one.
When the same question recurs every week, the answer becomes a standing console: an internal app built from that sentence. Rows are at risk accounts ordered by MRR, with renewal date, the billing signal, the date of the last inbound message, the one-line ask, and the current assignee. It reads live from both tools every time somebody opens it. One button per row assigns the conversation to a named teammate in Intercom, and it asks for confirmation before it writes anything.
Honest limits
It reads, and it acts only when a person presses a button. It stores nothing. There is no saved history, so you cannot come back in six months and see how last March's list compared to this one, and there is no trend line of churn-risk conversations over time. If you need that history, you need a warehouse, and that is the right tool for it.
It does not watch. Nothing fires when a subscription flips to cancelling. Nobody gets an alert at 2am. If you want the trigger side, an automation platform does that well and it is worth having alongside this.
It does not predict. "About to churn" here means a billing state that already exists plus a conversation a human being can read. There is no model scoring your accounts, and you should be suspicious of the version of this that claims otherwise.
And the answer is only as good as your keys. If your Stripe customers have no internal account ID in metadata, matching falls back to email and domain, and it will be imperfect in exactly the ways described above. Fixing the key is a one-time job that improves every future answer.
Team is $16 per seat per month with 2.3 million AI tokens included, Solo is $5. SOC 2 controls are in place. The connections use your own Intercom and Stripe credentials, and the console sees whatever those credentials see.
Skopx Team
The Skopx engineering and product team