This contract is owned by monitoring-ai, the producer of these endpoints. It lives here, next to
the handlers, so that a change to src/handlers/api/customers/* and a change to the contract are the
same pull request. It previously lived in the consumer repo (customer-intelligence), where backend
authors never saw it — see the warning under Customer List for what that cost.
Read this before changing any /customers handler, and especially before narrowing a projection.
The CIP dashboard at https://cip.littledata.io is a second consumer of GET /customers alongside the
admin UI, and it reads considerably more fields than the admin table does.
Audience is both sides: backend engineers changing these endpoints, and frontend developers (or agents acting on their behalf) changing the CIP UI without accidentally changing the contract.
It is safe to share publicly. It intentionally describes endpoints, request shapes, and client-side behavior only. Do not add real tokens, customer data, backend credentials, or production-only private configuration to this document.
Customer Intelligence is a thin Next.js frontend. It does not define its own Next.js API routes.
The browser calls the monitoring-ai HTTP API directly. The base URL comes from NEXT_PUBLIC_API_URL, which is a public browser environment variable. Use an origin such as a local development URL or deployed API URL, but do not document private credentials here.
The API client lives in src/lib/api.ts. Most frontend code should call the exported api instance, or the SWR hooks in src/lib/hooks, instead of calling fetch directly.
Sign-in starts by navigating the browser to:
GET {NEXT_PUBLIC_API_URL}/auth/google?loginStyle={popup|redirect}&returnUrl={frontend callback URL}
The frontend callback route reads a sessionToken query parameter when present, stores it in localStorage, removes it from the visible URL, and then calls /auth/me.
Every API request made through ApiClient sends:
Content-Type: application/json
Authorization: Bearer {sessionToken}
The current client uses credentials: "omit". Do not rely on cookies unless the frontend and backend are deliberately changed together.
Auth endpoints currently consumed by the frontend:
GET /auth/me
{ data: { user } } or { user }.LittledataUser from src/lib/types.ts.POST /auth/logout
sessionToken locally even if the request fails.A client that is not a browser — a local Claude agent, a script, an automation — cannot complete
the Google sign-in above. It authenticates instead with a per-user ldat_* API token minted
from an admin’s session. The token inherits the admin’s identity, so every /customers* endpoint in
this document answers exactly as it would for that admin in the dashboard. Machine-readable spec:
openapi-cip.yaml.
Base URL is the same origin CIP uses as NEXT_PUBLIC_API_URL; production is
https://alnmpsr7dc.execute-api.us-east-1.amazonaws.com/prod.
Scopes. POST /auth/api-tokens takes { "name": string, "scope"?: "read" | "write" }.
| Scope | Who can mint | What it authenticates |
|---|---|---|
read (default) |
admin or agency session | GET only. Any other method answers 401 — the token is never authenticated for it. This is the external Agency API contract. |
write |
admin session only (403 otherwise) | Every method the admin’s own session could: the PUT / POST / DELETE endpoints below included. Falls back to read-only if the owner stops being an admin. |
Both last 12 months, and neither can mint or revoke tokens (403). The plaintext is returned
once; only its hash is stored. Revoke with DELETE /auth/api-tokens/{id}; list with
GET /auth/api-tokens.
Minting a write token. There is no UI for the write scope yet (the token page in
monitoring-admin-ui mints read), so mint it once from a terminal. Sign in to the dashboard, copy
sessionToken from the browser’s localStorage, then:
curl -sS -X POST "$API/auth/api-tokens" \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"CIP agent — Edward laptop","scope":"write"}'
Copy data.token from the response into the agent’s environment (never into a repo, prompt file
or shared doc). Then every call is:
curl -sS "$API/customers?phase=paying" -H "Authorization: Bearer $LDAT_TOKEN"
No cookie, no CORS preflight — this is server-to-server, so OPTIONS routes are irrelevant here.
Endpoints an agent uses, with the scope each needs. Request and response shapes are in the sections that follow; nothing below is agent-specific.
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET |
/customers |
read | Whole list, optionally ?search=&planGroup=&phase=&healthStage= |
GET |
/customers/{shopName} |
read | Detail: health score and reasons, MRR, plan, owner, flags |
GET |
/customers/{shopName}/contacts |
read | Intercom app users mirrored for the store |
GET |
/customers/{shopName}/leads |
read | Everyone the leads database holds for the brand (the path is /leads, not /people — see that section) |
GET |
/customers/{shopName}/company |
read | Firmographics (industry, headcount, revenue bands) |
GET |
/customers/{shopName}/health-history |
read | Score over time with the reasons that changed |
GET |
/customers/{shopName}/notes |
read | CS notes (mirrored from Intercom) |
GET |
/customers/{shopName}/audit-checks |
read | Open Monitor AI errors and warnings |
PUT |
/customers/{shopName}/account-owner |
write | { accountOwner: "David" \| "Cornelia" \| "Cesar" \| "Edward" \| null } |
PUT |
/customers/{shopName}/health-flags |
write | { hadRecentPositiveCall: boolean } — moves the health score |
POST |
/customers/{shopName}/notes |
write | { body, sentiment? } — writes a note into Intercom |
DELETE |
/customers/{shopName}/notes/{noteId} |
write | Hides a note from CIP (Intercom keeps it) |
PUT |
/customers/{shopName}/contacts/{contactId}/champion |
write | { isChampion: boolean } — tags the contact in Intercom |
PUT |
/customers/{shopName}/leads/{leadId}/champion |
write | { isChampion: boolean } — tags a non-user (leads database) as a champion |
PUT |
/customers/{shopName}/contacts/{contactId}/linkedin |
write | { linkedinUrl: string \| null } |
Segmenting. The list endpoint filters on search, planGroup, phase and healthStage only
(healthStage still takes Yellow for the middle band). It has no pagination and returns the
whole collection — a few hundred rows — in one call, so pull it once, unfiltered, and segment
locally. Everything the dashboard shows as a tab or a bar is a client-side rule over that list, and
an agent that wants to agree with the dashboard reproduces them (source of truth: src/lib/*.ts in
customer-intelligence):
accountFilter.ts): a brand is published when its stores together do
> 5,000 ordersLastMonth or > $500 mrr. Sum a parent and every row whose
parentShopName points at it, from the unfiltered list, before comparing. A row missing
ordersLastMonth is not published for it; only a response with the field absent everywhere falls
back to the MRR bar alone.customers.phase. Installed, not
paying (installedFree.ts): phase === "free" (legacy empty phase + free plan name still
works until every row is re-synced). The list itself is already restricted to
ownerType: "external".churn.ts): churnedAt within the last 180 days; the churnSnapshot on the
detail carries the health the account had when it left.renewals.ts): renewalDate within the next 90 days, soonest first;
only annual stores carry one.healthStage Red / Yellow (display as Amber) / Green; healthScoreChanges
on the detail lists the reasons.suggestedMrr − mrr — negative is a saving the store should be moved to.Handle with care. A write token is an admin credential for a year: keep it in the agent’s
environment or keychain, one token per agent so each can be revoked alone, and revoke it when the
agent is retired. Two writes are visible outside CIP — POST …/notes creates an Intercom note the
whole team sees, and PUT …/champion tags the Intercom contact — so an agent should confirm with
its operator before either. PUT …/health-flags changes the health score that drives the RAG
columns and the next-action rule.
The customer table calls:
GET /customers
GET /customers?search=&planGroup=&phase=&healthStage=
Supported query parameters:
search: free text search over shopName and domain. The domain is searched because a
Shopify handle is frequently not the brand — Reebok EU is qirixp-by, Sarah Raven is 50u1vf-1s —
and handle-only search left those stores unreachable from the list: measured 2026-09-08, 986 of
2,570 external customers had a brand name appearing nowhere in their shopName, 547 of them paying.
Combined with planGroup the two are composed under $and, so a plan-filtered search keeps both.planGroup: plan grouping selected in the UI.phase: customer phase — paying | trialing | uninstalled | free | test (synced on the
customer doc).healthStage: Red, Yellow, or Green on the current API. The frontend displays Amber, but still sends Yellow for the middle health band until the backend rename is complete.The response may be either:
{ data: [...] }[...]The frontend maps each raw record into AccountListItem. Every field below is load-bearing for the
list view — the column or KPI it drives is named alongside it:
shopName, _id, domain — row identity and labelplan — Plan column and the plan filterphase — Paying / Trialing / Uninstalled / Installed-not-paying tabs. Synced by
syncCustomersFromShops as paying | trialing | free | test | uninstalled
(services/customers/customerPhase.ts). Free-named plans (including Free + paying) and
Shop.payment.phase === "free" both land as free. Shopify test charges are test. The list is
restricted to ownerType: "external" — Littledata / staging stores stay available by direct
shop lookup only.
A store that has removed every app is uninstalled even when Shop.payment.churned is false.
A free store bills nothing, so the churn flag is frequently never set when it leaves: measured
2026-09-08, 31 free stores over 1,000 orders/30d had uninstalled every app while churned stayed
false, Reebok EU and Livingood Daily among them. Until Sept 2026 those stores were excluded from the
sync entirely; they are synced now, because the customer list is the source for winback and the
uninstalled view, and badged from the apps array rather than the flag. auditChecksCron excludes
churned: true and phase: "uninstalled", so no audit is attempted for them — before that guard,
106 of the 1,300 audit-eligible customers were churned or uninstalled and every one had been audited
within the week.
The badge alone is not enough to render the row. The Uninstalled tab shows only stores churned
within six months, and it reads churnedAt to decide — so a departed free store with the badge and
no date appeared on no tab at all: not Uninstalled (no date) and no longer Free (the badge moved
it). churnedAt is now stamped from the last app’s uninstalledAt when payment.churnedAt is
absent, which is always for a free store. churned stays false: they never paid, so no revenue
churned — the badge and the date carry the fact that they left.
mrr — MRR column and all four KPI MRR totals. Computed by syncCustomersCron
(services/customers/customerMrr.ts): base price plus per-order overage above the plan limit on
last month’s orders; 0 for a test charge. The frontend used to derive planPrice + overageFee,
which added the per-order fee (cents) to the base price and under-read every store paying
overage — Aubin & Wills billed $621.40 read as $199.15 and fell under the $500 account-managed bar.
Overage follows the plan’s graduated bands where it has them, the marginal model ld-shopify
bills on (computeOverageCharge): Scale includes 1,500 orders, charges $0.15 on positions
1,501-3,000 and $0.06 above, so 8,686 orders cost $765.16, not the $1,276.90 a flat $0.15 gives.
Bands come from the plan’s price-list entry (PricingPlanTier.overageTiers), are applied only
when the store is on the list rate — a bespoke or grandfathered overageFee keeps its flat rate —
and are stamped onto the customer as overageTiers so every plan-cost consumer prices alike.
Flex has no included orders and no bands: every order is billed at $0.35, so a Flex store doing
8,686 orders is a $3,040/month account, not the $0 a base-price-only reading gives.
suggestedPlan, suggestedMrr — the “At current pricing” column: the cheapest tier on today’s
price list at the store’s volume, and its monthly cost, each priced on its own bands.
suggestedMrr − mrr is the repricing headroom; negative is a saving, and the busiest Flex
stores are the clearest case (kaged-store pays $3,040 on Flex where Scale would bill $765). Sent
together, only when a quote exists.
Only tiers from the live snapshot are quoted. The retired Enterprise Plus tier used to be injected into the candidate set and undercuts Plus above ~43,700 orders, so every large account was quoted a plan that no longer exists and cannot be sold. An annual store is quoted its annual price (20% off, the discount Shopify applies to the subscription), so the figure matches the one a renewal conversation uses: Plus at 60,000 orders is $2,390 monthly, $1,912 annual.
planPrice, overageFee — the frontend’s fallback MRR when mrr is absent (a row the sync has
not touched since this shipped): planPrice + max(0, ordersLastMonth − orderLimit) × overageFee,
or planPrice alone when the limit is unknown. Never planPrice + overageFee. The fallback is
flat — it has no bands to read — so it over-reads a busy Scale store until the sync stamps mrr.healthStage — the Red / Amber / Green KPI split and the Health columnhealthScore — Score columnconnectedDestinationTypes — live ld-shopify destination Connection names for the Destinations
column, including destinations monitoring-ai does not audit (Segment, Pinterest, TikTok,
Attentive, Free GA4 and Google Ads Online). destinations[].type remains the compatibility
fallback and the source for audit destination details.parentShopName — parent grouping; without it every brand store renders as a top-level row and the list inflatesintercomCompanyId, shopifyStoreId — the per-row IC and SP linksshopId — the per-row AD link to the admin app (/stores/:id). This is the ld-shopify
Shop._id, synced onto the customer; the record’s own _id is a different document id and
404s there, so it cannot stand in.orderCount — Orders columntrialEndDate — Trial-days-remaining column. Sent only when phase === "trialing", the only
case that renders a countdown; ~70 KB of the payload was otherwise trial dates nothing displayed.intercomLastLoginAt — Intercom company last_request_at. Intercom sets it for very few companies
(60 of 2,491 customers), so it is only the fallback for the Last seen cell — see contactsLastSeenAt.contactsLastSeenAt — the Last seen cell. Latest lastSeenAt across the store’s mirrored
contacts, rolled up by syncIntercomCustomersCron. Sent when present.championNames — the Champion column (names of Champion-tagged contacts, from the same cron).
Sent only when non-empty; the frontend maps absence to “no champion”.championLeft — the “Champion left” chip on the row. Sent only when true (a handful of rows), so it
costs nothing on the wire otherwise; the frontend maps absence to false.payBy — the Payment column’s first half (shopify |
invoice |
stripe |
brandStore |
free). |
isAnnual — the Payment column’s second half (annual vs monthly). Sent only when true.renewalDate — the Renewal column and the Upcoming renewals tab (renewals within 90 days).
Computed server-side by services/customers/renewalDate.ts: the next anniversary of
lastChargeDate, else of payingSince (Shop.payment.phaseUpdatedAt while paying). Sent only
for annual stores — a 30-day Shopify subscription has no renewal worth preparing for.testPlan — Shop.payment.testPlan, a Shopify test charge. Sent only when true. The frontend
drops these rows from every list and MRR total; they are not revenue.churnHealthStage, churnHealthScore, churnDisconnectedConnections — the Uninstalled tab’s
“health at churn” column, from the customer’s churnSnapshot (written once by syncCustomersCron
the first run it sees the store churned). Uninstalled rows only. A store that churned Green is
the one worth a post-mortem; the live healthStage keeps moving after churn and cannot tell you.accountOwner — the Owner column and the owner filter: one of David, Cornelia, Cesar,
Edward (models/accountOwners.ts). Sent when set.churnedAt — the “Churned” column on the Uninstalled tab, and its six-month window. Sent only
when phase === "uninstalled": a reinstalled store keeps its old churnedAt on the document,
and on an active row that date would only mislead. syncCustomersCron keeps churned rows
refreshed for 180 days (CHURN_GRACE_DAYS), matching the tab’s window.Deliberately not on the list, because no list consumer reads them (127 KB combined across 2,486
customers). Load them from GET /customers/{shopName} instead: orderLimit,
suspectedParentShopName, healthScoreCalculatedAt. AccountListItem still declares
healthScoreCalculatedAt; on list rows it is always null.
Do not narrow the list projection without checking this list. PR #575 cut
GET /customersto five fields as a performance change, reasoning that “the table read 5 fields” — true of the admin UI table, not of the CIP dashboard. Because the frontend maps each missing field tonulland renders—, the dashboard lost its MRR column, health split and parent grouping with no error, no failed request, and a green test suite on both sides. Both repos now pin these fields:src/handlers/api/customers/getCustomers.test.tshere, andsrc/lib/__tests__/api.test.tsincustomer-intelligence. If you change one, change the other.
lastCheckedAt (audit-check recency) was removed from this endpoint by #575 and is not coming
back — its auditChecks aggregate was the single largest cost in the request. AccountListItem
still carries last_checked_at for the detail-panel shape; on list rows it is always null. The
dashboard’s subsidiary-store “Seen” line uses intercomLastLoginAt instead.
Measured against prod (2,486 customers): the full list response is ~703 KB. Every field above costs 30–105 KB on the wire, so add one only when a column or KPI actually consumes it, and prefer the detail endpoint for anything a single account view can load on demand.
The list endpoint may still omit planPrice for a customer with no plan price set upstream. When
that happens the frontend sets list-row mrr to null and expects detail loading to fill it in.
The account side panel calls:
GET /customers/:shopName
The response is expected to be:
{ data: { ...customer } }
The frontend maps this into AccountDetail, extending the list-row fields with detail-only fields. Important fields currently read from the API include:
planPriceoverageFeeorderLimitintercomLastLoginAthadRecentPositiveCallhealthScoreChangesvalueMetricsvalueMessagesopenSupportTicketSeverityhasCompetitorInstallcompetitorNamestrialEndDatechurned, churnedAttags under valueMessages.tagschampionLeft, championLeftNames — the champion-left warning in the panel’s Champion blockpayBy, isAnnual, payingSince, renewalDate, testPlan, championNames, contactsLastSeenAt
— the same fields as on the list (see above), so the customer page needs no second sourceMRR is mrr from the API (see the list section); the detail response carries the same mrr,
suggestedPlan and suggestedMrr fields.
The customer page’s owner picker calls:
PUT /customers/:shopName/account-owner
Request body { "accountOwner": "Cornelia" }, or { "accountOwner": null } to clear. The value
must be one of the CS team names in src/models/accountOwners.ts; anything else is a 400, so the
picker offers exactly that list. syncCustomersCron gives every paying or trialing account with no
owner the default (DEFAULT_ACCOUNT_OWNER, David) so nothing active reads as unassigned; churned
accounts keep whatever they had. Ownership is per store — set it on the parent and on any
subsidiary that a different CSM looks after. The detail response carries accountOwner and
accountOwnerSetAt; the list carries accountOwner.
The store’s open and snoozed rule-derived tasks move with the account on the same call
(services/customers/tasks/reassignTasks.ts), and the response says how many did
(tasksReassigned, null if that write failed — the owner still changed). The nightly sync would
get there on its own, since it re-stamps assignee from accountOwner every run, but the Tasks
page never triggers a sync, so a handover otherwise left the outgoing CSM reading someone else’s
list all day. Two kinds keep the name they have: a manual task was given to a person on purpose,
and a linkedin introduction belongs to whoever is actually connected.
The customer page’s health chart calls:
GET /customers/:shopName/health-history
Response { data: { shopName, history: [...], trialEndDate?, payingSince?, churnedAt?, currentScore?,
currentStage?, currentCalculatedAt? } }. Each history point, oldest first:
{
"recordedAt": "2026-08-10T03:15:00.000Z",
"score": 3,
"stage": "Green",
"changes": ["Champion linked (+3)"],
"phase": "paying",
"source": "cron"
}
A point is appended only when the score or its changes lines differ from the previous run
(services/customerHealth/healthScoreHistory.ts), plus the first score a customer ever gets, so the
series is a list of events, not of days. phase is trialing | paying | uninstalled as of the
point; the chart uses it together with trialEndDate / payingSince to mark the trial → paying
handover. stage is the backend’s Yellow, mapped to Amber by the frontend like every other stage.
The collection starts empty on deploy: the chart shows the first point after the next nightly run.
The customer page’s Company section calls:
GET /customers/:shopName/company
Response { data: { shopName, company } }, where company is null when no record matched — an
unenriched store is an ordinary answer, not a 404. The 404 is reserved for a shopName this repo has no
customer for.
{
"matchedBy": "shopId",
"matchedShopName": "rareteacompany",
"name": "Rare Tea Company",
"website": "rareteacompany.com",
"logoUrl": "https://…",
"overview": "Loose leaf tea, direct from the gardens.",
"industry": "Retail",
"categories": ["Food & Drink → Beverages → Coffee & Tea"],
"keywords": ["Tea", "Wholesale"],
"companyType": "brand",
"isB2b": true,
"isB2c": false,
"foundedYear": 2004,
"ownership": "Privately Held",
"headquarters": { "label": "London, England, United Kingdom", "city": "London", "state": "England", "country": "United Kingdom" },
"employees": { "estimated": 17, "band": "1-10 employees", "storeLeads": 13, "byDepartment": { "marketing": 4 } },
"revenue": { "band": "$1M-$10M", "estimatedYearlyUsd": 5999132, "estimatedMonthlyUsd": 499928, "totalFundingUsd": 0 },
"storefront": { "platform": "shopify", "platformPlan": "Shopify Plus", "platformRank": 6885, "platformRankPercentile": 0.1857, "estimatedMonthlyVisits": 96698, "trafficRank": 653091 },
"amazon": { "matchedBrand": "Rare Tea", "revenueTier": "present", "annualRevenueEstimateUsd": 0, "asinCount": 42, "avgRating": 4.6 },
"social": { "linkedinUrl": "https://www.linkedin.com/company/the-rare-tea-company", "linkedinFollowers": 1766 },
"syncedAt": { "storeLeads": "2026-08-16T07:03:29.844Z", "keepa": "2026-08-30T00:00:00.000Z", "littledata": "2026-09-01T00:00:00.000Z", "record": "2026-09-02T00:00:00.000Z" }
}
Where it comes from. The companies collection in the marketing-automations database — a
different database on the same Atlas cluster as monitoring-ai, so it shares this repo’s cached Mongo
client (DB_NAMES.marketing). Read-only from here: marketing-automations owns the schema and every
write. Firmographics are AmpleMarket’s, the storefront and revenue.estimated* figures are Store
Leads’, amazon is Keepa’s, and littledata.shopId is stamped by that service’s
syncLittledataCustomers cron. src/models/marketingCompanies.ts mirrors only the fields read here.
Matching. matchedBy says which key found the record, strongest first:
matchedBy |
Key |
|---|---|
shopId |
littledata.shopId = this repo’s Customer.shopId (String(Shop._id)) — exact, and the bulk of matches |
domain |
Customer.domain against website, including the scheme / www. variants historic rows hold |
myshopifyDomain |
<shopName>.myshopify.com against website, and against the stored littledata.myshopifyDomain field |
registrableDomain |
one subdomain label dropped from Customer.domain — us.dockers.com → dockers.com — for a country or checkout storefront filed under the brand’s own domain |
A subsidiary brand store rarely holds a record of its own, so a miss retries with the parent store’s
keys; matchedShopName names whichever store the record was found for, and differs from shopName
when the parent’s record was used.
registrableDomain is the weakest key and deliberately last: it can only ever reach the brand’s own
domain, but two storefronts of one brand now agree on a single record instead of neither matching. It
drops exactly one label and never yields a public suffix — acme.co.uk does not become co.uk, and a
*.myshopify.com host never collapses onto the shared platform domain.
Coverage. Measured 2026-09-08 across 1,919 live customers: 61.5% resolved to a company before these two keys, 65.7% after. The remaining ~660 are not a matching problem — marketing-automations holds no record for them at all, which is an enrichment question, not a join one.
Coverage, measured 2026-09-03 against the accounts CIP publishes (MRR > $500 or > 5,000
orders/month): 92% of stores matched a company (86% on shopId alone). Among those, industry 85%,
headquarters 84–87%, LinkedIn followers 87%, headcount 86%, revenue band 75%, Store Leads sales
estimates 87%, Keepa Amazon match 80%. So every field is optional and the section has to read well
with any subset — plan for holes rather than treating a blank as a bug.
Fields deliberately not exposed. No street address: the stored one is an unreliable
concatenation (“Coventry , Coventry, Coventry , CV6 4QG, United Kingdom”) that often disagrees with
the postcode on the same record. amazon is omitted entirely unless Keepa matched the brand — it
checks every company, so most records carry zeroed Amazon fields meaning “looked, found nothing”.
revenue (the numeric one) is set on ~1% of rows — the band and
the Store Leads estimates are the usable revenue signals. technologies / builtWithTechnologies /
competitors are long BuiltWith lists, and competitor installs already reach the dashboard through
hasCompetitorInstall on the customer detail. LinkedIn is the only social account the collection
holds: BuiltWith returns other handles but marketing-automations does not store them.
The contacts tab calls:
GET /customers/:shopName/contacts
The response may be either:
{ data: [...] }[...]Contacts with an @littledata.io email (Littledata staff attached to the customer’s Intercom company)
are excluded server-side and never returned.
Each contact is normalized into Contact. Important fields currently read from the API include:
intercomContactIdidnameemaillastSeenAt — the contact’s last Intercom Messenger session, and absent far more often than not.
Intercom only stamps it when the person is seen by the Messenger; the app creates most contact
records through the API on sign-up, so a mirrored contact can be a live user for years and never
get one. Read a missing lastSeenAt as “Intercom has never recorded a session”, never as “logged
in once when the record was created” — createdAt is the record’s birthday, not a login.lastRepliedAt — the last time the contact replied to us in an Intercom conversation (chat or
email), or absent. The other half of the same question: about a third of contacts have one, and for
a contact with no lastSeenAt it is the only evidence anyone is still there. It moves only on
their reply, so it is not affected by outbound campaigns — Intercom’s last_contacted_at is, and
is deliberately not mirrored.isChampioncreatedAtlinkedinUrl — LinkedIn profile URL, or null. Renders the “LinkedIn ↗” link (the second contact
channel after email). Sourced from an Intercom custom attribute, Bright Data discovery, or a CSM.linkedinUrlSource — "intercom" | "discovered" | "manual" | nulllinkedinConnections — Littledata people who are a 1st-degree LinkedIn connection of this
contact, by Expandi seat key (["EdwardUpton", "JazField"]); [] when nobody is, or until the
leads database has an observation. Renders the “LinkedIn Connection” column, so a CSM can ask for
an introduction instead of writing cold. Copied off the matching leads-database row by
syncCustomerLeadsCron — see the linkedinConnections note on the leads list below.jobTitle — as observed on LinkedIn, or the title marketing-automations’ leads database holds
when LinkedIn has not been read yet, or nullrole — "user" (has logged in to the app) or "lead" (pushed to Intercom by
marketing-automations, never logged in); absent when Intercom did not saypersona — buyer persona, marketing-automations’ classification of the job title: Director,
Manager, Founder, Tech, Email, Finance, Other, AgencyDirector, AgencyFounder,
AgencyOther; null until known. Renders the persona chip beside the name.personaSource — "leads_db" (copied from the marketing-automations lead with the same email by
syncCustomerLeadsCron) or "intercom" (the Persona custom attribute the retired Intercom
lead push wrote; adopted by the Intercom mirror), or nullemployerCheckedAt — when the LinkedIn employer check last ran; absent until it hasroleChange — null, or { kind: "left_company" | "changed_role", detectedAt, previousTitle,
observedTitle, observedCompany }. Renders the red “Left company” / amber “Changed role” badge. A
left_company champion is what the health score’s “Champion left company (−4)” line reads.agency — null, or { id, name, website, matchedBy }: the agency this person works for, when
their email domain resolved to one of marketing-automations’ agency companies (see Agencies
below). id is that company’s id and is what /agencies/:agencyId takes. Renders the “Agency”
chip beside the name, linking to the agency page.These come from checkContactEmployersCron, which runs the contacts through marketing-automations’
employer-check API. Only contacts of account-managed customers are checked: more than 5,000 orders in
the last 30 days (ordersLastMonth) and linked to an Intercom company — the order half of the bar
this dashboard publishes on, regardless of plan. Churned customers are included on the same bar (their
order figure is frozen at uninstall), so the uninstalled tab’s contacts get URLs too. The API answers from marketing-automations’
own leads database when it already tracks the person (matched by email or LinkedIn URL) and only buys
a Bright Data LinkedIn record otherwise. Leads-database answers are applied in the same cron run;
Bright Data answers land on a later run. Checks are refreshed every 90 days per contact (verify) and
discovery of a missing URL is retried every 180 days, so a freshly added contact can sit with
linkedinUrl: null for up to a day.
persona, a blank jobTitle, a missing linkedinUrl and a left_company role change can also
arrive from marketing-automations’ leads database: syncCustomerLeadsCron (below) copies them onto
the mirrored contact with the same email. A departure from that source only sets roleChange when
no fresher LinkedIn verify pass has seen the person still at the customer.
GET /customers/:shopName/leads
(The path is /leads, not /people: a /people API Gateway resource was created by an earlier
deploy inside the CORS-preflight nested stack and cannot move, and a GET on it from the customers
router made the two stacks depend on each other. See the comment beside the route in serverless.yml.)
Everyone marketing-automations’ leads database holds against the store — the dashboard’s second
contact list, shown beside (not merged into) the Intercom app users above. Synced nightly at 02:15
UTC by syncCustomerLeadsCron through marketing-automations’ POST /people/by-company, matching the
store by myshopify slug, Intercom company id and storefront domain; stored in customerLeads
(models/customerLeads.ts). Until September 2026 these people reached the dashboard by being pushed
into Intercom as lead contacts and mirrored back out; that hop dropped leads with no email, Finance
personas and companies the push never flagged as customers, so the dashboard reads the source now
and every persona is returned, Finance included. Littledata staff are excluded.
Each row:
leadId — marketing-automations Lead._id; the row key with shopNameauditInviteUrl — the person’s personalized audit link, ${FRONTEND_URL}/welcome/{leadId}.{signature}
(see AGENTS.md → “Personalized prospect audit links”), or null when the lead id is not an ObjectId.
Opening it signs them in with no Google login; the dashboard offers it as Invite to audit so a
CSM can send a colleague at a current customer to see what connecting their channel would add. A
staff session opening it gets a preview and no counted visit.name, email (may be null — a person known only by name and LinkedIn is still listed), titlepersona — Director, Manager, Founder, Tech, Email, Finance, Other, AgencyDirector,
AgencyFounder, AgencyOther, or nulllinkedinUrl — profile URL or nulllinkedinConnections — Expandi seat keys of the Littledata people who are a 1st-degree
LinkedIn connection of this person (["EdwardUpton"]), or []. marketing-automations keeps one
Lead.linkedinConnections row per seat owner — fed by the Expandi outbound webhook and each
owner’s LinkedIn Connections.csv export — and /people/by-company sends only the rows at
distance: 1: a 2nd-degree or merely-observed row is not someone who can introduce us. Supersedes
the leads database’s old unattributed connected_on_linkedin boolean.departed — marketing-automations’ own employer check saw them leave; with departedAt and
departedObservation: { companyName, title } (where they went)status — the lead’s lifecycle status there (discovered / enriching / active / departed…)company: { id, name, website, matchedBy } — the marketing-automations company row and which key
matched it to this store (shopDomain — also a subsidiary store listed on its parent brand’s
company | intercomCompanyId | websiteDomain | rootDomain — a storefront subdomain matched by
its registrable domain)intercomContactId — set when the same email is a mirrored app user of this store. The dashboard
lists that person once, under app users, where the sync has already copied the persona and title.isChampion — a CSM has tagged this person as a champion of ours, through
PUT /customers/:shopName/leads/:leadId/champion below. Not written by the sync.leadUpdatedAt, syncedAtThe list is per store; the dashboard rolls a brand’s stores up and dedupes on email, as it does for contacts. Rows are replaced on every sync (a lead no longer returned for the store is removed), but a failed request leaves the previous rows in place.
Champion status is changed with:
PUT /customers/:shopName/contacts/:contactId/champion
Request body:
{
"isChampion": true
}
The frontend treats the response as a passthrough and refreshes contacts after the mutation.
A brand’s champion is regularly someone who has never logged in — the director who signed us off rather than the analyst who reads the dashboards — so there is no Intercom contact to carry the tag. Those people are tagged on their leads row instead:
PUT /customers/:shopName/leads/:leadId/champion
Request body and response shape match the contact call ({ "isChampion": true }). Nothing is written
to Intercom: the flag is isChampion on the customerLeads row, returned by
GET /customers/:shopName/leads above. 404 when the store holds no such lead.
Three things follow from the collection being keyed per store ({ shopName, leadId }):
syncCustomerLeadsCron does not write isChampion (see models/customerLeads.ts), so a tag
survives the nightly re-sync. A lead the database stops returning is deleted outright and the tag
goes with the person, which is the intended behaviour.championNames on GET /customers and GET /customers/:shopName is the union of both kinds —
services/customers/leadChampions.ts reads the lead-tagged names in one query for the whole list —
so the dashboard’s Champion column and its champion_present / champion_name fields count a
non-user champion. So does the journey:identify-champion task rule.
Not (yet) included: the health score’s “Champion linked (+3)” line, which reads the Intercom
company attribute champion / is_champion (services/customerHealth/inputBuilder.ts), not
either tag. Tagging a champion of either kind has never moved that line; wiring the two together
would change scores across the whole base and is deliberately left as its own decision.
A contact’s LinkedIn URL is pinned (or cleared) with:
PUT /customers/:shopName/contacts/:contactId/linkedin
Request body:
{
"linkedinUrl": "https://www.linkedin.com/in/jane-doe/"
}
linkedinUrl must be a linkedin.com/in/… URL, or null to clear. A hand-set URL is never
overwritten by discovery, and resets the employer check so the next cron run verifies the person
against the customer. The frontend treats the response as a passthrough and refreshes contacts.
Which agencies work with which customers. The evidence is the Intercom mirror: an agency’s staff log
in to the Littledata app on the brands they manage, so a brand’s Intercom company carries contacts
with the agency’s email domain. Nightly at 02:45 UTC, syncCustomerAgenciesCron puts every distinct
contact email domain to marketing-automations’ POST /companies/agencies-by-email-domain, which
resolves it to a company classified companyType: "agency" — through the company’s website, or
through the addresses of the leads it holds at the agency, because an agency’s mail domain is often
not its website (Power Digital’s people mail from powerdigitalmarketinginc.com; the company row is
powerdigital.com). Personal webmail, brands’ own domains and unknown domains resolve to nothing.
The result is one customerAgencies row per { shopName, agencyId } (models/customerAgencies.ts)
holding the contacts that proved the link, plus the agency field on those contacts. Rows are
replaced on every clean run; a failed lookup leaves everything as it was.
agencyId is marketing-automations’ Company._id. matchedBy is "website" (the domain is the
agency’s website) or "leadEmail" (the domain is one the agency’s leads mail from).
GET /customers/:shopName/agencies
{ data: [...] }, most recently seen first. Each row:
agencyId, name, website, matchedByemailDomains — the contact email domains that resolved to the agency at this storelastSeenAt — the latest login among the contacts below; when the agency was last in the storecontacts: [{ intercomContactId, email, name, lastSeenAt }] — who at the agency has logged inotherStores: [{ shopName, lastSeenAt, contactCount }] — every other store the same agency is
linked to, so the customer page can list the agency’s other clients without a second call. The
dashboard rolls these up to brands and ranks them by account size from the customer list it holds.syncedAtPer store, like contacts: the dashboard rolls a brand’s stores up and merges on agencyId.
GET /agencies
{ data: [...] }, one row per agency, most stores first:
agencyId, name, website, matchedByheadquarters (marketing-automations’ rendered location line), logoUrl, industry,
employees, employeeBand — from the company record, so the table can say where an agency is
based; each null when the record lacks itstoreCount, contactCount, lastSeenAtstores: [{ shopName, lastSeenAt, contactCount }] — the dashboard counts brands from theseGET /agencies/:agencyId
{ data: { agencyId, name, website, profile, stores } }. profile is the company’s firmographic
record in the same shape as Company profile above minus matchedBy / matchedShopName (there is
no store it was matched for), or null when the record has gone. stores is every linked store,
most recently seen first: { shopName, matchedBy, emailDomains, lastSeenAt, contacts, syncedAt }.
400 for an id that is not a company id; 404 when no store is linked to it and no company record exists.
The agency page shows the people at the agency the same way a customer page shows the people at the brand: two lists, the app users first and everyone else the leads database knows second.
GET /agencies/:agencyId/contacts
{ data: [...] }, most recently seen first — every mirrored Intercom contact whose email domain
resolved to this agency (Contact.agency), across every customer they have logged in to. Exactly the
shape of GET /customers/:shopName/contacts, one wire mapper shared between the two handlers, so the
same person cannot read differently on the two pages; shopName on each row is the customer that
contact belongs to. A person working on three of our brands has three contact records and appears
three times — the dashboard merges them on intercomContactId and lists the clients. Littledata
staff are excluded. An unknown agency answers []: GET /agencies/:agencyId is what decides whether
the agency exists. 400 for an id that is not a company id.
GET /agencies/:agencyId/people
{ data: [...] } — everyone marketing-automations’ leads database holds at the agency, in the shape
of GET /customers/:shopName/leads minus shopName and syncedAt: { leadId, email, name, persona,
title, linkedinUrl, linkedinConnections, departed, departedAt, departedObservation, status, company,
intercomContactId, leadUpdatedAt }. company.matchedBy is "companyId".
Read live from POST /people/by-company with the agency’s own companyId (marketing-automations
#575) — not from a synced collection. customerLeads is synced nightly because it is read for every
customer on every dashboard load; an agency page is opened a handful of times a week, and whoever
opens it wants the newest addresses, since the usual reason to open it is that the contact we had has
left. intercomContactId is set when the same email is a mirrored app user at one of our customers,
so the dashboard lists that person once, under contacts. A marketing-automations outage answers 500
rather than an empty list, which would read as “nobody works there”. An agency marketing-automations
holds no company row for answers []. 400 for an id that is not a company id.
The customer page’s to-do list, and the dashboard’s top-level Tasks page. It replaces the single “Next action” the page used to derive client-side (one rule won, one label) and the separate renewal checklist: every rule that applies now produces its own task, a CSM ticks tasks off, and the list is stored so the ticks survive and the whole team’s open work can be read in one place.
Rules live in services/customers/tasks/customerTaskRules.ts and are pure; the sync in
syncCustomerTasks.ts reconciles their output with the customerTasks collection
(models/customerTasks.ts). It runs nightly at 03:45 UTC (syncCustomerTasksCron, after the leads,
agencies and health-score crons) for every external, non-test customer — uninstalled ones included —
and again for one brand on every GET /customers/:shopName/tasks, so a task drops off within a page
load of the fact behind it changing.
Which brands, and how many. The rules fire on every fact they can see, and left alone they put
6,800 open tasks in front of a team of four — 83% of them on self-serve stores the dashboard does not
publish. Two limits sit between the rules and the collection (selectBrandTasks.ts), both judged on
the brand — a parent and its subsidiary stores, the way the dashboard’s account-size bar is:
churnSnapshot figures). Same rule and figures as CIP src/lib/accountFilter.ts — a task never
points at a customer the list does not show. A brand under the bar derives nothing and the sync
resolves whatever it had open. It is most of the collection, so the nightly run also skips loading
the rule input for it.MAX_OPEN_TASKS_PER_BRAND (4) rule-derived tasks are open on a brand at once
— the next best, by the rules’ own PRIORITY tier (renewal, close the trial, champion left, Red,
competitor, onboarding, Monitor AI error, Amber, win-back, reprice, connections in
CONNECTION_RULES order, upsell, re-engage, convert, welcome, LinkedIn, identify a champion, churn
reason), then urgency, then an already open row before a newcomer, then the bigger store. The rest
are held back: not inserted, and resolved if a row was open. A tick, a dismiss or a fact going away
frees a slot and the next task takes it on the following sync. done and dismissed rows never
take a slot, nor does a snooze that is still running; a snooze that has run out competes like any
other task. Manual tasks are outside the cap. The dashboard still orders across brands on money
(taskPriority.ts); the tier only decides which of a brand’s own tasks are shown.Reconciliation:
open;open, done or dismissed keeps that status and gets fresh copy;resolved reopens;snoozed reopens once snoozedUntil has passed — “not now” is not
“never” — and only has its copy refreshed until then;open or snoozed rule-derived row the rules no longer produce — or that the brand’s cap held
back this time — is marked resolved (the fact has gone, or something bigger is ahead of it);done and dismissed rows are never reopened by the sync; manual rows are never touched. So a
task ticked off while its fact persists (no champion tagged, say) stays off the list for good;
snooze is the way to make it come back.A task’s key names the fact: renewal:<YYYY-MM-DD> (next year’s renewal is a new task),
connection:<ldShopifyConnectionName>, audit:<auditCheckId>,
contact:welcome:<intercomContactId>, linkedin:contact:<intercomContactId> /
linkedin:lead:<leadId>, journey:<rule>, manual:<taskId>. { shopName, key } is unique.
What the rules produce (kind → key):
journey:new-champion,
journey:competitor, journey:rescue (Red) or journey:amber-check-in (Yellow/Amber),
journey:identify-champion, journey:re-engage (no login in 30 days), journey:reprice
(“Move to renewal:<date> inside T-90 (urgency: "now" inside T-30), dueAt the renewal
date, and the four prep lines (book the QBR with the champion, pull the value story, reprice
against today’s list — with the headroom or saving spelled out — log the outcome) in steps.Company.builtWithTechnologies from marketing-automations (the brand’s record, the
parent’s when a subsidiary has none) against the store’s connectedDestinationTypes /
shopifyConfigurationConnections. Klaviyo, Attentive, Recharge (reCharge, a Shopify
configuration connection), Meta, Google Ads, Pinterest, TikTok, Segment, Microsoft Ads,
Google Analytics — see CONNECTION_RULES. Skipped entirely until the customer sync has stamped
connectedDestinationTypes (unknown is not empty) and for uninstalled stores.audit:<_id>, now during a trial, else
soon). Scoped and filtered exactly as GET /customers/:shopName/audit-checks, so the tasks match
the page’s “Monitor AI errors”. Warnings get no task (the audit:warnings roll-up fired on most
stores and was never acted on); the page’s Health score section still lists them.contact:welcome:<id>: an app user (not a lead) with an important persona
(IMPORTANT_PERSONAS: Founder, Director, Manager, Email, Tech) whose Intercom record is under
30 days old. Littledata staff, agency staff and people who have left are skipped.linkedinConnections, see Contacts) who is not using the app —
a contact with no login in 30 days (or never), or a lead Intercom has never seen. assignee is
the connected seat owner rather than the account owner — under their ACCOUNT_OWNERS first name
(assigneeNameForSeat: DavidRaresPascu → David), so one person is one assignee. Fires for active stores and for six
months after churn.GET /customers/:shopName/tasks
Admin only. Re-runs the rules for this store’s whole brand first (a failure there is logged and the
stored rows are served; refreshed: false says so), then returns { data: { shopName, refreshed, tasks: [...] } } —
every row except resolved ones, most urgent first with done / dismissed rows after the open ones.
Each task:
id, shopName, key, kind, title, reason, steps: string[], urgency (now |
soon | routine), section (overview | health | contacts | people | stores |
notes | null — where on the page the task points), assignee (a Littledata first name — an
ACCOUNT_OWNERS value for anyone on the CS team — or a spelled-out seat owner, or null), dueAtperson — for contact and LinkedIn tasks: { name, email, persona, intercomContactId, leadId,
linkedinUrl, linkedinConnections }; null otherwisestatus (open | snoozed | done | dismissed), firstSeenAt, lastSeenAt, completedAt,
completedBy: { userId, name, email } | null, dismissedAt, snoozedUntil, snoozedBy,
createdBy (manual tasks), createdAt, updatedAtPer store, like contacts and notes: the dashboard rolls a brand’s stores up.
PUT /customers/:shopName/tasks/:taskId
Body { status: "open" | "snoozed" | "done" | "dismissed" }. done and dismissed stamp when and
who from the session; snoozed sets snoozedUntil 30 days out (SNOOZE_DAYS) and the task leaves
both lists until then; open clears every stamp. resolved is refused (400) — it belongs to the sync. 404 when the
id is not a task on that store. Returns { data: { task } }.
POST /customers/:shopName/tasks
Body { title, reason?, urgency?, assignee? } — a task a CSM adds by hand (kind manual, default
soon). Never rewritten or resolved by the sync. assignee is the colleague who is going to do it:
omit it and the task falls to the account owner, pass null to leave it on nobody, and pass one of
the ACCOUNT_OWNERS names to hand it over (anything else is a 400 — a free-text assignee would fork
one person into three and break the Tasks page’s own filter). A manual task keeps that name when the
account changes hands. Returns { data: { task } }.
GET /tasks
Every open task across every customer, plus the ones ticked off in the last 7 days
(recentlyDoneDays), in the same order as above: { data: { tasks, recentlyDoneDays } }.
?assignee=<name> narrows to one person (assignee=unassigned for tasks with nobody on them).
Rows carry the task and its shopName only — the dashboard joins owner, health and MRR from the
customer list it already holds.
Indexes: npx tsx scripts/ensure-audit-and-customer-indexes.ts creates the unique
{ shopName, key } index the sync upserts on and the { status, urgency, lastSeenAt } index the
list reads by.
The notes tab calls:
GET /customers/:shopName/notes
The response may be either:
{ data: [...] }[...]Each note is expected to match CustomerNote:
{
"id": "note-id",
"body": "Note body",
"created_at": "2026-01-01T00:00:00.000Z",
"author": { "id": "user-id", "name": "Name", "email": "name@example.com" },
"contact": { "id": "contact-id", "name": "Name", "email": "name@example.com" }
}
New notes are created with:
POST /customers/:shopName/notes
Request body:
{
"body": "Note body",
"sentiment": "positive"
}
sentiment is optional and must be "positive" or "negative" when present — the handler writes it
into the Intercom note body (a 🟢 / 🔴 header alongside the CIP user’s name) and onto the
customerNoteAnnotations row. It is independent of PUT /customers/:shopName/health-flags, which is
what moves the health score.
The note is attached to that shop’s most recently seen mirrored contact, so a shop with no
mirrored contacts answers 404 No mirrored contacts for this shop — CIP has to post to a store
that owns contacts, not to whichever store’s page the CSM has open (a brand’s contacts are rolled up
across every store under the parent).
A note Intercom did not return an id for answers 502 Intercom did not create the note — it was
not saved. Everything downstream is keyed on that id, so nothing is annotated or written through
to the cache and the note would never come back from GET; this used to answer 200, which told CIP
a note had been filed when none had.
The frontend refreshes notes after the mutation, and reads data.note.id to confirm the note exists.
A note added in error is removed with:
DELETE /customers/:shopName/notes/:noteId
No request body. Returns { data: { ok: true, noteId } }, or 404 when no note with that id belongs
to the shop. The frontend drops the row from its cache and refreshes notes.
This is a CIP-side tombstone, not an Intercom delete. Intercom’s REST API offers list, create
and retrieve for contact notes but no delete, so the note remains on the Intercom contact and the
syncIntercomCustomersCron mirror keeps re-fetching it. The handler sets deletedAt (plus
who deleted it) on the customerNotes cache row; the mirror’s upsert only $sets the Intercom
fields, so the tombstone survives it and GET /customers/:shopName/notes filters the note out for
good. Hard-deleting the cache row instead would have the note reappear after the next cron run.
When adding a note, the UI can optionally mark the recent call sentiment. Positive and negative call sentiment updates this endpoint:
PUT /customers/:shopName/health-flags
Request body:
{
"hadRecentPositiveCall": true
}
The frontend treats the response as a passthrough.
Audit checks are available through the API client, even if the UI is not fully wired to show them everywhere:
GET /customers/:shopName/audit-checks
Expected response:
{ data: { auditChecks: [...] } }
Each audit check is normalized into AuditCheckRow. Important fields currently read from the API include:
_idcheckdisplayNamestatus — integer AuditStatus: -1 UNKNOWN, 0 SUCCESS, 1 INFO, 2 WARNING,
3 ERROR. The CIP client maps with toNum and must not remapping these values; Monitor errors
are status >= 3, warnings status === 2.destinationentityIdeventNameupdatedAtThe frontend intentionally normalizes backend responses before components consume them.
Yellow and Amber health values both display as Amber.mrr falls back to planPrice plus overage above orderLimit; missing planPrice
too produces mrr: null.phase stays empty on an unfiltered list read (do not invent paying); a phase-filtered
request may fill from the filter. Synced badges are paying | trialing | free | test |
uninstalled.connectedDestinationTypes are converted into the Destinations display string; destinations
remain the fallback for records written before that inventory was synced and supply activation
booleans.shopifyConfigurationConnections: live plugin Connection names
(Shopify Markets, Littledata Pixel, Recharge, Profit signal and Custom data sources). The
universal shopify source is intentionally excluded. CIP displays these in “Shopify
configuration & audit”.intercomCompanyId and the public Intercom app id.These rules are part of the frontend contract. Components should consume the normalized types from src/lib/types.ts rather than depending directly on raw API records.
These changes should not require backend API response changes:
src/components while preserving the same hook and API calls.src/lib/hooks while preserving the same api method arguments and return types.src/lib/derive.ts, such as formatting, relative dates, or derived labels.NEXT_PUBLIC_* environment variables.Coordinate with the backend before making these changes:
{ data: [...] } or raw array list responses before the backend contract is confirmed.Yellow to Amber compatibility behavior before the backend rename is deployed everywhere.Before changing API-facing code:
src/lib/api.ts for the current endpoint and mapping behavior.src/lib/types.ts for the normalized type components should consume.src/lib/hooks instead of adding component-level fetch calls.src/lib/__tests__ or src/lib/hooks/__tests__ when changing request or mapping behavior.