> ## Documentation Index
> Fetch the complete documentation index at: https://docs.collectivex.health/llms.txt
> Use this file to discover all available pages before exploring further.

# Submit a question as an async job (asynchronous /v1/query)

> Submit exactly the request body you would send to `POST /v1/query`
and get a job id back immediately, instead of waiting for the
grounded answer on an open connection. The answer is produced in
the background and read with `GET /v1/jobs/{id}` — see the "Async
jobs" tag description for the lifecycle, retention, and a polling
recipe.

**Validation is identical to `/v1/query`, and happens now.** The
body must contain exactly one of `question` or `messages` (one
user message), may set `response_format`, and may not contain
anything else. Every `400` this endpoint returns — unknown field,
both/neither shape, empty or whitespace-only question, a message
with a `file` part, a non-`user` role — is one `/v1/query` would
have returned for the same body. Nothing is accepted here only to
fail an hour of polling later. One extra rule applies only here:
the body is persisted and echoed back on every poll, so it is
capped at 32 KiB of JSON.

**What runs, and when.** On `202` the job is `queued` and starts
running immediately, in the background. It is the same
retrieval + safety + generation pipeline `/v1/query` uses, with the
same published tone and the same deferral/crisis behaviour — so
the eventual `result` is exactly the `QueryResponse` you would have
received synchronously. Expect the job to finish in the same
10–40 s a synchronous call takes; a job is never faster than the
sync call, it just doesn't make you wait on the socket.

**Metering.** Submitting is one metered request against your
per-key budget (the same pool as `/v1/query`, `/v1/chat`, and
polling). The background execution is not metered again.

**Server-to-server only, with your long-lived key.** A session
token gets `403 forbidden` here, exactly as on `GET /v1/jobs/{id}`.
Never embed your long-lived key in a client.




## OpenAPI

