Every tool in a SaaS stack has its own idea of who a user is. Sentry has whatever the SDK set. PostHog has a person and, if you told it, a group. Stripe has a customer. The help desk has a contact. The CRM has a company. The same customer is five strangers in five tools, and "which paying customers did this error hit?" means introducing them to each other by hand.
The fix is an account: one row per customer company on your side, with each tool's id for it. Once that exists, the question is a join. This guide is how to build it.
1. Pick the key
The key is the id that stands for a paying customer. In almost every SaaS that is the Stripe customer id: it is the thing that pays, it is stable, and it already exists. If your product has a team or organisation model, your own organizations.id is the key and the Stripe customer id is one of its columns.
One rule: a customer is a company, not a person. A person can change jobs; a company keeps paying.
2. Propagate it
The clean way is to tell every tool the key at the moment you know it, so each tool's data carries it from the start.
Sentry: set the user, and set the account as a tag, on every request.
Sentry.set_user(id: current_user.id, email: current_user.email)
Sentry.set_tags(account: current_account.id)
PostHog: identify the person, and put them in a group that stands for the company. From then on every event carries the group, and PostHog can count events and people per company.
posthog.identify(user.id, { email: user.email });
posthog.group("company", account.id, { name: account.name, plan: account.plan });
Support and CRM: most help desks and CRMs have a company object with custom attributes; put the key there (stripe_customer_id, or your own id). Intercom's company_id, HubSpot's company property, Attio's record attribute.
Stripe: metadata on the customer, going the other way: metadata[account_id] and metadata[domain].
Do this on day one and the rest of the guide is unnecessary. Most products are not on day one.
3. Join by company domain where you cannot
Tools you did not tag from the start, and tools that only ever see an email address (a support inbox, a CRM contact), can still be joined: the domain of an email address is the company, most of the time.
PUBLIC = %w[gmail.com googlemail.com yahoo.com hotmail.com outlook.com live.com icloud.com proton.me].freeze
def company_domain(email)
domain = email.to_s.split("@", 2).last&.strip&.downcase
domain unless domain.nil? || PUBLIC.include?(domain)
end
Give every account a domain column (from the first user's email, or from the Stripe customer's email, or set by hand), and every tool's record with an email joins to it. The exceptions:
- Public mailboxes.
ada@gmail.comsays nothing about the company. Fall back to the exact address: keep a smallidentitiestable of (email,account_id) for those, filled when someone signs up. - Several domains. A company that is
acme.devandacme.com: more than one domain per account, or an alias table. - Agencies and consultants. One person at
agency.comworks for three of your customers. There is no right answer; put them on the one that pays them, or on none.
4. Read the affected users out of each tool
With the account table in place, each tool's "who" becomes a list of account ids:
Sentry, per issue: the top user values, GET /api/0/issues/{id}/tags/user/, each email:… resolved through company_domain to an account. For issues with many users, page the events instead. The full walk-through is in connecting Sentry errors to Stripe revenue.
PostHog, per window: everyone who hit the broken step. If groups are set, ask by group:
SELECT $group_0 AS company, count(DISTINCT person_id) AS people
FROM events
WHERE event = 'checkout_started'
AND timestamp >= toDateTime('2026-09-19 12:15:00') AND timestamp < toDateTime('2026-09-19 12:52:00')
AND $group_0 != ''
GROUP BY company
If groups are not set, person.properties.email and company_domain on your side.
Support: the conversations opened in the window, by the contact's email.
Union the lists; each account once.
5. Split paying from not, and rank
Join the list to Stripe: an account with an active subscription is paying, with an MRR; the rest are trials, free plans and prospects. Both lists are useful, in different ways: the paying list is who to write to first, the other list is the conversion you may have lost.
Rank the paying list by MRR, and by renewal date if you have it: the account paying €499 that renews in 25 days is the first email.
Keep it true
The join drifts: people change addresses, companies rename, someone signs up from a personal account. Two habits keep it honest. First, every join that fell through (an email that matched no account) goes in a list you look at weekly; most are one-line fixes to the identities table. Second, when a customer is looked up by hand, whatever you learned goes into the table, not into the chat where you learned it.
What Vesqo does with this
Vesqo keeps the account table for you. Each Stripe customer becomes an account keyed by its id, with a domain from its email; every other source's record (a Sentry user, a PostHog group or person, a support contact, a CRM company) is attached by company domain, with the public mailboxes excluded and exact addresses as the fallback. From then on an error, a conversation, a payment and a deal about the same company land on the same page, and a signal about an incident lists the paying accounts in it with what each pays. The customer impact use case shows what that looks like.