# Customer Conversations Client API: Agent Guide

You are an AI agent that has been handed this guide plus a Customer Conversations API key.
This document teaches you the entire API surface and, just as importantly, how to answer
questions over the data honestly so your numbers reconcile with the client's portal.

This is a living document. It describes exactly what the API does today and is updated as new capabilities ship. Endpoints, fields, or tools not listed here are not available.

Base URL: `https://app.customerconversations.co`

All endpoints live under `https://app.customerconversations.co/api/client/v1`. The machine-readable contract is the OpenAPI 3.1
document at `https://app.customerconversations.co/api/client/v1/openapi.json` (public, no key required). Fetch it when you need exact
schemas; this guide covers semantics and method.

## Authentication

Send the API key on every request:

```
Authorization: Bearer cc_...
```

Keys are brand-scoped: one key belongs to exactly one client (brand), and every response is
already filtered to that client. `meta.client_id` on every response tells you which. There
is no way to widen a key's view; do not try to query other brands.

A `401` means the key itself failed. The `error` field tells you why:

| 401 error | Meaning |
| --- | --- |
| `Missing Bearer API key` | No `Authorization: Bearer` header was sent |
| `Invalid API key` | The key is malformed or does not match any issued key |
| `API key is not active` | The key was revoked |
| `API key has expired` | The key passed its expiry date |

Do not retry a 401. Report it to your operator: they need to issue or rotate a key on the
portal's developer page.

## Scopes

Keys carry scopes. The default scopes are `read:campaigns`, `read:interviews_metadata`, `read:synthesis`. They cover every endpoint
below at its safe-metadata level.

Elevated scopes exist for sensitive data: `read:transcripts`, `read:customer_identity`, `read:raw_extraction`, `read:custom_extraction`, `export:data`. When your key lacks a scope
an endpoint or field needs, you get a `403` with a body like:

```json
{ "error": "Missing required scope: read:transcripts" }
```

A 403 is not an error in your request; it is a policy boundary. Answer with what the default
scopes give you, and tell your operator which scope would unlock more if it matters. What
each elevated scope unlocks:

| Scope | Unlocks |
| --- | --- |
| `read:transcripts` | Full interview transcript turns on the interview endpoints |
| `read:customer_identity` | `participant_email` on interview resources |
| `read:raw_extraction` | `raw_extraction` (provider-level extraction) on the interview detail endpoint |
| `read:custom_extraction` | `custom_extraction` (the cleaned structured extraction: competitor mentions, product feedback, pain points, feature requests) on the interview endpoints |
| `export:data` | Bulk data export flows |

Scope-gated fields are omitted from responses entirely when the key lacks the scope. The
`meta.includes_*` flags tell you what this response actually contained.

## Endpoints

These seven REST routes are the entire REST surface today. The MCP adapter below exposes
the same readers as agent tools.

### GET /api/client/v1/analytics/interviews

Portal-parity canonical reportable-corpus analytics under `read:interviews_metadata`. Returns
the exact volume, campaign, sentiment, and pulse-row chart series built by the portal helpers.
The default window is the portal's 30 days, anchored to canonical measurement `as_of` after
cutover and request time for legacy tenants; explicit `date_from` or `date_to` overrides
that default. Optional filters: `campaign`
(live campaign slug), `date_from`, and `date_to`.

### GET /api/client/v1/intelligence/search

Case-insensitive search over client-safe custom extraction fields in the usable, reportable,
live-campaign corpus under `read:custom_extraction`. `q` is required; `campaign` and `limit` are
optional. Test, non-live, short, disengaged, incomplete, non-reportable, and other-tenant
interviews are excluded before matching.

### GET /api/client/v1/synthesis

Published synthesis: the same frozen-contract intelligence bodies the client reads in the
portal. Start here for "what does the data say" questions; these documents are already
synthesized and quality-gated at publish time. The default `read:synthesis` scope serves
all of these published surfaces.

By default this is the durable document collection from US-002: use `type`, `lane_id`,
`campaign_id`, `limit`, `starting_after`, and `updated_since` for filtered pagination
and incremental syncs. Add `surface=portal` to return portal bodies rather than database rows:
`home`; `daily_feed` (requires `read:customer_identity`) with `day` or `date_from`/`date_to`; `lane` with a client-visible
`lane` slug; `campaign` with a client-visible `campaign` slug; `playbook`; or
`creative_concept` (the published creative lane and its concept annotations).

