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:

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
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. 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.domainus.dockers.comdockers.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.

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.

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.

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

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 { status: "open" | "snoozed" | "done" | "dismissed" }. done and dismissed stamp when and who from the session; snoozed sets snoozedUntil 30 days out (SNOOZE_DAYS) and the task leaves both lists until then; open clears every stamp. resolved is refused (400) — it belongs to the sync. 404 when the id is not a task on that store. Returns { data: { task } }.

POST /customers/:shopName/tasks

Body { title, reason?, urgency?, assignee? } — a task a CSM adds by hand (kind manual, default soon). Never rewritten or resolved by the sync. assignee is the colleague who is going to do it: omit it and the task falls to the account owner, pass null to leave it on nobody, and pass one of the ACCOUNT_OWNERS names to hand it over (anything else is a 400 — a free-text assignee would fork one person into three and break the Tasks page’s own filter). A manual task keeps that name when the account changes hands. Returns { data: { task } }.

GET /tasks

Every open task across every customer, plus the ones ticked off in the last 7 days (recentlyDoneDays), in the same order as above: { data: { tasks, recentlyDoneDays } }. ?assignee=<name> narrows to one person (assignee=unassigned for tasks with nobody on them). Rows carry the task and its shopName only — the dashboard joins owner, health and MRR from the customer list it already holds.

Indexes: npx tsx scripts/ensure-audit-and-customer-indexes.ts creates the unique { shopName, key } index the sync upserts on and the { status, urgency, lastSeenAt } index the list reads by.

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.

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.