Littledata MonitorAI guide

Customer Intelligence Platform (CIP) — API Contract

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.

System Boundary

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.

Authentication

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:

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.

Agent access (API tokens)

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):

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.

Customer List

The customer table calls:

GET /customers
GET /customers?search=&planGroup=&phase=&healthStage=

Supported query parameters:

The response may be either:

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:

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 /customers to 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 to null and 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.ts here, and src/lib/__tests__/api.test.ts in customer-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.

Payload budget

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.

Customer Detail

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:

MRR is mrr from the API (see the list section); the detail response carries the same mrr, suggestedPlan and suggestedMrr fields.

Account owner

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.

Health-score history

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.

Company profile

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.

Contacts

The contacts tab calls:

GET /customers/:shopName/contacts

The response may be either:

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:

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.

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.

People at the brand (leads database)

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:

The 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.

Champions who are not app users

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 }):

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.

Phone numbers

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
}

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).

Work emails

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.

Agencies

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).

Agencies working with a store

GET /customers/:shopName/agencies

{ data: [...] }, most recently seen first. Each row:

Per store, like contacts: the dashboard rolls a brand’s stores up and merges on agencyId.

All agencies

GET /agencies

{ data: [...] }, one row per agency, most stores first:

One agency

GET /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’s own people

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.

Tasks

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:

Reconciliation:

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):

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:

Per 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.

Linear issues

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 } }:

An issue is related to a store two ways, and a row can carry both:

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.

Tracking audit

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 } }:

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.

Notes

The notes tab calls:

GET /customers/:shopName/notes

The response may be either:

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.

Notes from email (the BCC inbox)

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:

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 as a contact" (section `contacts`), goes onto the sender's to-do list. The assignee is found by matching the From address to the sender's admin user (`services.google.email`), with the name folded to the `ACCOUNT_OWNERS` first name the same way a LinkedIn seat is. The task is keyed `email-contact:<address>` per store and only ever inserted, so a second email to the same person adds nothing, and a task already ticked off stays that way.

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().

Health Flags and Audit Checks

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:

Response Normalization Rules

The frontend intentionally normalizes backend responses before components consume them.

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.

Safe Frontend Changes

These changes should not require backend API response changes:

Changes That Need API Coordination

Coordinate with the backend before making these changes:

Implementation Checklist for Frontend Agents

Before changing API-facing code:

  1. Check src/lib/api.ts for the current endpoint and mapping behavior.
  2. Check src/lib/types.ts for the normalized type components should consume.
  3. Prefer updating an existing hook in src/lib/hooks instead of adding component-level fetch calls.
  4. Add or update tests under src/lib/__tests__ or src/lib/hooks/__tests__ when changing request or mapping behavior.
  5. Keep examples public and synthetic. Do not commit real session tokens, customer records, private URLs, or credentials.