### GET /api/client/v1/campaigns

Conversation goals (campaigns): the client's active research questions, briefs, and target
segments. Use these to scope analysis. Interview rows reference campaigns via
`conversation_goal_id`.

Filters: `limit`, `starting_after`, `updated_since`, `status` (matches the campaign
lifecycle column `launch_status`), `campaign_id`, `date_from`, `date_to`.
`updated_since` is supported.

### GET /api/client/v1/interviews

Quality-gated interview metadata. Only reportable, client-safe interviews appear: the same
usable-interview rules the portal uses, so counts you derive here reconcile with portal
numbers.

Filters: `limit`, `starting_after`, `campaign_id` (matches
`conversation_goal_id`), `status`, `date_from`, `date_to` (both on `created_at`).

`updated_since` is NOT supported here: the interviews table has no `updated_at` column,
and sending it returns `400` with error code `updated_since_unsupported`. Use
`date_from`/`date_to` windows for time-bounded pulls.

Default-scope fields per interview: `id`, `client_id`, `conversation_goal_id`,
`participant_name`, `interview_type`, `status`, `duration_seconds`, `nps_score`,
`customer_sentiment`, `churn_risk`, `best_quote`, `created_at`. Elevated scopes add
`participant_email`, `transcript`, and `custom_extraction`.

### GET /api/client/v1/interviews/{id}

One quality-gated interview. The response `data` is object-shaped:
`{ "data": { "interview": { ... } }, "meta": { ... } }`. Returns `404` when no
quality-gated interview with that id is visible to this client. With elevated scopes the
detail adds `transcript`, `participant_email`, `custom_extraction`, and (detail route
only) `raw_extraction`.

### GET /api/client/v1/openapi.json

The machine-readable OpenAPI 3.1 contract. Public, no key, not rate limited. The same is
true of this guide and the BI guide (`https://app.customerconversations.co/api/client/v1/bi-guide.md`).

Recommended order for most questions: synthesis first (already-published answers), then
campaigns (context), then interviews (raw material), then interview detail when you need
one conversation in depth.

## MCP for Claude Code and other agents

Agents that support MCP Streamable HTTP can use the same key and scopes through
`https://app.customerconversations.co/api/client/v1/mcp`. The server is read-only and exposes `get_interview_analytics`,
`list_interviews`, `search_customer_intelligence`, `get_synthesis_document`, and
`list_campaigns`. Each tool delegates to the corresponding REST reader, so publication,
measurement, pagination, field-level scope, and tenant rules are identical.

```json
{
  "mcpServers": {
    "customer-conversations": {
      "type": "http",
      "url": "https://app.customerconversations.co/api/client/v1/mcp",
      "headers": { "Authorization": "Bearer cc_1a2b3c4d..." }
    }
  }
}
```

The tool list is scope-aware. A key only sees tools whose base read scope it carries, and
sensitive interview fields remain controlled by the same elevated scopes as REST.

## Response envelope

Every authenticated response is:

```json
{
  "data": [ ... ],
  "meta": {
    "client_id": "uuid",
    "count": 50,
    "has_more": true,
    "next_cursor": "opaque-string-or-null"
  }
}
```

`meta` may also carry `includes_*` scope flags and measurement fields
(`measurement_mode`, `measurement_total_count`, `measurement_basis`) that prove how
counts were computed. The interview detail endpoint wraps its single row as
`data.interview` instead of an array.

## Pagination protocol

Every list endpoint uses keyset cursor pagination:

1. Request with `limit` (default 50, max 200).
2. Read `meta.has_more`. If `true`, request again with
   `starting_after=<meta.next_cursor>` and the SAME filters.
3. Stop when `has_more` is `false` (`next_cursor` will be `null`).

Cursors are opaque and route-bound: never construct or edit one, and never reuse a cursor
from one endpoint on another. A malformed or foreign cursor returns `400` with error code
`invalid_cursor`; recover by restarting the walk from page one. Walks are deterministic
(no gaps, no duplicates, stable under concurrent inserts).

Full-walk recipe:

