The customer health score is a single number that tells the Customer Success team how each paying store is doing. It rolls up engagement signals (is there a champion on the account, are they logging in, what data is flowing) and risk signals (a connection was removed, a competitor product is installed, a support issue is open) into one RAG stage — 🔴 Red, 🟡 Yellow, or 🟢 Green — that shows on the store row in cip.littledata.io and on the company sidebar in Intercom.
It is not a forecast and it is not a churn probability. It is a CSM-oriented checklist: every factor that moves the score is something a human can act on.
Only customers that are all of the following:
Stores outside this set don’t appear in the RAG list and keep their previous score, if any, until they re-qualify. The one exception is the Recent positive call toggle (below) — flipping that for any customer recomputes a score on the spot, even if the customer has no destination connected yet.
Every time the nightly run (or a “Recent positive call” toggle) changes a store’s score or its
“why” lines, a point is appended to customerHealthScoreHistory — score, stage, lines, lifecycle
phase. GET /customers/{shopName}/health-history serves it to the CIP dashboard’s health chart.
Unchanged runs write nothing, so the timeline is a list of events. The first time a store churns,
syncCustomersCron also freezes its score, MRR and any already-disconnected destinations into
customers.churnSnapshot, so a Green churn stays visible as a Green churn.
| Stage | Score | Meaning |
|---|---|---|
| 🔴 Red | ≤ 0 |
At risk — needs attention now. |
| 🟡 Yellow | 1 or 2 |
Worth watching; one nudge in the wrong direction and they slide to Red. |
| 🟢 Green | ≥ 3 |
Healthy. No intervention needed. |
Thresholds were calibrated against the actual score distribution to land roughly 45% Red / 19% Amber / 36% Green — the closest integer-cutoff split to a 1/4 – 1/4 – 1/2 target given that ~33% of stores cluster at score 0.
The exact score is shown alongside the stage so two Greens with different headroom can be told apart (e.g. +8 is far more robust than +1).
Every store also carries a “why this score” list — the same factors below, with their +N / −N contributions — so a CSM can see at a glance which lever moved them.
| Factor | Points | What it means |
|---|---|---|
| Champion linked | +3 | The Intercom company has been marked as having an internal champion (someone inside the store who advocates for Littledata). Set via the Champion? attribute on the Intercom company (not on a contact tag — that just identifies which person to use for login timing). |
| Recent positive call | +2 | The CSM has flipped the “Recent positive call” toggle in cip.littledata.io to mark that they’ve had a good conversation with the store recently. Entirely manual — no automation guesses this. |
| Two or more connections | +1 | The store has at least two live destination connections — GA4 counts as one of them. One connection earns nothing, and a third doesn’t earn a further point on this axis. |
| Two or more non-GA4 connections | +1 | Stacks on the point above, so two real destinations are worth +2 and GA4-plus-one is worth +1. This is the sharpest retention signal we have measured: across the paying base plus everything churned since Sept 2025 (n=2,014), churn falls from 25.8% at one non-GA4 destination to 15.2% at two, and flattens after that. GA4 still earns its place in the count above — at one non-GA4 destination, stores with GA4 churn 21.6% against 29.5% without — but “GA4 plus one” retains materially worse than two real destinations, so the two states must not score the same. |
| Google Ads on the Littledata action | +1 | Google Ads is connected and a Littledata conversion action is the account’s primary conversion goal, so Smart Bidding is actually bidding on Littledata’s data. Read from the googleAdsGoalConfig audit check at Success or Info (Info means Littledata is primary alongside a GA4 purchase action — still primary). A connected account whose Littledata action sits as secondary earns nothing. |
| Littledata Klaviyo flows live | +1 | Klaviyo is connected and the klaviyoActiveLittledataFlows check is at Success — the Littledata-triggered flows are switched on and sending. Connecting Klaviyo without setting the flows live is worth nothing, because nothing is being sent. |
| A destination returning over 10× | +1 | At least one destination earns more than 10× the monthly plan cost on its own, from the same per-destination returns the value hero cards show (roiByDestination). Never a cross-destination sum — two mediocre channels can’t add up to a point neither earned. |
| Bot Protection or Profit Signal | +1 | The merchant has switched on Bot Protection (Connection.settings.filterUnidentifiedEvents) or Profit Signal (Connection.settings.profitOptimisation) for at least one destination. One point however many connections carry them — the question is whether the merchant has found the features at all. |
| Event Editor in use | +1 | The store has at least one custom event or customised standard event in the Event Editor, from ld-bi.ShopFeatureAdoption_fact mirrored nightly onto Customer.eventEditor by syncCustomersCron. Drafts alone earn nothing — nothing they define reaches a destination. 51 stores on 2026-10-07. |
| Shopify Markets through Littledata | +1 | More than two active Shopify Markets and Littledata’s Markets source switched on, from the shopifyMarketsActive check’s own activeMarketCount / littledataMarketsActivated. Five markets with no Littledata Markets source is a job we aren’t yet doing for them, so it earns nothing. |
The eight adoption signals are worth a point each, deliberately. Connecting a pixel is one step; the rest are what turn a connection into value. That is why the old “+3 for a second non-GA4 destination” is now worth two points rather than four — a GA4 store that bids Google Ads on the Littledata action and runs live Klaviyo flows is a healthier account than one with four pixels wired up and nothing switched on.
Connections come from the live inventory, not the audited destinations. The count is the union of
connectedDestinationTypes(every live ld-shopify Connection) and the auditeddestinations[]— the same keys the connection-removal diff runs on, so “two connections” means the two a CSM sees on the customer page.destinations[]holds just what MonitorAI audits, so Segment was invisible to it: 132 paying Segment customers had no audited destination at all and went unscored.
Bot Protection and Profit Signal are synced, not audited. They live in each Connection’s settings in ld-shopify-prod, so
syncCustomersFromShopscopies the connection names carrying them ontoCustomer.botProtectionConnections/Customer.profitSignalConnectionson every run. Fleet-wide that is ~222 stores with Bot Protection on and ~27 with Profit Signal.
| Factor | Points | What it means |
|---|---|---|
| No Champion login in 30+ days | −1 | The most recent login from anyone tagged Champion on the store’s Intercom contacts was 30–89 days ago. (Non-champion logins are intentionally ignored — the question is whether the advocate is still active.) |
| No Champion login in 90+ days | −2 | Same signal, 90+ days ago. Replaces the 30-day penalty (not added on top). If the store has no Champion contact at all, this signal doesn’t fire. |
| Champion left company | −4 | A contact tagged Champion now shows a different current employer on LinkedIn. The advocate is gone and the +3 “Champion linked” is describing someone who no longer works there, so this is the largest penalty alongside the app embed. Detected by checkContactEmployersCron through marketing-automations’ employer-check API (Bright Data LinkedIn snapshots); the “why this score” line names the person. Clears when CS moves the Champion tag to a new contact (or the next check sees them back at the company). |
| Champion changed role | −1 | Same check, same employer, but the champion’s title has changed since we started tracking them (a Head of Ecommerce who is now VP Brand may no longer own the Littledata relationship). Not applied on top of “left company”. Best-effort: since LinkedIn’s Nov 2025 policy change titles mostly come from the headline. |
| Connection removed | −2 | A destination integration was connected on the last refresh and is no longer connected now — Meta, GA4, Google Ads, Klaviyo, Microsoft Ads, Segment, TikTok, Pinterest or Attentive. The “why this score” list shows which one. A destination has to disappear from both the audited list and the live connection list before it counts, so a half-finished sync cannot trigger this on its own. |
| Competitor install detected | −3 | A competitor product is active on the store. Triggered by any competitor check naming one at Info, Warning or Error — see the list below. A competitor is a competitor however we spotted it, so the surface makes no difference and neither does the severity: the checks that only ever find an install cap themselves at Info, because Littledata cannot fix the fact that the merchant bought something else. The competitor’s name is included in the explanation. |
| App embed not enabled | −4 | Every Littledata client-side tracking app embed check that actually ran came back false (appEmbedEnabled, appEmbedEnabledStandardApp, appEmbedEnabledSegmentApp, appEmbedEnabledFreeApp). They are not tracking client-side events. A check that never ran is not evidence: a store with no embed check row is left alone, and so is one whose check threw in ld-shopify (a failed run still writes status: false, which is most of the false embed rows fleet-wide — Customer.shopifyAuditCheckErrors records which). |
| MonitorAI error | −2 | The store has at least one MonitorAI audit check at Error. The audits have been refined so an Error is taken as a real problem on its own — there’s no manual gate any more. |
| MonitorAI warning | −1 | Same, but the worst audit on the store is a Warning (no Errors). Stops there: Info, Success, and Unknown audit statuses don’t move the score. |
| Stale Linear ticket | −1 each, capped at −3 | Every Linear ticket on the store that is still unresolved, was opened more than 7 days ago, and was opened within the last 90 days. Read live from Linear (the same two links GET /customers/{shop}/linear-issues uses: the store’s Linear customer record, and CST issues naming the shop). MonitorAI’s own tickets don’t count — they come from audit checks that are already scored as MonitorAI error / warning, and charging a store twice for one finding is double-counting. A ticket raised yesterday is work in progress, not a signal; one raised a year ago is Linear housekeeping. The cap stops a bad support week outweighing every other signal on the account. If Linear can’t be read, the penalty is 0 — “we couldn’t ask” is not evidence of a problem. |
The MonitorAI penalty is recalculated from the current audit-check rows on every refresh — fixing the underlying issue (so the audit moves back to Success / Info / Unknown) is what clears it.
Audit checks feed the score in two ways. Both apply at the same time — they’re independent signals — so a single audit can move the score twice (once via competitor detection, once via the worst-status penalty).
1. The competitor checks → Competitor install detected (−3)
| Audit check (system name) | Display name in cip | Reads |
|---|---|---|
klaviyoActiveCompetitorFlows |
Active alternative flows | Klaviyo flow triggers |
googleAdsCompetitorConversions |
Alternative server-side conversions | Google Ads conversion action names |
metaCompetitorCAPI |
Alternative CAPI detected | Sibling pixels on the Meta dataset |
shopifyAlternativeTracking |
Alternative server-side tracking | The storefront’s own tags and web pixels |
alternativeProperty |
Alternative property | GA4 properties named after a rival |
The list lives in one place, competitorChecks in models/auditChecks.ts; anything that writes competitorNames belongs on it.
When any of these is at Info, Warning or Error, the Competitor install detected −3 penalty fires once for the store, regardless of how many competitor checks are naming someone. Competitor names from the checks appear in the explanation.
Info counts here and nowhere else in the score. A competitor install is a commercial fact rather than a defect, so the storefront scan grades it Info and reserves Warning for a destination Littledata and the rival both feed — the half we can fix. Reading Error/Warning only threw away the widest detector we have.
These checks never open a Linear ticket (linearSuppressedChecks). There is nothing for us to fix, so every ticket raised on one ended up Canceled — and a Canceled ticket makes syncLinearState force its check back to Success, which used to erase the competitor from the account permanently. The penalty therefore ignores Linear state entirely: there is no ticket left to read around.
2. All audit checks → MonitorAI error / warning
Every other audit check the store has — EMQ, conversion-window drift, dataset quality, Klaviyo flow coverage, the same competitor checks, anything else — feeds the MonitorAI error / warning factor based on the worst single status across the whole catalogue. Error wins over Warning; Info / Success / Unknown don’t count.
This is a single signal per store, not per audit: a store with five Errors and twenty Warnings still scores −2 (one MonitorAI error penalty), not −10. The point of the penalty is “something on this store is at Error / Warning right now,” not “tally the misses.”
A Plus store with Meta CAPI + Google Ads + Klaviyo + GA4 connected, a champion linked, no recent positive call, no open issues. Google Ads bids on the Littledata action, the Klaviyo flows are live, Google Ads returns 14×, and Bot Protection is on for Klaviyo:
+3 +1 +1 +1 +1 +1 +1 = +9 → 🟢 Green.
Now an Error appears on Active alternative flows (Klaviyo competitor) and nobody from the store has logged in for 45 days. The same audit feeds two factors — the specific competitor pathway and the catch-all worst-audit pathway:
+9 −3 −2 −1 = +3 → slides to the bottom of 🟢 Green.
The champion then leaves and their Champion? attribute is unset:
A destination is removed, and two support tickets the merchant raised a fortnight ago are still open:
0 −2 −2 = −4 → still 🔴 Red, deeper.
Open Support Ticket — independent of the health score. Fires when the store has any audit check linked to a non-terminal Linear ticket. Those are MonitorAI’s own tickets, which the stale-ticket penalty deliberately excludes, so this tag never lines up with that penalty: it says “an audit finding is open in Linear”, while the penalty counts the tickets people filed. Only ERROR checks open a ticket (postChangesToLinear), so a store carrying only warnings never gets this tag — its warnings still show in the app, in Slack and on the shared audit link, and still count towards worstAuditSeverity. Competitor checks never open one either, whatever their status, so they can never raise this tag.Multiple Shopify Markets (not a scoring factor — surfaced as context for CSMs scoping enterprise stores).The four At Risk - … tags — No Login 90d, Connection Removed, Competitor Detected, Champion Left — were retired in September 2026. Each restated a factor the “why this score” list and the RAG tag already carry, but a tag only moves when the store is refreshed: one that drops out of scoring, loses its Intercom link or churns keeps whatever it last had, with nothing in Intercom to say how old that is. Read the risk off the score instead. (scripts/remove-at-risk-intercom-tags.ts stripped them; the empty tag objects stay in the workspace until someone confirms no saved segment needs them.)
Separately, marketing-automations applies a Monitor: <check name> company tag per failing audit check, driven by the audit_status_transition webhook this repo sends from updateAuditCheck. Those are not part of the health score and are not maintained here.
The overnight refresh works through the base one store at a time and takes about an hour, so a score you see first thing in the morning is from last night, not from this minute. The “last refreshed” date on the customer detail page is the one to trust: if it is older than yesterday while other stores have moved on, that store’s refresh did not complete and the score on screen is stale — worth flagging rather than acting on.
If a score looks wrong, the most common explanations are:
Champion contact tag. Untag the
person who left (and tag their successor) in Intercom or from the Contacts tab in cip.littledata.io;
the next nightly run clears it. Internal @littledata.io contacts are ignored everywhere.beautyboxok is OK! Beauty Box, a Reach plc title, so its @reachplc.com
people all read as departed. Noise in the slug does the same (jot-dev becomes the company name
“jot dev”, which does not match “Jot”). Since #785 a moved verdict is ignored when the observed
employer matches the contact’s own work-email domain, the storefront domain or the slug
(services/contacts/employerIdentity.ts), and the check records reinterpretedFrom: "moved" so you
can see it happened. Rows flagged before that guard existed keep the flag until their next verify
pass — npx tsx scripts/reconcile-false-departures.ts --apply clears them in one go.Champion? attribute on the Intercom company isn’t set, or isn’t set to a truthy value (Yes / true / 1). Set it on the company, not on a contact.lastSeenAt only from contacts tagged Champion in Intercom. If nobody on the account carries that tag, you’ll see no login penalty but also no champion benefit; if a contact does carry it but hasn’t logged in, the −1 / −2 will apply until they do.