Skip to content
Seamline
← Use cases

How do you build a customer health score from CRM, billing and support data?

7 min read (+ optional SQL)

By Tim, founder of Seamline · Last updated 6 October 2026

Most health scores don't fail at the formula. They fail earlier: the signals live in different systems that don't agree on who the customer is.

A B2B customer health score is supposed to tell you which accounts are at risk before it's too late. In practice, many SaaS teams end up with a scorecard that's green for customers about to churn, because it's built from data that was easy to reach rather than the cross-system signals that matter.

This use case builds a health score the other way around: start with signals that plausibly indicate risk, link them to one customer across CRM, billing, support and product, score them simply, show the evidence behind every score, and then test which signals actually predict churn in your business. You can read it in about seven minutes. For anyone who wants to build it, the key steps have SQL tested on Postgres behind a toggle, and a technical appendix lets you run the whole example.

The result

A list where every colour comes with its reason:

CustomerHealthConfidenceRenews inEvidence
Brightpath Learning🔴 RedHigh47 days3 open tickets, 1 high-priority · active users −45% · MRR down from €3,500 to €2,500
Northwind Analytics🔴 RedHigh64 days€1,950 overdue 17 days · high-priority ticket open · active users −35%
Atlas & Rowe🔴 RedHigh41 days€3,000 overdue 23 days · urgent ticket open (SSO login)
Fjord Studio🟡 YellowMedium88 daysActive users −25% · no CS conversation in 87 days · support data not linked
Oakline Legal⚪ UnknownLow128 daysNo billing, support or product data linked yet

All companies, people and numbers in this use case are fictional.

Look at Brightpath Learning. In the CRM it looks fine: CS spoke to them three days ago and the relationship notes are positive. A score built from CRM data alone would call it green. Billing and product tell a different story: they removed seats, and usage nearly halved.

Why most health scores go wrong

  • They're built from what's in the CRM. Last contact, sentiment, NPS. Easy to reach, and mostly about how the relationship feels, not what the customer does.
  • The strongest signals live elsewhere. Payment behaviour is in billing. Frustration is in support. Adoption is in the product. Each system uses its own customer record.
  • A single number hides the reason. "Health: 42" invites "says who?". Nobody knows what to do with it.
  • Missing data counts as healthy. A customer with no linked support or usage data has no bad signals, so it shows up green. That's the most dangerous green there is.

How it works

 CRM ──────┐
 Billing ──┤
 Support ──┤
 Product ──┤
           ▼
   Customer identity      one canonical ID, linked to every system's records
           │
           ▼
       Signals            each from the system that owns it
           │
           ▼
   Score + evidence       simple points, with the reason next to every point
           │
     ┌─────┴─────┐
     ▼           ▼
    CRM        Slack      health on the company record, weekly list to each owner

The formula is the easy part. The layer that links the signals to one customer is what makes the score worth trusting. This is how you build a reliable single customer view and health scorecard: not by replacing your CRM or data stack, but by linking each tool's records to one customer and applying the scoring rules consistently.

Step 1: Pick plausible churn signals before the formula

You don't know yet which signals predict churn in your business. Nobody does on day one: an overdue invoice can be an early warning in one company and routine in another, and login frequency matters for a daily tool but not for one used once a quarter. So start with a handful of signals that plausibly indicate risk and that you can explain to anyone, decide which system owns each one, and test them in step 5.

SignalSourceWhy it mattersExample threshold
Overdue invoiceBillingPayment problems can come before cancellations1 point after 1 day, 2 after 14
High-priority or piling-up ticketsSupportUnresolved problems wear down patience2 points for an open high-priority ticket, 1 for 3+ open
Falling usageProductFewer people logging in means less value1 point at −15%, 2 at −30%
ContractionBillingRemoving seats or downgrading is a vote with money1 point if MRR fell 10%+ in 3 months, 2 if 25%+
SilenceCRMNobody has spoken to them in a while1 point after 60 days