```python
import requests

BASE = "https://app.customerconversations.co/api/client/v1"
HEADERS = {"Authorization": "Bearer cc_..."}

def walk(path, **filters):
    params = {"limit": 200, **filters}
    while True:
        resp = requests.get(BASE + path, headers=HEADERS, params=params)
        resp.raise_for_status()
        body = resp.json()
        yield from body["data"]
        meta = body["meta"]
        if not meta.get("has_more"):
            break
        params["starting_after"] = meta["next_cursor"]

interviews = list(walk("/interviews"))
```

For incremental syncs: campaigns and synthesis support `updated_since` (ISO 8601);
interviews do not, so window on `date_from`/`date_to` instead.

## Rate limits

Every key-authenticated route shares one budget: 600 requests per rolling hour per API
key. Every authenticated response carries:

- `X-RateLimit-Limit`: the per-key hourly limit (600)
- `X-RateLimit-Remaining`: requests still available at admission time
- `X-RateLimit-Reset`: unix timestamp (seconds) when capacity next frees

Beyond the limit you get `429` with body `{ "error": "rate_limited" }` and a
`Retry-After` header giving the seconds until the oldest counted request ages out of the
window. Handle it honestly: sleep the full `Retry-After`, then resume. Do not busy-poll,
do not shorten the wait, and do not fan out across parallel requests to race the limiter
(429 responses do not consume quota, but hammering helps nothing since the window is
rolling). A full walk of a few thousand interviews at `limit=200` costs only dozens of
requests, so the budget is generous when you paginate properly. Note that `403` scope
failures count as usage; probing endpoints you lack scopes for spends your budget.

The public documentation routes (`openapi.json` and the two guides) require no key and are
not rate limited.

## Error codes

| Status | `error` value | Meaning | What to do |
| --- | --- | --- | --- |
| 400 | `invalid_cursor` | `starting_after` is malformed, truncated, or from a different route | Restart the walk without a cursor |
| 400 | `invalid_updated_since` | `updated_since` is not valid ISO 8601 | Fix the timestamp format |
| 400 | `updated_since_unsupported` | This route has no `updated_at` (interviews) | Use `date_from`/`date_to` |
| 401 | see the auth table above | The key failed | Stop; ask your operator for a valid key |
| 403 | `Missing required scope: <scope>` | Valid key, missing scope | Work within default scopes or request the scope |
| 404 | not found | Interview id not visible to this client | Treat as nonexistent |
| 429 | `rate_limited` | Over the rolling-hour budget | Sleep `Retry-After` seconds, then resume |
| 503 | `measurement_unavailable` | Canonical measurement could not be proven for this response | Retry later; do not fabricate counts |

400 bodies also carry a `details` field explaining the specific failure.

## How to answer questions honestly

This is the part that separates a useful analysis from a misleading one.

**Use structured fields, not transcript grep, for frequency questions.** Questions like
"how often does price come up" or "how many customers mention competitor X" should be
answered from the structured per-interview fields (`nps_score`, `customer_sentiment`,
`churn_risk`, `best_quote` at default scope; competitor mentions, product feedback, and
pain points inside `custom_extraction` with `read:custom_extraction`), not by searching
transcript text. Transcripts contain the AI interviewer's questions as well as the
customer's answers, so naive text search overcounts massively. Real example from a
production corpus: a text search for price terms matched 432 of 435 transcripts because the
interviewer asks about price in nearly every call, while only 134 customers actually
discussed price. The structured extraction fields capture what the customer said, including
paraphrase, and are the reliable answer surface.

**Transcript access is an elevated scope by design.** The default-scope surface (structured
fields plus published synthesis documents) is intended to answer most questions. If you do
hold `read:transcripts` and must search text, restrict matching to customer turns, never
the interviewer's.

**Counts reconcile with the portal.** The interviews endpoints apply the same
usable-interview rules the portal applies, so a count you derive by walking
`/interviews` matches what the client sees in their portal. When `meta` carries
`measurement_total_count`, prefer it as the corpus-wide reportable total; it is computed
under the same rules, independent of your filters and page limit.

**Prefer published synthesis for interpretive questions.** The `/synthesis` documents are
quality-gated at publish time, and their measurement claims are validated by the synthesis
engine before publication. Quote them as the house view; derive your own numbers from
`/interviews` when the question is not already answered there, and say which you did.
