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.One Google account holds up to ten live sessions at once (services.sessions on the user document,
src/services/auth/sessionManager.ts), so CIP, the monitoring dashboard and the prospect portal can
all be open on the same login, on a laptop and a phone. Signing in to a second app no longer
invalidates the first one’s token — it used to, because a session was a single services.session
object that each new login overwrote, and the older app then 401’d and logged itself out. A token
still expires 30 days after it was issued, and expired ones are dropped as they are met.
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 |
POST |
/customers/{shopName}/leads/{leadId}/phone |
write | No body — asks marketing-automations to find the lead’s phone number; call again while pending |
POST |
/customers/{shopName}/leads/{leadId}/email |
write | No body — asks marketing-automations to find the lead’s work email; call again while pending |
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.features — the Features column: ids of the Littledata features the store has adopted, from
services/customers/customerFeatures.ts. Today only eventEditor (a custom or customised standard
event exists, per ld-bi.ShopFeatureAdoption_fact, mirrored nightly by syncCustomersCron).
Sent only when non-empty; an absent field means no features.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 sourcefeatures — always sent on detail ([] when none), same ids as the list.eventEditor — the numbers behind the eventEditor feature, sent whenever the store has a fact row
(adopted or not): adopted, customEvents, customizedStandardEvents, draftEvents,
destinations (ld-shopify connection slugs), and firstCreatedAt / lastUpdatedAt when known.MRR 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. Scoring is a half-hourly tick, not a nightly run: each
tick of updateCustomerHealthScoreCron scores the customers that are due (not scored in the last 20
hours, RECENTLY_SCORED_MS), stalest first, and stops a minute before its Lambda timeout for the
next tick to carry on, so every customer is rescored roughly once a day at a time that drifts a
little earlier each day. Retries are capped at zero and concurrency at one (raw CFN in
serverless.yml). Before Sept 2026 the single nightly run timed out every night, its two default
retries re-ran it from the top, the tail of the base was never scored, and where an audit row landed
between two runs the same customer got two cron points 33 minutes apart and the chart flipped nightly.
The audit rows that landed between runs were all of one kind: checks whose Linear issue the team had
closed as Canceled, which syncLinearState auto-resolves at 08:00 UTC every day and the night’s audit
re-raises. The health score’s “MonitorAI error / warning” and “Competitor install detected” lines now
read only checks whose Linear issue is live or absent (auditSignals.ts#openCheckFilter), so a
dismissed finding no longer moves the score whichever side of 08:00 it is read. The retry points
already written are removed by scripts/cleanup-health-history-retry-points.ts (dry run by default;
keeps the last point of a night and collapses exact repeats). 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:
intercomContactIdidnameemail — the address the person logs in to the app with. Stays the identity key (dedupe, rollup).alternateEmail — the same person’s other address, or null: the email of a leads-database row
syncCustomerLeadsCron joined to this contact on an alias rather than the same email — another of
the brand’s domains (daniele@vessi.com beside daniele@vessifootwear.com) or a mailbox made for a
tool (shaun@vessi.com beside shaun_shopify@vessi.com). Render it under email.preferredEmail — the address to write to: alternateEmail when email carries a tool suffix
(_shopify, .shop, -admin, …), otherwise email. Use it for mailto links and copy buttons.lastSeenAt — 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.leadId, phoneNumber, phoneRequest — the leads row linked to this contact (it carries the
contact’s intercomContactId), with that lead’s phone number and lookup state; leadId: null when
the leads database holds nobody for the contact. Customer contacts only, not the agency list. See
“Phone numbers” below.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.
unconfirmedLinkedin — { linkedinUrl, observedCompany, observedTitle }, present only when
discovery found exactly one profile carrying this person’s name at a different employer. That is
what someone who has already left looks like — but it is equally what a stranger sharing their
name looks like, and a LinkedIn search row (name, subtitle, location) cannot tell them apart. So it
is never adopted as linkedinUrl and never becomes a roleChange. Render it as a prompt — “is this
them?” with the profile and the employer it names — and on confirmation PUT the URL to the
endpoint below, which pins it as manual and sends the person through the next verify pass for a
real verdict. Discovery cannot resolve a leaver any other way: the search is by name and the
customer is only applied to the answers, so anyone who has moved on matches nothing.Which stamp a verdict lands on is the job’s mode, not its outcome: employerCheck records a verify
pass against a URL we already hold, linkedinLookup records an attempt to find one. Until #786 a
discover job that found nobody — the usual answer, about 95% of that queue — wrote employerCheck
instead, while the discover queue reads linkedinLookup.checkedAt. Those contacts never left the
queue, and a missing checkedAt sorts first, so they refilled the front of every batch: with 139 of
them ahead of a 100-deep batch the queue stopped advancing and the same profiles were bought from
Bright Data four times a day. Note what this means for departures generally: a contact with no
LinkedIn URL can never be found to have left — only 17% of contacts at account-managed customers
have one, including 30 of the 43 champions the health score depends on.
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 this lead is a mirrored app user of this store: the same email, or
the same person under an alias (another brand domain, a _shopify-style tool mailbox, a changed
surname — see alternateEmail above). The dashboard lists that person once, under app users,
where the sync has already copied the persona, title and, for an alias, the lead’s address.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.phoneNumber, phoneRequest — the lead’s phone number and where a lookup for it stands
({ status: pending | found | not_found, requestedAt, resolvedAt }, or null when none was asked
for). See “Phone numbers” below.emailRequest — where a work-email lookup for the lead stands, same shape; the address itself is
email. See “Work emails” below.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.
The customer page’s “Get phone number” link asks marketing-automations to find a lead’s number
through FullEnrich (its POST /people/phone, no lead-score gate):
POST /customers/:shopName/leads/:leadId/phone
No body. Answer:
{
"leadId": "64b1f0c2a9e3d45f6a7b8c9d",
"shopName": "acme-coffee.myshopify.com",
"status": "pending",
"phoneNumber": null,
"requestedAt": "2026-10-05T10:00:00.000Z",
"resolvedAt": null
}
status is found (with phoneNumber), pending, not_found or insufficient_data (no name, or
neither a LinkedIn URL nor a company website — nothing was submitted, nothing is recorded).pending. Call again
to read the result: each call reads FullEnrich back once. A number already held is returned
without buying another; a miss is final and is never re-bought.customerLeads row for the lead (phoneNumber, phoneRequest),
so GET …/leads and GET …/contacts show it straight away. The nightly sync also carries both
fields from /people/by-company.error.Only leads are looked up. An Intercom contact gets the link through the lead row that carries its
intercomContactId: GET /customers/:shopName/contacts sends that row’s leadId, phoneNumber and
phoneRequest on each contact (leadId: null when there is none, and then there is no lookup).
The “Get email” link, offered where a lead has no address, is the same arrangement as phone numbers
against marketing-automations’ POST /people/email:
POST /customers/:shopName/leads/:leadId/email
No body. Answer: { leadId, shopName, status, email, requestedAt, resolvedAt }, with the same
statuses and the same 404 / 503 rules as the phone call. A found address is written as the rows’
email (lower-cased, as the sync stores it) with emailRequest; a pending or missed lookup records
only emailRequest and never clears an address a row holds. not_found also covers a find
marketing-automations refused (another lead holds the address, another company’s domain, the address
that bounced). marketing-automations keeps a customer’s lead parked when it writes the address, so
the lookup never enrols the person in outbound. Contacts carry emailRequest from their linked lead
row too, beside leadId.
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.
Two tiers: recommendations and to-do lists. A rule-derived task is the system’s suggestion —
worth scanning, not yet anybody’s work. It becomes work when a person accepts it
(PUT …/tasks/:id with { accepted: true }): the row is stamped acceptedAt / acceptedBy, its
assignee points at whoever took it on, and from then on it is theirs — the sync refreshes its copy
but never re-stamps the assignee, a change of account owner leaves it alone (reassignTasks.ts),
and it no longer takes one of the brand’s four recommendation slots, so the next suggestion surfaces
behind it. A manual task is on somebody’s list the moment it is typed in. The dashboard reads the
two apart: its Tasks page shows the to-do list (manual + accepted) by default and the
recommendations behind a chip, and the customer page draws the recommendations under the to-do list.
Accepting is the action on a recommendation; ticking it off is the action on a to-do.
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, and so is an accepted one — it stopped being a
suggestion when somebody took it on, so it stays on their list whatever else the rules find. 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.assignee is left
alone. It is still resolved when the rules stop producing it — the connection went live, the
check passed — because the point of a list that resolves itself is that nobody ticks off work the
data already shows.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), dueAtacceptedAt, acceptedBy: { userId, name, email } | null — set once somebody has moved the task
onto a to-do list; both null on a recommendation nobody has taken on, and on a manual task,
which is on a list by definitionperson — 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 is either { status: "open" | "snoozed" | "done" | "dismissed" } or
{ accepted: boolean, assignee? } — both at once is a 400.
status: 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.
accepted: true moves a recommendation onto a to-do list: it stamps acceptedAt / acceptedBy from
the session, reopens the row (clearing every other stamp, so a snoozed or resolved suggestion comes
back as work) and sets assignee. That is the caller by default — their session name folded to a CS
first name the way seat ids are (assigneeNameForSeat) — or the assignee in the body: one of the
ACCOUNT_OWNERS names, or null for nobody. Anything else is a 400, and assignee without
accepted: true is a 400 too. accepted: false puts the task back on the recommendation pile,
leaving its status and assignee where they are.
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 customer page’s Linear issues section — every Linear issue tied to the store, open and
resolved — read live from Linear on each call. An issue’s state moves by the hour and a nightly
mirror would show a ticket as open the day after it closed, so nothing is stored; one GraphQL request
per store, and the dashboard rolls a brand’s stores up and folds rows on id.
GET /customers/:shopName/linear-issues
{ data: { configured, customerId, issues } }:
configured — false when monitoring-ai has no LINEAR_API_KEY. The list is then empty for a
different reason than “no issues”, and the dashboard says so.customerId — Linear’s customer id for the store, or null. A store is matched to Linear’s
Customers by domain: the store’s .myshopify.com host (acme and acme.myshopify.com both
resolve to acme.myshopify.com), the same rule postToLinear uses to attach Monitor AI’s issues.issues — most recently updated first, open and completed alike (the row’s state says which; a
ticket that just closed is as much news as one that just opened). Cancelled issues are left
out: a ticket that was dropped, not resolved — most are Monitor AI audit tickets triaged as
false positives, which the app already lists. Each row:
id, identifier (CST-412), title, url — the link opens the issue in Linearstate: { name, type, color } — Linear’s workflow state; type is Linear’s category
(triage / backlog / unstarted / started / completed; canceled never reaches the
wire). completed is closed; everything else is open.team (key), assignee (name), priority (0 none – 4 low), priorityLabelcreatedAt, updatedAt, completedAt, canceledAtlinkedBy: ("customer" | "title")[] — how the issue was tied to the store, see belowAn issue is related to a store two ways, and a row can carry both:
customer — the issue has a customer need against Linear’s customer record for the store’s
domain. Any team, any title: this is the link to use when filing a ticket by hand.title — the issue is in the CST team and its title contains the shop name, which is how
postChangesToLinear titles Monitor AI’s issues (MonitorAI: <shop>: …) and how
findOpenAuditIssueForShop finds them. Restricted to CST so a short shop name does not sweep up
unrelated tickets, and the shop has to stand on its own in the title (titleNamesShop): Linear’s
filter is a substring match, so kosas would otherwise bring back every kosas-cosmetics issue —
a different store. Archived issues are included, so old resolved ones still appear.400 without a shop name; 502 when Linear cannot be read (timeout after 10 s, a non-2xx, or GraphQL errors) — the dashboard shows the failure on the section rather than an empty list.
The customer page’s Tracking audit section — the store’s latest finished store audit (the public
audit at /audit, see AGENTS.md → Store audit), summed up with a link to the full report. Per
store, like Linear issues; the dashboard draws one card per brand store that has one.
GET /customers/:shopName/store-audit
{ data: { shopName, audit, runUrl } }:
audit — null when nobody has audited the store. An audit is the store’s when its scan resolved
to the store’s .myshopify.com shop or it was typed as the store’s domain (www. dropped) — the
same two keys the audit cache matches a store on. Only complete audits count; the latest wins,
whoever ran it. Otherwise:
auditId, host, storeName, scannedAt (the original scan’s time when the audit is a copy of
a recent one), reportUrl (the report step on the admin UI; absent without FRONTEND_URL)score: { value, tone, title } — the report’s tracking score out of 100; absent when no area
could be scoredareas — the report’s three areas in its order (accuracy, security — titled Privacy —,
speed): title, score and tone (absent when unmeasured), headline (the finding the area
opens on), findings ({ text, tone }, what took points off, without the points, at most three)
and moreFindings (how many more the report lists)scanCount — how many times the store has been scanned. A second visitor within 24h gets a copy
of the last scan, not a new one, and copies are not counted.runUrl — the public audit page with the store’s domain filled in (/audit?url=…), which starts a
scan on arrival. Absent without FRONTEND_URL.Every string is quoted from the report view (buildStoreAuditView), as the report email quotes it;
tone is success / warning / critical / info / plain. 400 without a shop name, 404 for a
store monitoring-ai does not know.
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.
A CSM can BCC (or CC) the notes inbox on an email to a customer, and it arrives on the customer page
as an ordinary note — no CIP change needed to read it. A Mailgun route forwards the message to
POST /inbound-email/mailgun (src/handlers/api/inboundEmail/mailgunInboundEmail.ts), which is
public and checks two things:
timestamp + token under MAILGUN_WEBHOOK_SIGNING_KEY,
within 15 minutes. A bad one answers 406, which Mailgun does not retry.@littledata.io. The check is tolerant by design and
rejects only on positive evidence of a spoof. littledata.io publishes DMARC p=reject, so a forged
From gets dmarc=fail in the topmost Authentication-Results header, the one Mailgun’s MX
(mxa.mailgun.org) prepends, and that verdict is acted on. The legacy X-Mailgun-Spf and
X-Mailgun-Dkim-Check-Result headers count only when both say fail. A pass, a missing verdict,
or headers worded differently all get filed. A strict check silently dropped the first two real
emails, while the worst a spoof can do is add one note. A rejection is logged with the evidence
the check saw: header names and verdicts, never the body.Each To/Cc address (except Littledata’s own and the inbox’s) is matched to a store in this order: a
mirrored Intercom contact → a customerLeads row (not departed) → the email domain against the
store’s domain (free-mail providers never match; the exact host and the parent store win). The
email makes one note per store. It goes on the matched contact, or on the store’s most recently
seen contact when the match came from a lead or the domain. The body is headed
[✉️ Email from <sender>] and carries the words alone. There are no Subject, To, Cc or Sent lines,
because the page already shows the contact and the date. The heading is followed by Mailgun’s
stripped-text with the signature cut (src/lib/emailSignature.ts), capped at 8,000 characters.
Gmail signatures have no delimiter, so the cut is made at the first of: -- , “Sent from my …”, a
confidentiality disclaimer, or a short line starting with the full name of someone on the email. The message the email answers — usually
the customer’s — follows under --- In reply to: <attribution> ---. Only that one message is kept,
cut at 4,000 characters (src/lib/emailQuotedReply.ts), with its signature and disclaimer cut too. Its own quoted history (nested >>, a
further “On … wrote:”, an older Outlook header block) is dropped. The annotation carries source: "email"
and emailMessageId.
Forwarding works too. An email sent to the inbox alone (forwarded, or BCC’d with nobody on
To) has no customer on its To line, so the recipients come from the forwarded message’s own
From/To/Cc block, as Gmail, Apple Mail and Outlook write it (src/lib/emailForwarded.ts, wrapped
header lines rejoined). Failing that, they come from addresses the forwarder typed in their own
text (“call notes for tyler@acme.com”). The note is headed
[✉️ Email from <original sender>, forwarded by <forwarder>], then the
forwarded message without the thread under it or its signature, and last whatever the forwarder
typed, under --- Note from <forwarder> ---. The sender check still applies to the forwarder.
A domain-only match also files a task. The sender wrote to someone at the brand who is neither
a contact nor a lead, so the account does not know them. A manual task, “Add
Unmatched mail is dropped without a reply. inboundEmails holds one row per Message-Id (status,
subject, recipients, what matched, never the body). That row is what makes a Mailgun retry safe:
a finished message answers 200 duplicate, and a half-finished one skips the stores already noted.
Mailgun setup: create a route with the expression match_recipient("bcc@mailgun.littledata.io") and the
action forward("https://alnmpsr7dc.execute-api.us-east-1.amazonaws.com/prod/inbound-email/mailgun"), followed by stop().
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.