Two things deliberately aren't on the list:

  • Renewal date. It isn't a health signal; it's urgency. A red customer renewing in 40 days and one renewing in 8 months need the same diagnosis but a different timeline. Show it next to the score, and use it to sort.
  • Sentiment and NPS. Useful context, but they measure how the relationship feels. Start with what customers do; add sentiment later if step 5 shows it helps.

Each signal comes from a different system, and each system knows the customer under a different name: "Northwind Analytics" in the CRM, "NORTHWIND ANALYTICS B.V." in billing, an organisation called northwind.io in support, acc_1 in the product.

So give every business customer one canonical ID and link each system's records to it. Rules suggest the links, a person confirms them, and only confirmed links count. The renewal-risk use case shows how, step by step. The MRR used for the contraction signal is the same billing-based number as in the HubSpot ARR vs Stripe use case.

Then make one rule non-negotiable:

In the sample, Oakline Legal has no confirmed billing, support or product link. A naive score would show it green, because nothing looks wrong in data that isn't there. This one shows "unknown", which is the honest answer and a to-do for whoever maintains the links.

Show the SQL for step 2

One row per customer, with every signal from the system that owns it. NULL means "we don't know", not "fine":

-- One row per customer: every signal, from the system that owns it.
-- Only confirmed links are used. NULL means "we don't know", not "fine".
CREATE OR REPLACE VIEW customer_signals AS
-- Demo date: 2026-10-15, to match the sample data.
-- In production, replace it with CURRENT_DATE or your agreed reporting date.
WITH params AS (SELECT date '2026-10-15' AS today),
links AS (
  SELECT customer_id, source, source_id
  FROM customer_source_ids WHERE status = 'confirmed'
),
crm AS (
  SELECT l.customer_id, c.name, c.owner, c.renewal_date, c.last_cs_touch
  FROM links l JOIN crm_companies c ON l.source = 'crm' AND c.id = l.source_id
),
billing AS (
  SELECT l.customer_id,
         max(p.today - i.due_date) FILTER (WHERE i.status = 'open' AND i.due_date < p.today) AS days_overdue,
         sum(i.amount_eur)         FILTER (WHERE i.status = 'open' AND i.due_date < p.today) AS overdue_eur,
         string_agg(i.id, ', ')    FILTER (WHERE i.status = 'open' AND i.due_date < p.today) AS overdue_invoices
  FROM links l CROSS JOIN params p
  JOIN billing_invoices i ON l.source = 'billing' AND i.customer_id = l.source_id
  GROUP BY l.customer_id
),
mrr AS (
  SELECT l.customer_id,
         sum(m.mrr_eur) FILTER (WHERE m.month = date '2026-10-01') AS mrr_now,
         sum(m.mrr_eur) FILTER (WHERE m.month = date '2026-07-01') AS mrr_3m_ago
  FROM links l JOIN billing_mrr m ON l.source = 'billing' AND m.customer_id = l.source_id
  GROUP BY l.customer_id
),
support AS (
  SELECT l.customer_id,
         count(*) FILTER (WHERE t.status IN ('open','pending')) AS open_tickets,
         count(*) FILTER (WHERE t.status IN ('open','pending')
                            AND t.priority IN ('high','urgent'))  AS urgent_open,
         string_agg(t.id, ', ') FILTER (WHERE t.status IN ('open','pending')) AS open_ticket_ids
  FROM links l
  JOIN support_tickets t ON l.source = 'support' AND t.org_id = l.source_id
  GROUP BY l.customer_id
),
product AS (
  SELECT l.customer_id, u.active_last, u.active_prev
  FROM links l JOIN product_usage u ON l.source = 'product' AND u.account_id = l.source_id
)
SELECT
  crm.customer_id, crm.name, crm.owner,
  crm.renewal_date - p.today                        AS renews_in_days,
  p.today - crm.last_cs_touch                       AS days_since_cs_touch,
  -- billing (no linked billing record -> NULL)
  CASE WHEN EXISTS (SELECT 1 FROM links x WHERE x.customer_id = crm.customer_id AND x.source = 'billing')
       THEN coalesce(b.days_overdue, 0) END         AS days_overdue,
  b.overdue_eur, b.overdue_invoices,
  m.mrr_now, m.mrr_3m_ago,
  -- support (linked but no tickets -> 0)
  CASE WHEN EXISTS (SELECT 1 FROM links x WHERE x.customer_id = crm.customer_id AND x.source = 'support')
       THEN coalesce(s.open_tickets, 0) END         AS open_tickets,
  s.urgent_open, s.open_ticket_ids,
  -- product
  pr.active_last, pr.active_prev,
  round(100.0 * (pr.active_last - pr.active_prev) / nullif(pr.active_prev, 0)) AS usage_change_pct