````yaml /openapi.yaml post /v1/jobs
openapi: 3.1.0
info:
  title: CollectiveX Health Partner API
  version: '1.10'
  description: >
    The CollectiveX Health Partner API gives your product access to a grounded,

    peer-reviewed women's-health research corpus. Every answer includes inline

    citations from real papers; the API refuses to answer when the corpus lacks

    strong evidence.


    There are two ways to use it: ask a single question and get one

    grounded answer (`/v1/query`), or have a back-and-forth conversation

    with streaming or sync responses (`/v1/chat`). A third, asynchronous

    way — submit the same question as a job with `POST /v1/jobs` and poll

    `GET /v1/jobs/{id}` for the answer — is for callers that can't hold

    the connection open (see "Async jobs" below). Pick whichever matches

    your integration.


    ---


    ## API keys: server-side, or native-mobile-direct via short-lived session
    tokens


    > **Warning:** Do NOT call `/v1/query` or `/v1/chat` directly from a

    > browser or a mobile app using your long-lived partner key. Build a

    > server-side proxy (a Next.js Route Handler, Express middleware, or

    > Lambda function) that forwards requests and authenticates with the

    > partner key from a secret manager. Browser-direct usage will leak the

    > key to your users. **This rule is absolute and does not change** —

    > everything below adds a second sanctioned pattern for native mobile

    > apps, it does not relax this one, and it does not extend to browsers.


    **For web apps, server-to-server is the supported and recommended

    integration**, full stop: keep your long-lived partner key on your own

    backend and proxy every browser request through it. This is what

    HumanGo does today — the browser never sees any CollectiveX Health

    credential, long-lived or short-lived.


    **Short-lived session tokens exist for exactly one case: a native

    mobile app that streams from us directly**, without a round trip

    through your backend for every turn. They are **not** a sanctioned way

    to call this API from a browser or any other JavaScript context — a

    token handed to client-side JS is sitting in developer tools same as

    the long-lived key would be, just on a shorter clock. To be plain about

    how this is enforced: it's a partner-trust rule, stated here and in the

    partner terms, not a technical one — we do not block browser-origin

    requests by user-agent or otherwise, and a session token will work if

    you send it from one. We reserve the right to revoke a partner's keys

    for minting or using session tokens outside native mobile apps.


    There are two sanctioned integration patterns:


    **1. Server-to-server proxy** (recommended default, and the only

    supported pattern for web):


    ```

    Browser  →  useChat({ api: '/api/proxy/chat' })
                  ↓
    Partner's server-side proxy (your Route Handler / Lambda)
                  ↓  Authorization: Bearer $CXH_API_KEY (from env)
    POST https://api.collectivex.health/v1/chat  (or /v1/query)

    ```


    **2. Native-mobile-direct, via a short-lived session token** (see

    "Mobile-direct access" below): for a native mobile app you don't want
    proxying every chat

    turn through your own backend, your backend presents the long-lived

    key to `POST /v1/session-tokens` and hands the returned short-lived key

    straight to that app, which calls `/v1/query` / `/v1/chat` with it

    directly:


    ```

    Your backend  →  POST /v1/session-tokens  (Authorization: Bearer
    $CXH_API_KEY)
                        ↓  { key: "...", expires_in_seconds: 900, scope: ["query","chat"] }
    Mobile app    →  POST /v1/query (or /v1/chat)  (Authorization: Bearer
    <session key>)

    ```


    The session key can call **only** `/v1/query` and `/v1/chat`, expires in

    at most 60 minutes, and can never mint another session token. See

    "Mobile-direct access" below for the full contract and a runnable

    example. **The long-lived key itself must never leave your server** —

    embedding it in a mobile *or* web client is exactly the leak this

    section exists to prevent; the short-lived session key it mints is

    safe to hand to a native mobile client, and only a native mobile

    client.


    Your app owns end-user identity. We never receive end-user PII. The partner

    key is shared across all of your end-users; you take the contract that no

    PHI is forwarded to us in prompts.


    ---


    ## Authentication


    All requests require an `Authorization` header:


    ```

    Authorization: Bearer <CXH_API_KEY>

    ```


    To request a key:
    [partners@collectivex.health](mailto:partners@collectivex.health)


    ---


    ## Ask a question — `POST /v1/query`


    Most partner integrations don't need a conversation at all — just a

    question and a grounded answer. If that's you, `/v1/query` is the

    simplest way in: send one question, get back one JSON response, done.

    The endpoint is stateless — no session, no setup, no history to manage.


    **When to pick this:**


    - **Snapshot insights** — daily, weekly, or on-demand recommendations

    - **Embedded ask-widgets** — drop a research-search box on a content site

    - **Claude Skills, MCP tools, agent tool-calls** — one call, one cited
    answer

    - **Slack / Discord slash commands** — `/research <question>` → answer +
    sources

    - **Background batch generation** — content calendars, email digests, alerts

    - **Voice assistant integrations** — single utterance → single grounded
    answer


    Because each call is independent, you can also cache by query hash (same

    question, same answer, skip the call), retry safely (a repeated call

    returns a fresh grounded answer), and fan out many different questions in

    parallel within your per-key rate budget.


    ```bash

    curl -sS -X POST https://api.collectivex.health/v1/query \
      -H "Authorization: Bearer $CXH_API_KEY" \
      -H "content-type: application/json" \
      -d '{
        "question": "What does the research say about magnesium for perimenopause sleep?"
      }'
    ```


    **What comes back** (`SyncResponse`, abbreviated):


    ```typescript

    interface SyncResponse {
      request_id: string;          // UUID — use for support correlation
      answer: string;              // empty string on deferral or crisis
      citations: Citation[];       // empty array on deferral or crisis
      deferral?: { originalQuery: string };  // corpus lacked strong evidence
      crisis?: { category, locale, originalQuery, resources };  // safety classifier fired
      error?: { message: string }; // generic sanitized failure message (v1.1)
    }

    ```


    ```json

    {
      "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "answer": "Magnesium supplementation has been associated with modest improvements in sleep quality during perimenopause...",
      "citations": [
        {
          "id": "CXH-00835",
          "score": 0.92,
          "text": "Transdermal estradiol was associated with significant reductions...",
          "metadata": { "doi": "10.1093/humrep/dead156", "year": "2023" }
        }
      ]
    }

    ```


    Already building request bodies for `/v1/chat`? `/v1/query` also accepts

    a single-element `messages` array in place of `question` — see the

    endpoint reference below for the exact shape.


    **Structured responses (BETA).** Add `"response_format": "structured"` to

    get a `structured_answer` field alongside the normal `answer`/`citations`

    fields — the same answer, reshaped into sections and citation-bound

    claims for callers that want to render or route the answer programmatically

    instead of parsing markdown. This is additive only: omit the field, or set

    it to `"text"` (the default), and the response is unchanged. See

    `QueryRequest.response_format` and `StructuredAnswer` in the schema

    reference below. This field and its shape may change in a future minor

    version while in beta.


    **You MUST branch on `structured_answer.status` first.** It is a required

    field with one of five values (`answered`, `ungrounded`, `deferral`,

    `crisis`, `error`) — never infer the outcome from `sections`/`citations`

    being empty. A failed or truncated generation is surfaced as

    `status: "error"` with `structured_answer.error` populated and

    `sections`/`citations` always empty — it is never reshaped into a section

    that looks like a normal cited answer. `deferral` and `crisis` each carry

    one non-empty section with pre-approved copy (see `StructuredSection.kind`)

    so that even a client that only renders `sections[].body` never shows a

    blank card for a self-harm query.


    **Common errors:** `400 invalid_request` (bad or unknown field in the

    body), `401 unauthorized` (missing or malformed key), `429 rate_limited`

    (back off and retry after the `Retry-After` header). See "Rate limits"

    below and the endpoint reference for the full list.


    ---


    ## Have a conversation — `POST /v1/chat`


    When your integration needs back-and-forth — a chat UI, follow-up

    questions, multi-turn context — use `/v1/chat` instead. It streams the

    answer by default so users see text appear as it's generated; send

    `stream: false` if you'd rather wait and get one JSON blob back.


    **When to pick this:** a chat widget, an assistant thread, or any flow

    with follow-ups where the next question references the prior one ("is it

    different?", "what about HRT?").


    ### Streaming (default)


    ```bash

    curl -sS -D /tmp/headers.txt \
      -X POST https://api.collectivex.health/v1/chat \
      -H "Authorization: Bearer $CXH_API_KEY" \
      -H "content-type: application/json" \
      -d '{
        "messages": [
          {
            "id": "msg_smoke_1",
            "role": "user",
            "parts": [{ "type": "text", "text": "How does luteal-phase progesterone affect sleep?" }]
          }
        ]
      }'
    ```


    **What comes back:** HTTP `200`, `content-type: text/event-stream`, an

    `x-vercel-ai-ui-message-stream: v1` header (AI SDK v6 protocol marker),

    and an SSE body — abbreviated:


    ```

    data: {"type":"start"}

    data: {"type":"text-delta","delta":"Progesterone during the luteal phase "}

    data: {"type":"data-sources","data":[{"id":"CXH-00835","score":0.92,...}]}

    data: {"type":"finish","finishReason":"stop"}

    ```


    ### Synchronous mode (`stream: false`)


    Send `"stream": false` to get a flat JSON response instead of SSE — the

    same `SyncResponse` shape shown under "Ask a question" above. This stays

    fully supported indefinitely; it's for callers that structurally can't

    read a stream (Claude Skills, MCP tool calls, batch pipelines). **New

    one-shot integrations should use `/v1/query` instead** — same contract,

    simpler request body.


    ```bash

    curl -sS -X POST https://api.collectivex.health/v1/chat \
      -H "Authorization: Bearer $CXH_API_KEY" \
      -H "content-type: application/json" \
      -d '{
        "stream": false,
        "messages": [
          {
            "id": "msg_smoke_1",
            "role": "user",
            "parts": [{ "type": "text", "text": "How does luteal-phase progesterone affect sleep?" }]
          }
        ]
      }' | jq '.'
    ```


    ### Multi-turn follow-ups


    Include the prior `user` + `assistant` messages in the array — the same

    shape as any AI SDK / OpenAI-style `messages` history. Only the last 10

    messages are used.


    > **Heads-up:** retrieval keys on the *latest* user message only. A bare

    > follow-up like "yes please" or "tell me more" will not retrieve relevant

    > papers on its own — keep follow-ups self-contained ("yes — show me the

    > HRV studies") or rewrite the query on your side before sending.


    ### Latency: prefer streaming wherever you can render (or buffer) it


    Answers are retrieval-grounded and fully generated per request, so a

    complete answer takes roughly **14–23 seconds** end to end (measured

    against production on the golden question set, 2026-08-14). The two modes

    spend that time very differently:


    | mode | first byte | first answer text | complete answer |

    |---|---|---|---|

    | `stream` (default) | ~7s (p50) | **~9s (p50)** | ~18s (p50) |

    | `stream: false` | ~18s (p50) — the full wait | — | ~18s (p50) |


    The second half of the wall clock is the model writing the answer — time

    streaming consumers spend reading, and sync consumers spend staring at a

    spinner. Sync mode cannot be made materially faster server-side; switching

    to streaming halves *perceived* latency with no server change. Anything

    user-facing should consume the SSE stream — first words on screen at ~9s

    instead of an ~18s blank wait. Streaming doesn't require a chat UI or the

    AI SDK either: read the `text-delta` chunks and concatenate them (the

    proxy example below pipes bytes through unchanged).


    ### Building a chat UI (`useChat` via your server-side proxy)


    Point `useChat` at YOUR own proxy, never at `api.collectivex.health`

    directly. If this `useChat` instance runs in a native mobile app that

    holds a short-lived session token (native mobile only, not browser/JS

    — see "API keys" above), point it

    at `/v1/chat` directly with that token instead of a proxy. A browser

    `useChat` instance always goes through your proxy — never the

    long-lived key, and never a session token either:


    ```tsx

    'use client';

    import { useChat } from '@ai-sdk/react';

    import { DefaultChatTransport } from 'ai';


    export function ResearchChat() {
      const { messages, sendMessage } = useChat({
        transport: new DefaultChatTransport({
          api: '/api/proxy/chat',   // YOUR proxy — NOT api.collectivex.health
        }),
      });
      // ... render
    }

    ```


    Your server-side proxy holds the `CXH_API_KEY` and forwards to `/v1/chat`:


    ```typescript

    // app/api/proxy/chat/route.ts

    import 'server-only';

    export const runtime = 'nodejs';

    export const maxDuration = 60;


    export async function POST(req: Request) {
      const CXH_API_KEY = process.env.CXH_API_KEY;
      if (!CXH_API_KEY) return new Response('CXH_API_KEY not configured', { status: 500 });

      const upstream = await fetch('https://api.collectivex.health/v1/chat', {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          authorization: `Bearer ${CXH_API_KEY}`,
        },
        body: await req.text(),
      });
      return new Response(upstream.body, {
        status: upstream.status,
        headers: upstream.headers,
      });
    }

    ```


    **Common errors:** same as "Ask a question" above, plus `502` in sync

    mode when the generation pipeline fails with nothing usable (the body is

    still a `SyncResponse` with `error.message` set — retry with backoff).


    ---


    ## Rate limits


    Default limit for every key: **60 requests per minute**, shared across

    every `/v1/*` endpoint that requires a key — today that's `/v1/query`,

    `/v1/chat`, `POST /v1/jobs`, `/v1/jobs/{id}`, and

    `POST /v1/session-tokens`. (A future endpoint on this same key naturally

    joins the same budget unless we say otherwise.) Submitting an async job

    is one metered request and each poll is one more; the job's background

    execution is never metered.


    Two session-token consequences worth sizing for: **minting is itself a

    metered request** against your budget (one mint = one request), and a

    **session key does not get its own budget** — it draws from its parent

    long-lived key's pool, so the limit is effectively per *partner*, not

    per key, once session tokens are in play.

    Limits are **per-key configurable** — some partners are provisioned with

    a different requests-per-minute ceiling, and/or a requests-per-day

    ceiling, on request. There is no implicit daily cap unless your key has

    been configured with one; ask partners@collectivex.health if you want a

    daily ceiling added or changed.


    Read `X-RateLimit-Remaining` on every successful response to self-throttle

    before reaching 429. When a key has a configured daily ceiling, successful

    responses also carry `X-RateLimit-Limit-Daily` /

    `X-RateLimit-Remaining-Daily` / `X-RateLimit-Reset-Daily` — check these

    too, since the per-minute headers alone can show plenty of headroom while

    you're one request from the daily cap. When you receive a 429, wait the

    number of seconds in the `Retry-After` response header before retrying.

    `X-RateLimit-*` on a 429 always reflects whichever budget (per-minute or,

    if configured, per-day) was actually exceeded.


    On the rare occasion the rate-limit service itself is degraded, you'll

    get a `503` with `error.code: "internal_error"` and a `Retry-After`

    header — treat it like any other transient 503 and retry with backoff.

    A genuine 429 always carries a `Retry-After` header; in a rare

    clock-skew case its value may be `0`, which clients should treat as

    "retry after a short backoff", not "retry immediately".


    ---


    ## Mobile-direct access — short-lived session tokens


    Session tokens are for native mobile apps only — see "API keys" above

    for why this doesn't extend to browsers. If your product has a native

    mobile app you'd rather not proxy every chat turn through your own

    backend, your backend can mint a short-lived, scoped key instead of

    embedding the long-lived one in the app:


    ```bash

    # 1. Your backend mints a session token (long-lived key, server-side only)

    curl -sS -X POST https://api.collectivex.health/v1/session-tokens \
      -H "Authorization: Bearer $CXH_API_KEY" \
      -H "content-type: application/json" \
      -d '{"ttl_seconds": 900}'
    # -> { "key": "<short-lived key>", "expires_in_seconds": 900,

    #      "scope": ["query", "chat"], ... }


    # 2. Hand `key` to the mobile client, which calls /v1/query or /v1/chat

    #    with it directly:

    curl -sS -X POST https://api.collectivex.health/v1/query \
      -H "Authorization: Bearer <short-lived key>" \
      -H "content-type: application/json" \
      -d '{"question": "What does the research say about magnesium for perimenopause sleep?"}'
    ```


    What a session key can and can't do:


    - Scoped to `/v1/query` and `/v1/chat` only — every other endpoint,
      including `POST /v1/session-tokens` itself, returns `403 forbidden`
      (no chaining a session key into another session key).
    - Expires automatically 60 seconds to 60 minutes after minting
      (`ttl_seconds`, default 900 = 15 minutes); Unkey deletes it on its
      own, nothing to clean up. An expired key returns `401 unauthorized`
      with an "API key expired" message — treat that as "fetch a fresh
      token from your backend", not as an error to alert on.
    - Shares its parent long-lived key's rate-limit budget and telemetry —
      minting session keys does not multiply your rate limit. A budget
      change to the long-lived key reaches outstanding session keys only
      after they expire, since each session key's limits are captured at
      mint time.
    - Always capped at `max_requests` total requests (1–10,000, default
      200), independent of the rate-limit window. An exhausted key
      returns `403 forbidden` permanently — mint a new one, don't retry.

    See the `POST /v1/session-tokens` operation below for the full request/

    response schema and error contract.


    ---


    ## OpenAPI spec


    The machine-readable contract is at

    [/openapi/v1-chat.yaml](/openapi/v1-chat.yaml) (the source of this

    documentation). Drop it into Postman, Insomnia, or Stainless to generate a

    client SDK.


    ---


    **Versioning policy:** Additive minor bumps for new fields/codes; breaking

    shape changes are major.
  contact:
    email: partners@collectivex.health
servers:
  - url: https://api.collectivex.health
    description: Production partner API
security:
  - bearerAuth: []
tags:
  - name: Ask a question
    description: One-shot, single-turn — send a question, get one grounded JSON answer.
  - name: Have a conversation
    description: Multi-turn chat — streaming by default, or synchronous with stream:false.
  - name: Async jobs
    description: >
      Ask a question without holding the connection open. `POST /v1/jobs`

      accepts exactly the `/v1/query` request body, answers `202` with a job id
      in milliseconds, and runs

      the same grounded-answer pipeline in the background; you poll

      `GET /v1/jobs/{id}` until the job reaches a terminal state. Built

      for agent pipelines and serverless callers whose own request timeout

      is shorter than a grounded answer can take.


      **Lifecycle.** `queued` → `running` → exactly one of `succeeded`

      (read `result`), `failed` (read `error`), or `expired`. A job can

      also go straight from `queued` to `expired` if it was never started

      in time. Terminal states never change again.


      **What you get back.** For a `query` job, `result` is byte-for-byte

      the `200` body `POST /v1/query` would have returned for the same

      request (`QueryResponse`) — same `answer`, `citations`, optional

      `deferral` / `crisis` / `error`, and `structured_answer` when you

      asked for `response_format: "structured"`. A request that

      `/v1/query` would have answered with `502` (generation failed with

      nothing usable) becomes a `failed` job whose `error` carries the

      same generic message.


      **Retention.** Every job carries an `expires_at` (one hour after

      submission today). A job that is still `queued` or `running` when

      that deadline passes is marked `expired` the next time it is read;

      a job seen `running` for more than 2 minutes is likewise treated as

      expired on the next read (the runner cannot live that long, so the

      answer is never coming). A job that already finished keeps its

      `result` readable **until `expires_at`** — the first read after

      that returns `404 not_found`, its stored request and result are

      cleared at that moment, and the row itself is deleted within

      about 24 hours. Fetch your result before `expires_at`.


      **Polling.** Start ~1 s after the `202`, then back off (e.g. ×1.5

      up to 10 s). Every poll is a metered request against the same

      per-key budget as `/v1/query`: honour `X-RateLimit-Remaining` and,

      on a `429`, `Retry-After`. Branch on the HTTP status of each poll:

      a `2xx` means read `status`; a `429` or a `503` means wait

      `max(Retry-After, 1)` seconds and poll again — a `503` with

      `Retry-After` while polling is transient (our auth/rate-limit

      dependency was briefly unavailable) and the job itself is

      unaffected; any other status means stop and inspect. A grounded

      answer typically takes 10–40 s.


      **Failures.** A `failed` (or `expired`) job carries a

      machine-readable `error_code`; branch on it, not on the `error`

      text: `corpus_unavailable` and `internal` are transient — submit

      a new job with backoff; `generation_failed`, `empty_query`,

      `invalid_payload` and `expired` mean the same request will fail

      the same way.


      **Who can call it.** Server-to-server only, with your long-lived

      partner key — both operations refuse session tokens with

      `403 forbidden`. Any of your long-lived keys can poll any of your

      jobs (ownership is per partner, not per key).
  - name: Session tokens
    description: |
      Mint a short-lived, scoped key for a mobile client from your
      long-lived partner key — see "Mobile-direct access" in the
      introduction.
paths:
  /v1/jobs:
    post:
      tags:
        - Async jobs
      summary: Submit a question as an async job (asynchronous /v1/query)
      description: |
        Submit exactly the request body you would send to `POST /v1/query`
        and get a job id back immediately, instead of waiting for the
        grounded answer on an open connection. The answer is produced in
        the background and read with `GET /v1/jobs/{id}` — see the "Async
        jobs" tag description for the lifecycle, retention, and a polling
        recipe.

        **Validation is identical to `/v1/query`, and happens now.** The
        body must contain exactly one of `question` or `messages` (one
        user message), may set `response_format`, and may not contain
        anything else. Every `400` this endpoint returns — unknown field,
        both/neither shape, empty or whitespace-only question, a message
        with a `file` part, a non-`user` role — is one `/v1/query` would
        have returned for the same body. Nothing is accepted here only to
        fail an hour of polling later. One extra rule applies only here:
        the body is persisted and echoed back on every poll, so it is
        capped at 32 KiB of JSON.

        **What runs, and when.** On `202` the job is `queued` and starts
        running immediately, in the background. It is the same
        retrieval + safety + generation pipeline `/v1/query` uses, with the
        same published tone and the same deferral/crisis behaviour — so
        the eventual `result` is exactly the `QueryResponse` you would have
        received synchronously. Expect the job to finish in the same
        10–40 s a synchronous call takes; a job is never faster than the
        sync call, it just doesn't make you wait on the socket.

        **Metering.** Submitting is one metered request against your
        per-key budget (the same pool as `/v1/query`, `/v1/chat`, and
        polling). The background execution is not metered again.

        **Server-to-server only, with your long-lived key.** A session
        token gets `403 forbidden` here, exactly as on `GET /v1/jobs/{id}`.
        Never embed your long-lived key in a client.
      operationId: postJob
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/QueryRequest'
            examples:
              question:
                summary: Plain question (recommended)
                value:
                  question: >-
                    What does the research say about magnesium for perimenopause
                    sleep?
              structured:
                summary: Same question, structured result
                value:
                  question: >-
                    What does the research say about magnesium for perimenopause
                    sleep?
                  response_format: structured
      responses:
        '202':
          description: |
            Accepted. The job exists and is running in the background. Poll
            `poll_url` (a path relative to the API base URL you called)
            until `status` is terminal. Nothing about the answer is known
            yet — this response deliberately carries no `result`.
          headers:
            X-Request-Id:
              description: >-
                UUID correlation key for this request. Also stamped on the job's
                background execution, so quoting it to support finds both.
              required: true
              schema:
                type: string
                format: uuid
                example: 550e8400-e29b-41d4-a716-446655440000
            X-RateLimit-Limit:
              description: >-
                Maximum requests per minute for this key (shared budget with
                /v1/query, /v1/chat, /v1/jobs/{id}, and /v1/session-tokens;
                per-key configurable, default 60).
              required: true
              schema:
                type: integer
                example: 60
            X-RateLimit-Remaining:
              description: Requests remaining in the current per-minute rate-limit window.
              required: true
              schema:
                type: integer
                example: 59
            X-RateLimit-Reset:
              description: >-
                Unix timestamp (seconds) when the per-minute rate-limit window
                resets.
              required: true
              schema:
                type: integer
                example: 1717000060
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobAcceptedResponse'
              example:
                request_id: 550e8400-e29b-41d4-a716-446655440000
                id: 8f14e45f-ceea-467e-adc0-7a1a6b5f6b28
                job_type: query
                status: queued
                poll_url: /v1/jobs/8f14e45f-ceea-467e-adc0-7a1a6b5f6b28
                expires_at: '2026-09-11T13:00:00.000Z'
                created_at: '2026-09-11T12:00:00.000Z'
        '400':
          description: |
            400 Bad Request. The body failed the `/v1/query` request rules
            (the message names the problem — same texts as `/v1/query`), or
            exceeds the 32 KiB async-job body cap. Fix the request; nothing
            was created.
          headers:
            X-Request-Id:
              description: Request UUID.
              schema:
                type: string
                format: uuid
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              examples:
                empty_question:
                  value:
                    error:
                      code: invalid_request
                      message: Latest user message must not be empty.
                      request_id: 550e8400-e29b-41d4-a716-446655440000
                unknown_field:
                  value:
                    error:
                      code: invalid_request
                      message: >-
                        Unknown field: topK. The /v1/query endpoint does not
                        accept retrieval tuning parameters.
                      request_id: 550e8400-e29b-41d4-a716-446655440000
                too_large:
                  value:
                    error:
                      code: invalid_request
                      message: >-
                        Request body too large for an async job (limit 32768
                        bytes of JSON).
                      request_id: 550e8400-e29b-41d4-a716-446655440000
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            404 Not Found — **endpoint switched off**. Not expected in
            normal operation: you only see it if the operator kill switch
            for async jobs has been turned off, in which case every caller
            receives this bare response: status 404, body `{}`, no error
            envelope and no `X-Request-Id` — indistinguishable from an
            unknown route by design. There is nothing to retry; contact
            partners@collectivex.health.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
              example: {}
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/JobSubmitUnavailable'
      security:
        - bearerAuth: []
components:
  schemas:
    QueryRequest:
      type: object
      additionalProperties: false
      description: >
        Request body for POST /v1/query. Uses additionalProperties: false
        (mirrors

        Zod .strict() on the server, matching ChatRequest's convention). Exactly

        one of `question` or `messages` is required — not both. Retrieval tuning

        knobs are intentionally not accepted here either, for the same reason as

        /v1/chat: we curate retrieval, partners don't tune.
      oneOf:
        - required:
            - question
        - required:
            - messages
      properties:
        question:
          type: string
          minLength: 1
          description: |
            A single question, plain string. The preferred shape for /v1/query
            callers — no message envelope needed. Internally wrapped into a
            single-element UIMessage before being passed to the same pipeline
            that /v1/chat uses, so behavior is identical to sending that text as
            the sole message on /v1/chat.
        messages:
          type: array
          description: |
            Single-element AI SDK v6 UIMessage array, provided for continuity
            with callers already building /v1/chat request bodies who want to
            switch endpoints with a minimal diff. Unlike /v1/chat, this array
            MUST contain exactly one message with role "user" — /v1/query has
            no multi-turn concept. Sending more than one message, or a message
            with a different role, is a 400 invalid_request (use /v1/chat with
            stream:false instead if you need conversation history).
          minItems: 1
          maxItems: 1
          items:
            $ref: '#/components/schemas/UIMessage'
        response_format:
          type: string
          enum:
            - text
            - structured
          default: text
          description: |
            **BETA.** When omitted or `"text"`, the response is byte-for-byte
            the `SyncResponse` this endpoint has always returned — no behavior
            change. When `"structured"`, the response ADDS a `structured_answer`
            field (machine-readable sections/claims/citations) alongside the
            unchanged `answer`/`citations` fields; nothing is removed or
            replaced. This field, and the shape it produces, may change in a
            future minor version while in beta.
    JobAcceptedResponse:
      type: object
      description: |
        `202` body from `POST /v1/jobs`. A receipt, not a result: it tells
        you where to poll and how long the job will be readable.
      required:
        - request_id
        - id
        - job_type
        - status
        - poll_url
        - expires_at
        - created_at
      properties:
        request_id:
          type: string
          format: uuid
          description: >-
            UUID minted for this submission. The job's background execution
            reuses it for tracing, so quote it to support.
        id:
          type: string
          format: uuid
          description: The job's id. Use it with `GET /v1/jobs/{id}`.
        job_type:
          type: string
          description: Always `query` today — see `JobResponse.job_type`.
          example: query
        status:
          type: string
          enum:
            - queued
          description: >-
            Always `queued` on the 202 — execution begins in the background
            immediately after this response is sent.
        poll_url:
          type: string
          description: |
            Path to poll, relative to the API base URL you called (e.g.
            `/v1/jobs/8f14e45f-…`). Deliberately not an absolute URL: the
            host you called is the host to poll.
          example: /v1/jobs/8f14e45f-ceea-467e-adc0-7a1a6b5f6b28
        expires_at:
          type: string
          format: date-time
          description: >-
            When the job and its result stop being readable — one hour after
            submission. Fetch your result before then.
        created_at:
          type: string
          format: date-time
          description: When the job was created.
    ErrorEnvelope:
      type: object
      description: >
        Standard partner API error envelope (D-09). Mirrors Anthropic/OpenAI
        error shape.

        Parse `error.code` for machine-readable error classification.
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
            - request_id
          properties:
            code:
              type: string
              description: >
                Machine-readable error code (D-10 locked enum). Use this for
                branching logic.

                Adding new codes is a minor version bump per the versioning
                policy.
              enum:
                - unauthorized
                - forbidden
                - rate_limited
                - invalid_request
                - corpus_unavailable
                - grounding_deferral
                - crisis_resources
                - not_found
                - internal_error
            message:
              type: string
              description: >-
                Human-readable error description. Use for logging; do not parse
                programmatically.
            request_id:
              type: string
              format: uuid
              description: UUID for this request. Include when contacting support.
    UIMessage:
      type: object
      description: >
        AI SDK v6 UIMessage shape. id + role + parts are the load-bearing
        fields.

        `file` parts are rejected with `400 invalid_request` on every endpoint —

        this API's contract is text questions in, grounded text answers out, and

        file URLs are never forwarded upstream. Text parts (and, on replayed

        assistant history, the `data-*` parts our own stream emits) are
        accepted.
      required:
        - id
        - role
        - parts
      properties:
        id:
          type: string
          description: >
            Unique message identifier. AI SDK clients (e.g. useChat)
            auto-generate

            this; raw HTTP callers MUST provide it (any opaque string — e.g.
            UUID,

            partner-side conversation row id). validateUIMessages rejects
            messages

            without an id.
        role:
          type: string
          enum:
            - user
            - assistant
            - system
          description: Message sender role.
        parts:
          type: array
          description: >
            Message content as an array of parts. Each part has a `type` field.

            For user messages: use `{ "type": "text", "text": "..." }`.

            For assistant messages: the API returns text-delta parts assembled
            into parts.
          items:
            $ref: '#/components/schemas/UIMessagePart'
    UIMessagePart:
      type: object
      description: A single content part within a UIMessage.
      required:
        - type
      properties:
        type:
          type: string
          description: 'Part type. Common values: "text", "tool-invocation", "tool-result".'
        text:
          type: string
          description: Text content (present when type is "text").
  responses:
    Unauthorized:
      description: >
        401 Unauthorized. Missing, malformed, or invalid API key. Check that the
        Authorization

        header is present and has the form `Bearer <key>`. An EXPIRED session
        token also

        returns 401, with an "API key expired" message — session-token clients
        should treat

        that specific message as "request a fresh token from your backend", not
        as an alert.

        Session tokens additionally return 401 whenever the session-token
        feature is disabled

        (the feature flag is a kill switch for outstanding keys).
      headers:
        X-Request-Id:
          description: Request UUID.
          schema:
            type: string
            format: uuid
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: unauthorized
              message: >-
                Missing or malformed Authorization header. Expected: Bearer
                <key>
              request_id: 550e8400-e29b-41d4-a716-446655440000
    Forbidden:
      description: >
        403 Forbidden. Key is valid but does not have permission for this
        operation. Four

        cases, distinguishable by `error.message`: (1) the key's scope excludes
        this

        endpoint — e.g. a session token calling anything other than /v1/query or
        /v1/chat,

        including /v1/session-tokens itself; (2) the key's total request
        allowance is

        exhausted ("…allowance is exhausted. Mint a new session token." —
        permanent for

        that key, do not retry; mint a new token); (3) "This session token is
        not usable.

        Mint a new one." — a SERVER-SIDE provisioning fault, not a client
        mistake: do NOT

        re-mint in a loop (each mint is a metered request and the replacement
        will fail the

        same way) — surface the error and contact partners@collectivex.health;
        (4) an

        account-level restriction — contact partners@collectivex.health.
      headers:
        X-Request-Id:
          description: Request UUID.
          schema:
            type: string
            format: uuid
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: forbidden
              message: This key does not have permission to access /v1/chat.
              request_id: 550e8400-e29b-41d4-a716-446655440000
    RateLimited:
      description: >
        429 Too Many Requests. Rate limit exceeded. Read the Retry-After header
        (seconds)

        before retrying. Also check X-RateLimit-Remaining on successful
        responses to

        self-throttle before hitting 429. Limits are per-key configurable (a
        partner may

        have a non-default requests-per-minute ceiling and/or a requests-per-day
        ceiling);

        X-RateLimit-* on this response always reflects whichever budget was
        exceeded.
      headers:
        Retry-After:
          description: >-
            Seconds until the rate-limit window resets. Wait this long before
            retrying.
          required: true
          schema:
            type: integer
            example: 37
        X-Request-Id:
          description: Request UUID.
          schema:
            type: string
            format: uuid
        X-RateLimit-Limit:
          description: >-
            Maximum requests for the window that was exceeded (per-minute by
            default, or per-day if this key has a daily budget and that's what
            was hit).
          schema:
            type: integer
            example: 60
        X-RateLimit-Remaining:
          description: Requests remaining (0 when rate-limited).
          schema:
            type: integer
            example: 0
        X-RateLimit-Reset:
          description: Unix timestamp (seconds) when the window resets.
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: rate_limited
              message: Rate limit exceeded. Retry after 37s.
              request_id: 550e8400-e29b-41d4-a716-446655440000
    InternalError:
      description: >
        500 Internal Server Error. Unexpected server-side failure. Retry with
        exponential

        backoff. If persistent, contact partners@collectivex.health with the
        request_id.
      headers:
        X-Request-Id:
          description: Request UUID.
          schema:
            type: string
            format: uuid
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: internal_error
              message: An unexpected error occurred. Please retry.
              request_id: 550e8400-e29b-41d4-a716-446655440000
    JobSubmitUnavailable:
      description: |
        503 Service Unavailable. A TRANSIENT failure in the auth/rate-limit
        dependency every partner endpoint shares (Unkey), so the job could
        not be accepted. Always carries `error.code: "internal_error"` and
        a `Retry-After` header (jittered); retry the submission with
        exponential backoff — nothing was created. Unlike `/v1/query`,
        this endpoint never returns `corpus_unavailable` at submission:
        the research corpus is not touched until the job runs in the
        background, and a corpus outage during execution surfaces as a
        `failed` job with `error_code: "corpus_unavailable"` on
        `GET /v1/jobs/{id}`, not as a 503 here.
      headers:
        X-Request-Id:
          description: Request UUID.
          schema:
            type: string
            format: uuid
        Retry-After:
          description: Seconds to wait before retrying. Always present on this response.
          required: true
          schema:
            type: integer
            example: 5
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: internal_error
              message: Rate limit service temporarily unavailable. Please retry.
              request_id: 550e8400-e29b-41d4-a716-446655440000
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: cxh_pk_live_*
      description: |
        Bearer token used by every `/v1/*` endpoint. Two kinds of key work here:
          - **Long-lived partner key** (`cxh_pk_live_*`) — issued by
            CollectiveX Health (contact partners@collectivex.health). Keep
            it server-side only — never expose it in client bundles or
            mobile app code. This rule is absolute and does not change for
            any endpoint.
          - **Short-lived session key** — minted server-side via
            `POST /v1/session-tokens` from a long-lived key. Safe to hand
            to a native mobile client only — not a browser or other
            JavaScript context, see "API keys" in the description above.
            It's scoped to `/v1/query` and `/v1/chat` only, expires in at
            most 60 minutes, and can't mint further session keys.
            `POST /v1/session-tokens` itself only ever accepts the
            long-lived key.

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.