FROM crm CROSS JOIN params p
LEFT JOIN billing b  USING (customer_id)
LEFT JOIN mrr m      USING (customer_id)
LEFT JOIN support s  USING (customer_id)
LEFT JOIN product pr USING (customer_id);

Step 3: A simple, explainable customer health score formula

Add up the points from step 1, and keep two things apart: how the customer looks, and how much of the customer you can actually see.

PointsHealth
0–1🟢 Green
2–3🟡 Yellow
4 or more🔴 Red
Sources linkedConfidence
Billing, support and productHigh
One of them missingMedium
Two or more missingLow, and health shows ⚪ Unknown unless the known signals already add up to red

So Fjord Studio is yellow with medium confidence: the signals we can see are worrying, and support data is missing. That says more than "yellow" or "unknown" alone.

Why points and not weights, or a model? Because the first version has to be explainable. Anyone should be able to look at a red customer and see which two or three things put it there. Once you've run it for a few quarters and know which signals actually predict churn in your business, you can weigh them differently. Start simple and earn the complexity.

Show the SQL for step 3

The score, the colour and the evidence in one view:

-- A simple, explainable score: points per signal, with the reason written next to it.
-- Health and confidence are separate: one says how the customer looks,
-- the other how much of the customer we can actually see. Thresholds are examples.
CREATE OR REPLACE VIEW customer_health AS
WITH points AS (
  SELECT *,
    CASE WHEN days_overdue > 14 THEN 2 WHEN days_overdue > 0 THEN 1 ELSE 0 END             AS p_payment,
    CASE WHEN urgent_open > 0 THEN 2 WHEN open_tickets >= 3 THEN 1 ELSE 0 END               AS p_support,
    CASE WHEN usage_change_pct <= -30 THEN 2 WHEN usage_change_pct <= -15 THEN 1 ELSE 0 END AS p_usage,
    CASE WHEN mrr_now <= mrr_3m_ago * 0.75 THEN 2
         WHEN mrr_now <= mrr_3m_ago * 0.90 THEN 1 ELSE 0 END                                AS p_contraction,
    CASE WHEN days_since_cs_touch > 60 THEN 1 ELSE 0 END                                    AS p_silence,
    (days_overdue IS NULL)::int + (open_tickets IS NULL)::int + (usage_change_pct IS NULL)::int AS n_missing,
    concat_ws(', ',
      CASE WHEN days_overdue     IS NULL THEN 'billing' END,
      CASE WHEN open_tickets     IS NULL THEN 'support' END,
      CASE WHEN usage_change_pct IS NULL THEN 'product' END)                                AS missing
  FROM customer_signals
),
scored AS (
  SELECT *, p_payment + p_support + p_usage + p_contraction + p_silence AS risk_points
  FROM points
)
SELECT
  name AS customer, owner, renews_in_days, risk_points,
  CASE
    -- too little data to judge: say so instead of guessing (unless it's already clearly red)
    WHEN n_missing >= 2 AND risk_points < 4 THEN 'unknown'
    WHEN risk_points >= 4 THEN 'red'
    WHEN risk_points >= 2 THEN 'yellow'
    ELSE 'green'
  END AS health,
  CASE n_missing WHEN 0 THEN 'high' WHEN 1 THEN 'medium' ELSE 'low' END AS confidence,
  concat_ws(' · ',
    CASE WHEN p_payment > 0 THEN
      format('€%s overdue %s days (%s)', to_char(overdue_eur, 'FM999,999'), days_overdue, overdue_invoices) END,
    CASE WHEN p_support > 0 THEN
      format('%s open %s, %s high-priority (%s)', open_tickets,
             CASE WHEN open_tickets = 1 THEN 'ticket' ELSE 'tickets' END, urgent_open, open_ticket_ids) END,
    CASE WHEN p_usage > 0 THEN
      format('active users %s%% (%s → %s)', usage_change_pct, active_prev, active_last) END,
    CASE WHEN p_contraction > 0 THEN
      format('MRR down from €%s to €%s', to_char(mrr_3m_ago, 'FM999,999'), to_char(mrr_now, 'FM999,999')) END,
    CASE WHEN p_silence > 0 THEN
      format('no CS conversation in %s days', days_since_cs_touch) END,
    CASE WHEN missing <> '' THEN 'no data from: ' || missing END
  ) AS evidence
FROM scored;

-- The list: worst first; among equals, the renewal that comes up soonest
SELECT customer, owner, health, confidence, risk_points, renews_in_days, evidence
FROM customer_health
ORDER BY CASE health WHEN 'red' THEN 1 WHEN 'yellow' THEN 2 WHEN 'unknown' THEN 3 ELSE 4 END,
         risk_points DESC, renews_in_days;

On the sample data this returns the five customers in the table at the top, plus three green ones: Kestrel Logistics and Corvid Labs with high confidence, and Mosaic Retail with medium confidence. Mosaic's €800 invoice is only 7 days overdue, and its support data isn't linked, which the evidence column says.

Step 4: Show the evidence, not just the colour

The evidence column is the most important part of the result. Compare:

  • "Brightpath Learning: red."
  • "Brightpath Learning: 3 open tickets, one high-priority (users can't log in) · active users down 45% · MRR down from €3,500 to €2,500 after removing seats. Renews in 47 days."

The first starts a discussion about whether the score is right. The second starts a phone call. Every point in the score should trace back to a record someone can open: the invoice, the ticket, the usage chart.

Where the result goes

  • The CRM: health and evidence written to the company record, so the account owner sees them where they already work.
  • A weekly list per owner: red and yellow customers, sorted by renewal date, in Slack or email.
  • AI, optionally, on top: once the evidence is trustworthy, a model can turn it into a short brief and a draft message for the owner to review. Not before: AI reasoning over unlinked data produces confident, wrong summaries.

Step 5: Test the score against reality

A health score is a hypothesis, not a fact. Don't assume the formula is right; test it like a prediction. After a quarter, compare the colours with what customers did:

  • Did the reds churn or downgrade more often than the greens? If not, the signals or thresholds are wrong.
  • Which greens left anyway? Look at what they had in common. That's your missing signal.
  • How often did owners dismiss a red, and why? If a signal is dismissed most of the time, it's noise. Store the dismissals with a reason, so this check takes minutes.

Adjust one threshold at a time, and write down when you changed it, so last quarter's colours stay comparable.

What breaks in production

  • New customers without links. Every new signup needs a link in each system. Unlinked customers drift into "unknown"; someone has to review that list weekly.
  • Usage definitions. "Active user" means something different per product. Agree on it once, or the usage signal changes every time someone edits a dashboard.
  • Seasonality. Usage drops in August and December for many B2B products. Compare with the same period last year, or the score turns yellow every summer.
  • Ticket spikes from one event. An outage creates tickets for everyone. Exclude incident-related tickets, or every customer turns red at once.
  • Score fatigue. If half the customers are red, nobody looks. Tune the thresholds until red means "act this week".

Three ways to implement it

1. A spreadsheet, reviewed weekly. With fewer than about 50 customers, a sheet with one row per customer and five signal columns can be enough, if someone updates it every week.

2. A customer success platform. Tools built for this can calculate the score and give CS a place to work from. What they don't do automatically is resolve the identity, definitions and data ownership underneath. If the signals going into the platform are incomplete or inconsistent, the score will be too. That layer is worth getting right whichever tool sits on top.

3. A separate data layer, maintained by someone. The links, the signals and the score in a small database, written back to the CRM on a schedule. The most robust option, and the one that needs the most care. It's what we build and maintain at Seamline, but plenty of teams run it in-house.

Technical appendix: run this yourself

Run these in order in an empty Postgres database (a free Supabase project works). Tested on Postgres 16.

1. Sample data (fictional companies and numbers; "today" is 15 October 2026):

-- Sample data: fictional companies, people and numbers. "Today" is 2026-10-15.

-- CRM: companies with owner, renewal date and the last logged CS conversation
CREATE TABLE crm_companies (
  id            text PRIMARY KEY,
  name          text,
  owner         text,
  renewal_date  date,
  last_cs_touch date
);

-- Billing: invoices, and MRR now vs. three months ago
CREATE TABLE billing_invoices (
  id          text PRIMARY KEY,
  customer_id text,
  amount_eur  numeric,
  due_date    date,
  status      text            -- open, paid
);
CREATE TABLE billing_mrr (
  customer_id text,
  month       date,           -- first day of the month
  mrr_eur     numeric
);

-- Support: tickets per organisation
CREATE TABLE support_tickets (
  id         text PRIMARY KEY,
  org_id     text,
  priority   text,            -- low, normal, high, urgent
  status     text,            -- open, pending, solved
  created_at date,
  subject    text
);

-- Product: active users per account, last 30 days vs the 30 days before
CREATE TABLE product_usage (
  account_id   text PRIMARY KEY,
  active_last  int,
  active_prev  int
);

INSERT INTO crm_companies VALUES
  ('hs_101','Northwind Analytics','Sanne','2026-12-18','2026-09-30'),
  ('hs_102','Atlas & Rowe',       'Daan', '2026-11-25','2026-10-08'),
  ('hs_104','Kestrel Logistics',  'Sanne','2027-03-10','2026-09-22'),
  ('hs_105','Fjord Studio',       'Daan', '2027-01-11','2026-07-20'),
  ('hs_106','Mosaic Retail',      'Sanne','2027-04-02','2026-10-01'),
  ('hs_107','Brightpath Learning','Daan', '2026-12-01','2026-10-12'),
  ('hs_108','Corvid Labs',        'Sanne','2027-06-15','2026-09-15'),
  ('hs_110','Oakline Legal',      'Daan', '2027-02-20','2026-09-01');

INSERT INTO billing_invoices VALUES
  ('INV-2041','cus_A1', 1950,'2026-09-28','open'),
  ('INV-2042','cus_A2', 3000,'2026-09-22','open'),
  ('INV-2050','cus_A4', 2000,'2026-10-01','paid'),
  ('INV-2051','cus_A6',  800,'2026-10-08','open'),
  ('INV-2052','cus_A7', 2500,'2026-10-01','paid'),
  ('INV-2053','cus_A8',  920,'2026-10-01','paid'),
  ('INV-2054','cus_A5', 1000,'2026-10-01','paid');

INSERT INTO billing_mrr VALUES
  ('cus_A1','2026-07-01',2000), ('cus_A1','2026-10-01',2000),
  ('cus_A2','2026-07-01',2550), ('cus_A2','2026-10-01',2550),
  ('cus_A4','2026-07-01',1500), ('cus_A4','2026-10-01',2000),
  ('cus_A5','2026-07-01',1000), ('cus_A5','2026-10-01',1000),
  ('cus_A6','2026-07-01', 800), ('cus_A6','2026-10-01', 800),
  ('cus_A7','2026-07-01',3500), ('cus_A7','2026-10-01',2500),
  ('cus_A8','2026-07-01', 920), ('cus_A8','2026-10-01', 920);

INSERT INTO support_tickets VALUES
  ('T-4170','zd_9', 'high',  'open',   '2026-09-30','Export to BI fails'),
  ('T-4171','zd_9', 'normal','pending','2026-10-09','Question about invoice'),
  ('T-4180','zd_10','urgent','open',   '2026-10-08','SSO login broken'),
  ('T-4190','zd_12','normal','open',   '2026-10-10','Feature request: bulk edit'),
  ('T-4191','zd_13','normal','open',   '2026-10-02','Slow dashboard'),
  ('T-4192','zd_13','normal','open',   '2026-10-06','Slow dashboard (again)'),
  ('T-4193','zd_13','high',  'open',   '2026-10-12','Users can''t log in'),
  ('T-4195','zd_14','low',   'solved', '2026-09-20','Change billing email');

INSERT INTO product_usage VALUES
  ('acc_1', 26, 40),   -- Northwind: down 35%
  ('acc_2', 18, 19),   -- Atlas & Rowe
  ('acc_4', 64, 50),   -- Kestrel: up
  ('acc_5',  9, 12),   -- Fjord: down 25%
  ('acc_6', 14, 14),   -- Mosaic
  ('acc_7', 22, 40),   -- Brightpath: down 45%
  ('acc_8', 11, 11);   -- Corvid

2. The identity links. In a real setup, rules propose these and a person confirms them; the renewal-risk use case has the SQL for that.

-- One canonical ID per business customer, linked to each system's records.
-- In a real setup, rules suggest these links and a person confirms them.
CREATE TABLE customer_source_ids (
  customer_id text,
  source      text,           -- crm, billing, support, product
  source_id   text,
  status      text            -- only 'confirmed' links are used
);

INSERT INTO customer_source_ids VALUES
  ('CUST_000001','crm','hs_101','confirmed'),('CUST_000001','billing','cus_A1','confirmed'),
  ('CUST_000001','support','zd_9','confirmed'),('CUST_000001','product','acc_1','confirmed'),
  ('CUST_000002','crm','hs_102','confirmed'),('CUST_000002','billing','cus_A2','confirmed'),
  ('CUST_000002','support','zd_10','confirmed'),('CUST_000002','product','acc_2','confirmed'),
  ('CUST_000004','crm','hs_104','confirmed'),('CUST_000004','billing','cus_A4','confirmed'),
  ('CUST_000004','support','zd_12','confirmed'),('CUST_000004','product','acc_4','confirmed'),
  ('CUST_000005','crm','hs_105','confirmed'),('CUST_000005','billing','cus_A5','confirmed'),
  ('CUST_000005','product','acc_5','confirmed'),
  ('CUST_000006','crm','hs_106','confirmed'),('CUST_000006','billing','cus_A6','confirmed'),
  ('CUST_000006','product','acc_6','confirmed'),
  ('CUST_000007','crm','hs_107','confirmed'),('CUST_000007','billing','cus_A7','confirmed'),
  ('CUST_000007','support','zd_13','confirmed'),('CUST_000007','product','acc_7','confirmed'),
  ('CUST_000008','crm','hs_108','confirmed'),('CUST_000008','billing','cus_A8','confirmed'),
  ('CUST_000008','support','zd_14','confirmed'),('CUST_000008','product','acc_8','confirmed'),
  ('CUST_000010','crm','hs_110','confirmed');
-- Oakline Legal: no confirmed billing, support or product link yet

3. Then run the SQL from steps 2 and 3 above.

Want this done for your data? See how we work → /services/crm-data-cleansing

Want to see what this looks like on your own data?

Pick one question your team currently answers by opening three or four different systems. We'll map where the data lives, how the records connect, and what it would take to answer it reliably.

Book a revenue data diagnostic →