openapi: 3.1.0

info:
  title: Quorum Transcript API
  version: "1.0.0"
  summary: Transcripts of UK council and parliamentary committee meetings, as JSON.
  description: |
    Welcome. This API gives you the text of what was actually said in UK
    council chambers and parliamentary committee rooms — 64,450 transcripts
    across 434 bodies, growing by a few dozen a day.

    It is a small API. Three things to read (councils, transcripts, exports),
    one way to authenticate, one way to paginate. You can be pulling real data
    inside a minute.

    **What v1 covers.** Transcript text, with the meeting metadata attached
    to each transcript — that is the whole surface today. The insights, entities, stakeholders and semantic
    search you may have seen in the Quorum web app are not yet exposed
    programmatically. If your integration needs one of those, tell us: what
    lands in v2 is driven by what customers ask for.

    ## Trying it without a subscription

    [**Account → API**](https://quoruminsight.com/account/api) issues a demo
    key on any account, including a free one,
    with no card. It authenticates against every endpoint below and behaves
    exactly like a full key — same shapes, same pagination, same export
    pipeline — but it reads a fixed sample rather than the corpus:

    - two councils, `birmingham` and `swansea`
    - ten transcripts from each, full text, chosen as the ten oldest so the
      sample never shifts under you
    - a bulk export of those same twenty
    - valid for 14 days, one per account

    At the fixed page size of 10, the sample is two pages — enough to write
    the same pagination loop you would run in production. Anything outside it
    responds as though it does not exist: a council you did not get raises
    `council_not_in_plan`, a transcript you did not get is a `404`. That is
    the key being scoped, not a gap in coverage.

    ## Quickstart

    Create a key at **Account → API**. It is shown once, so store it somewhere
    safe, then send it as a bearer token on every request:

    ```bash
    curl https://quoruminsight.com/api/v1/councils \
      -H "Authorization: Bearer qk_live_..."
    ```

    That returns the list of bodies we cover. Take an `id` from it and pull a
    page of transcripts:

    ```bash
    curl "https://quoruminsight.com/api/v1/transcripts?council_id=birmingham" \
      -H "Authorization: Bearer qk_live_..."
    ```

    Then take a `meeting_uid` from that and read one on its own, as plain text:

    ```bash
    curl "https://quoruminsight.com/api/v1/transcripts/1081560?format=text" \
      -H "Authorization: Bearer qk_live_..."
    ```

    That is the whole tour. Everything below is detail.

    ## Which endpoint do I want?

    | You want | Use |
    | --- | --- |
    | The list of bodies and their ids | `GET /councils` |
    | The text of one meeting | `GET /transcripts/{meeting_uid}` |
    | Everything we hold, once | `POST /exports` |
    | Whatever changed since yesterday | `GET /transcripts?updated_since=…` |

    Every transcript carries its meeting's metadata — title, date, type and
    body — so there is no separate metadata call to make.

    ## The two integration patterns

    Almost every integration is one of these, and picking the wrong one is the
    most common mistake — so start here.

    **1. Backfill — "give me your whole archive."**
    Use `POST /exports`. It builds gzipped NDJSON files in the background — up
    to 10,000 transcripts per file, no cap on the total — and hands back signed
    download links. Paginating a full backfill would be ~6,500 requests; an
    export is one file set.

    **2. Stay current — "what changed since yesterday?"**
    Use `GET /transcripts?updated_since=<timestamp>`. Store the timestamp of
    your last successful run and pass it back next time. Typically a handful of
    rows a night.

    The normal shape is: export once, then poll `updated_since` forever.

    Both assume you are **mirroring into your own store** and serving your
    users from that, rather than calling us per end-user request. Everything
    here is designed for that: cursors that never skip, `updated_since` for
    cheap deltas, and `ETag`/`304` so an unchanged record costs almost nothing
    to re-check.

    ## Recipes

    **Nightly delta** — the loop most integrations run forever:

    ```python
    import requests

    S = requests.Session()
    S.headers["Authorization"] = f"Bearer {KEY}"

    def sync(since):          # `since` = the watermark you stored last run
        params, high = {"updated_since": since, "limit": 10}, since
        while True:
            page = S.get("https://quoruminsight.com/api/v1/transcripts",
                         params=params, timeout=60).json()
            for row in page["data"]:
                upsert(row)                       # keyed on meeting_uid
                high = max(high, row["updated_at"])
            if not page["has_more"]:
                return high                       # store this as the new watermark
            params = {"cursor": page["next_cursor"]}
    ```

    Two things that matter here. Upsert rather than insert: the `updated`
    ordering can hand you the same row twice if it is touched mid-walk (it
    never hides one from you, which is the trade we wanted). And advance the
    watermark from `updated_at` in the data, not from your own clock.

    **Full backfill** — start an export, wait, download the parts:

    ```bash
    ID=$(curl -s -X POST https://quoruminsight.com/api/v1/exports \
      -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
      -d '{}' | jq -r .data.id)

    until [ "$(curl -s https://quoruminsight.com/api/v1/exports/$ID \
      -H "Authorization: Bearer $KEY" | jq -r .data.status)" = "ready" ]; do
      sleep 30
    done

    curl -s https://quoruminsight.com/api/v1/exports/$ID -H "Authorization: Bearer $KEY" \
      | jq -r '.data.parts[].url' | xargs -n1 -P4 curl -sO
    ```

    Each part is gzipped NDJSON: one JSON object per line, in exactly the shape
    `GET /transcripts/{meeting_uid}` returns, so one parser serves both paths.
    NDJSON rather than a single JSON array because an array cannot be parsed
    without loading the whole file into memory, and a full-corpus export runs
    to several GB.

    **Cheap re-check** — has this one record changed?

    ```bash
    curl -i https://quoruminsight.com/api/v1/transcripts/1081560 \
      -H "Authorization: Bearer $KEY" \
      -H 'If-None-Match: "kPGxUuFrH2rd5MQyoc0lQ7WlZ8k"'
    # HTTP/1.1 304 Not Modified   — no body, no transcript read
    ```

    ## Pagination

    Cursor-based. Follow `next_cursor` until `has_more` is `false`. Never build
    your own cursor — the value is opaque and its format may change.

    Two orderings exist, and a cursor is only valid for the one that issued it:

    - `order=updated` (default on `/transcripts`) walks by last-modified
      **ascending**. This is the complete walk: a record modified while you are
      paginating moves *forward* past your cursor, so you may see it twice but
      you can never miss it. Upsert by `meeting_uid` and you are safe.
    - `order=recent` walks by meeting date descending. Convenient for
      browsing. Records with no date are unreachable under this ordering — 31
      transcripts today — so use `order=updated` when completeness matters.

    Supplying `updated_since` forces `order=updated`.

    `limit` is a **maximum, not a guarantee**. `/transcripts` may return fewer
    rows than you asked for if the page would otherwise get too large — its
    ceiling is 10 either way. Use
    `has_more`, never the row count, to decide whether to keep going.

    ## What is in the corpus

    Figures as of August 2026:

    - **574 bodies** listed by `GET /councils` — 378 UK councils and 196
      parliamentary and Senedd committees. 434 of them have at least one
      transcript; the rest are tracked but not yet producing.
    - **64,450 transcripts**, one per meeting we have text for.
    - Transcripts average ~79 KB of text (median 72 KB, 99th percentile
      276 KB).

    Councils and committees live in one namespace, told apart by `domain`.
    Watch out for two field names that mean something different on the
    parliamentary side: `country` carries the chamber (`Commons`, `Lords`,
    `Senedd`) rather than a nation, and `region` carries a committee grouping
    rather than a UK region. Branch on `domain` before you trust either.

    ## Access and scope

    An API subscription covers **every body in the corpus**, independently of
    any Quorum web plan on the same account.

    Use `?council_id=` to narrow a request. An unrecognised id returns `404`
    rather than an empty page, so a typo surfaces as an error instead of
    looking like missing data. `GET /councils` lists the valid ids.

    ## Billing and limits

    API access is its own annual subscription, separate from the Quorum web
    plans and sold alongside a Business plan so your team keeps the web app
    next to the integration — see
    [pricing](https://quoruminsight.com/pricing).

    There is no metering: **requests and transcripts are unlimited**, and
    nothing is counted against a quota.

    One safeguard exists. Sustained traffic above 600 requests/minute on a
    single key trips a circuit breaker and returns `429`. That is roughly ten
    requests a second — far above any real integration, and in practice it only
    fires when a client is stuck in a retry loop. The API and the Quorum
    website share a database, so this protects everyone. If you have a genuine
    need for that volume, get in touch.

    ## Caching

    Single-resource reads return an `ETag`. Send it back as `If-None-Match` and
    an unchanged record returns `304` with no body. This is the cheapest way to
    poll.

    ## Stability

    The version lives in the path, so `/api/v1` keeps its shape. Within it we
    treat these as non-breaking and may ship them without warning: **new fields
    on existing objects**, new endpoints, new `meeting_type` values, and new
    `error.code` values. Parse permissively — ignore fields you do not
    recognise rather than rejecting the payload, and treat an unfamiliar
    `error.code` as its HTTP status.

    Two things are deliberately not contracts: `error.message` is written for
    humans and will change, and `cursor` values are opaque and may change
    format between releases (never store one).

    ## Errors

    Every error has the same shape, and `code` is the part to branch on —
    `message` is written for a human and may change.

    ```json
    {
      "error": {
        "code": "not_found",
        "message": "No council 'birmingam'. Call GET /api/v1/councils for the valid ids.",
        "docs_url": "https://quoruminsight.com/docs/api"
      }
    }
    ```

    Worth handling explicitly: `429` (back off for `Retry-After` seconds),
    `invalid_cursor` (restart the walk without a cursor), and `500`
    (`internal_error` — safe to retry).

    ## A note on transcript text

    `content` is plain text with no timestamps. Its shape depends on how the
    transcript was produced, which the `source` field tells you:

    - `deepgram` — we transcribed the audio. Speaker labels are baked into the
      prose: `"Speaker 0: Good morning..."`. About 5% of the corpus.
    - `captions` — we ingested the body's own subtitle track. Plain prose, no
      speaker attribution.

    Branch on `source` if speaker labels matter to you.

  contact:
    name: Quorum support
    url: https://quoruminsight.com/contact

servers:
  - url: https://quoruminsight.com/api/v1

security:
  - bearerAuth: []

tags:
  - name: Councils
    description: The bodies we cover, and the ids everything else accepts.
  - name: Transcripts
    description: The text itself. Smaller pages — the payload is ~100x metadata.
  - name: Exports
    description: Bulk download for backfills. Unlimited size, split across files.

paths:
  /councils:
    get:
      tags: [Councils]
      summary: List councils and committees
      operationId: listCouncils
      description: |
        Every body in the corpus — 574 rows today, so there is no pagination.
        This is normally the first call an integration makes: the `id` values
        it returns are what `council_id` accepts everywhere else.

        Includes bodies we track but have no transcripts for yet (434 of the
        574 currently have at least one). Cheap to re-check: the list changes
        rarely, so send back the `ETag` and expect a `304`.
      responses:
        "200":
          description: OK
          headers:
            ETag: { schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Council" }
              examples:
                default:
                  summary: A council and a Commons committee
                  value:
                    data:
                      - id: birmingham
                        name: Birmingham City Council
                        region: West Midlands
                        country: England
                        domain: council
                      - id: ukpc-255
                        name: Secondary Legislation Scrutiny Committee
                        region: Select
                        country: Lords
                        domain: parliament
        "304": { description: Not modified — your `ETag` is still current. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /transcripts:
    get:
      tags: [Transcripts]
      summary: List transcripts with their text
      operationId: listTranscripts
      description: |
        The bulk read path.

        **Page size is 10 and cannot be raised.** Transcripts average ~79 KB,
        so 10 rows is a ~800 KB response; 50 would risk exceeding the 4.5 MB
        response ceiling and failing outright. If a page would still be too
        large, fewer than 10 rows are returned — always drive your loop from
        `has_more`, not from the row count.

        Defaults to `order=updated`, the complete ascending walk. For a full
        backfill use `POST /exports` instead: it is one file set rather than
        ~6,500 requests.
      parameters:
        - { $ref: "#/components/parameters/CouncilId" }
        - { $ref: "#/components/parameters/DateFrom" }
        - { $ref: "#/components/parameters/DateTo" }
        - { $ref: "#/components/parameters/UpdatedSince" }
        - { $ref: "#/components/parameters/MeetingType" }
        - { $ref: "#/components/parameters/Cursor" }
        - { $ref: "#/components/parameters/Order" }
        - name: limit
          in: query
          description: Maximum rows per page. Capped at 10.
          schema: { type: integer, minimum: 1, maximum: 10, default: 10 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Transcript" }
                  has_more: { type: boolean }
                  next_cursor: { type: [string, "null"] }
              examples:
                default:
                  summary: One row of a delta walk (text truncated here)
                  value:
                    data:
                      - meeting_uid: "1081560"
                        council_id: birmingham
                        council_name: Birmingham City Council
                        title: BES Approval & Fees
                        meeting_type: planning
                        live_date: "2026-04-16T10:00:00.000Z"
                        summary: >-
                          Three procurement-relevant signals emerged: (1) a £2,000 general
                          monitoring administration fee to be added to the legal agreement
                          for the development project; (2) a planning decision on the
                          battery energy storage system (BES) application…
                        characters: 48862
                        content: "Good morning everyone and welcome to the April meeting of the City Council Planning Committee…"
                        content_url: null
                        analyzed_at: "2026-04-29"
                        updated_at: "2026-04-29T14:20:53.805Z"
                    has_more: true
                    next_cursor: eyJ2IjoxLCJvIjoidXBkYXRlZCIsi...
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /transcripts/{meeting_uid}:
    get:
      tags: [Transcripts]
      summary: Get one transcript
      operationId: getTranscript
      description: |
        The full record, or with `?format=text` the bare text as `text/plain`
        with no envelope — pipe that straight into whatever consumes it.

        Returns an `ETag` (distinct per format). A `304` here is genuinely
        cheap: it is answered from a small metadata row without reading the
        transcript body at all.
      parameters:
        - name: meeting_uid
          in: path
          required: true
          description: The `uid` of the meeting, not a separate transcript id.
          schema: { type: string }
          example: "1081560"
        - name: format
          in: query
          description: |
            `json` returns the full record. `text` returns the bare transcript
            as `text/plain`. Anything else is a `400`.
          schema: { type: string, enum: [json, text], default: json }
      responses:
        "200":
          description: OK
          headers:
            ETag: { schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Transcript" }
              examples:
                default:
                  summary: format=json (text truncated here)
                  value:
                    data:
                      meeting_uid: "1081560"
                      council_id: birmingham
                      council_name: Birmingham City Council
                      title: BES Approval & Fees
                      meeting_type: planning
                      live_date: "2026-04-16T10:00:00.000Z"
                      summary: >-
                        Three procurement-relevant signals emerged: (1) a £2,000 general
                        monitoring administration fee to be added to the legal agreement
                        for the development project…
                      characters: 48862
                      content: "Good morning everyone and welcome to the April meeting of the City Council Planning Committee. For those who don't know, I'm Councillor Neumarsh, I'm the chair of the committee…"
                      content_url: null
                      analyzed_at: "2026-04-29"
                      updated_at: "2026-04-29T14:20:53.805Z"
            text/plain:
              schema: { type: string }
              examples:
                default:
                  summary: format=text
                  value: |
                    Good morning everyone and welcome to the April meeting of the City
                    Council Planning Committee. For those who don't know, I'm Councillor
                    Neumarsh, I'm the chair of the committee…
        "304": { description: Not modified — your `ETag` is still current. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /exports:
    post:
      tags: [Exports]
      summary: Start a bulk export
      operationId: createExport
      description: |
        Queues a job and returns immediately with `202`. Poll
        `GET /exports/{id}` until `status` is `ready`, then download each part.

        **There is no size limit.** Send `{}` and you get the entire archive.
        Large exports are split into files of up to 10,000 transcripts each and
        are built incrementally across several background runs, so a
        full-corpus export takes a few minutes and reports `running`
        throughout. `row_count` and `parts` grow as it progresses — wait for
        `ready` before downloading.

        Parts are gzipped NDJSON: one JSON object per line, in exactly the
        shape `GET /transcripts/{meeting_uid}` returns, so a single parser
        serves both paths. In an export `content` is always inline — the
        `content_url` fallback never appears.

        The same filters as `GET /transcripts` apply, minus pagination.
      requestBody:
        description: Filters. An empty object exports everything.
        content:
          application/json:
            schema:
              type: object
              properties:
                council_id:
                  type: string
                  description: One body id, as returned by `GET /councils`.
                date_from: { type: string, format: date }
                date_to: { type: string, format: date }
                updated_since: { type: string, format: date-time }
                meeting_type:
                  type: string
                  description: |
                    Comma-separated committee types. Same caveat as the query
                    parameter: filtering excludes the ~7% of meetings with no
                    classified type.
                  example: planning
            examples:
              everything:
                summary: The whole archive
                value: {}
              scoped:
                summary: One council, planning only, this year
                value:
                  council_id: birmingham
                  meeting_type: planning
                  date_from: "2026-01-01"
      responses:
        "202":
          description: Accepted — the job is queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      status: { type: string, enum: [queued] }
                      estimated_rows:
                        type: integer
                        description: |
                          A planner estimate so you know roughly what you asked
                          for, not an exact count — expect it to differ from
                          the final `row_count`. Nothing gates on it.
                      created_at: { type: string, format: date-time }
              examples:
                default:
                  value:
                    data:
                      id: 4f6d2c18-0b2e-4f0a-9c1d-6a1f3b8e77aa
                      status: queued
                      estimated_rows: 64450
                      created_at: "2026-08-19T09:14:02.118Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /exports/{id}:
    get:
      tags: [Exports]
      summary: Poll an export
      operationId: getExport
      description: |
        Poll every 30 seconds or so until `status` is `ready`. Keys only see
        exports belonging to their own account; anything else is a `404`.
      parameters:
        - name: id
          in: path
          required: true
          description: The `id` returned by `POST /exports`.
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Export" }
              examples:
                running:
                  summary: Still building — parts are empty until it is ready
                  value:
                    data:
                      id: 4f6d2c18-0b2e-4f0a-9c1d-6a1f3b8e77aa
                      status: running
                      format: ndjson.gz
                      row_count: 20000
                      bytes: 161138448
                      parts: []
                      expires_at: null
                      error: null
                      created_at: "2026-08-19T09:14:02.118Z"
                      updated_at: "2026-08-19T09:17:44.902Z"
                ready:
                  summary: Ready — download every `parts[].url`
                  value:
                    data:
                      id: 4f6d2c18-0b2e-4f0a-9c1d-6a1f3b8e77aa
                      status: ready
                      format: ndjson.gz
                      row_count: 64450
                      bytes: 519204411
                      parts:
                        - index: 1
                          rows: 10000
                          bytes: 80569224
                          url: https://…/api-exports/…/part-0001.ndjson.gz?token=…
                        - index: 2
                          rows: 10000
                          bytes: 80447118
                          url: https://…/api-exports/…/part-0002.ndjson.gz?token=…
                      expires_at: "2026-08-26T09:21:10.004Z"
                      error: null
                      created_at: "2026-08-19T09:14:02.118Z"
                      updated_at: "2026-08-19T09:21:10.004Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer qk_live_...`

        Keys do not expire. You can hold up to 10 live keys at once, so rotate
        by creating the new one, moving traffic, then revoking the old.
        Revoking takes effect within 60 seconds.

  parameters:
    CouncilId:
      name: council_id
      in: query
      description: |
        Restrict to one body. An unknown id returns `404` rather than an empty
        result — see `GET /councils` for valid ids.
      schema: { type: string }
      example: birmingham
    DateFrom:
      name: date_from
      in: query
      description: Only meetings held on or after this date (YYYY-MM-DD), inclusive.
      schema: { type: string, format: date }
      example: "2026-01-01"
    DateTo:
      name: date_to
      in: query
      description: Only meetings held on or before this date (YYYY-MM-DD), inclusive.
      schema: { type: string, format: date }
      example: "2026-06-30"
    UpdatedSince:
      name: updated_since
      in: query
      description: |
        Only records modified at or after this ISO 8601 timestamp. Forces
        `order=updated`.

        Filters on `updated_at`, which is a superset of "the text changed":
        background analysis passes touch a record without altering its
        transcript, so you may see rows whose `content` is identical to last
        time. Harmless, just slightly chatty.
      schema: { type: string, format: date-time }
      example: "2026-08-18T00:00:00Z"
    MeetingType:
      name: meeting_type
      in: query
      description: |
        Restrict to one or more committee types, comma-separated —
        `?meeting_type=environment_transport,cabinet_executive`. A single type
        is rarely the whole story, so lists are the norm.

        **Filtering excludes untyped meetings.** About 7% of meetings have no
        classified type, and they will not appear in a filtered result. If you
        are mirroring our data and completeness matters, either omit this
        filter or run a second unfiltered pass.

        An unrecognised value returns `400` listing the valid ones.
      schema:
        type: string
        example: planning,licensing
      examples:
        planning:
          summary: Planning applications
          value: planning
        transport:
          summary: Transport and infrastructure signals
          value: environment_transport,cabinet_executive,finance_resources
    Cursor:
      name: cursor
      in: query
      description: |
        Opaque. Pass back the `next_cursor` from the previous page, and send it
        on its own — the filters that produced it are already baked in.
      schema: { type: string }
    Order:
      name: order
      in: query
      description: |
        `updated` is the complete walk (last-modified ascending, never skips).
        `recent` is newest-meeting-first, for browsing. A cursor is only valid
        for the ordering that produced it, so keep it constant for a whole
        walk.
      schema: { type: string, enum: [recent, updated] }

  schemas:
    Council:
      type: object
      description: A body we cover — a UK council, or a parliamentary or Senedd committee.
      properties:
        id:
          type: string
          description: What `council_id` accepts everywhere else. Stable.
          example: birmingham
        name: { type: string, example: Birmingham City Council }
        region:
          type: [string, "null"]
          description: |
            UK region when `domain` is `council` (e.g. `West Midlands`). When
            `domain` is `parliament` this carries a committee grouping instead
            — `Select`, `General`, `Committees`.
          example: West Midlands
        country:
          type: [string, "null"]
          description: |
            `England`, `Scotland`, `Wales` or `Northern Ireland` when `domain`
            is `council`. When `domain` is `parliament` this carries the
            **chamber** — `Commons`, `Lords` or `Senedd` — not a nation.
          example: England
        domain:
          type: string
          enum: [council, parliament]
          description: |
            `council` for local authorities, `parliament` for UK parliamentary
            and Senedd committees. Check this before interpreting `region` or
            `country`.
          example: council

    Transcript:
      type: object
      properties:
        meeting_uid:
          type: string
          description: The meeting's identifier, and your upsert key.
          example: "1081560"
        council_id: { type: [string, "null"], example: birmingham }
        council_name:
          type: [string, "null"]
          description: Convenience only — join on `council_id` for a reliable name.
        title:
          type: [string, "null"]
          description: The meeting's title.
        meeting_type:
          type: [string, "null"]
          description: |
            Classified committee type. Null for the ~7% we have not classified
            — those are excluded whenever you filter on `meeting_type`.
          enum:
            - full_council
            - cabinet_executive
            - scrutiny
            - audit_governance
            - planning
            - licensing
            - regulatory
            - finance_resources
            - housing
            - environment_transport
            - health_social_care
            - children_education
            - regeneration_economy
            - community_safety
            - area_community
            - culture_leisure
            - staffing
            - parliament_committee
            - null
          example: planning
        live_date: { type: [string, "null"], format: date-time }
        summary:
          type: [string, "null"]
          description: |
            A generated summary written for a procurement audience — it leads
            on spend, contracts and tender signals rather than summarising the
            agenda evenly. Null on a handful of rows.
        characters:
          type: integer
          description: Length of the full transcript text, in characters — including when `content` is null.
          example: 48862
        content:
          type: [string, "null"]
          description: |
            The transcript text. Plain prose, no timestamps.

            Null only when the transcript exceeds the 1 MB inline limit, in
            which case `content_url` is populated instead. Nothing in the
            corpus is close to that today (the largest is under 500 KB), but
            handle both branches — the threshold may be lowered.
        content_url:
          type: [string, "null"]
          description: |
            Where to fetch the text when `content` is null. On
            `GET /transcripts/{meeting_uid}` this is a signed download link
            valid for 10 minutes. In a list response it is instead a URL back
            to this record's own single-fetch endpoint, which needs your
            bearer token like any other call. Always null in exports.
        analyzed_at:
          type: [string, "null"]
          description: Date (YYYY-MM-DD) our analysis last ran over this transcript.
        updated_at:
          type: [string, "null"]
          format: date-time
          description: What `updated_since` filters on. Advance your watermark from this field.

    Export:
      type: object
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [queued, running, ready, failed, expired]
          description: |
            `queued` — accepted, not started.
            `running` — being built; a large export passes through several
            background runs, so this can persist for minutes while `row_count`
            and `parts` climb.
            `ready` — complete and downloadable. Only now are `parts` populated.
            `failed` — see `error`. Start a new export.
            `expired` — past `expires_at`; the files are gone and `parts` is
            empty. Start a new export.
        format: { type: string, enum: [ndjson.gz] }
        row_count:
          type: [integer, "null"]
          description: Transcripts written so far. Final once `status` is `ready`.
        bytes:
          type: [integer, "null"]
          description: Total compressed size written so far.
        parts:
          type: array
          description: |
            Signed download links, one per file, each holding up to 10,000
            transcripts (the last one, and any part closed early by a
            background run boundary, may be shorter). Empty until `status` is
            `ready`.

            Links are minted fresh on every poll and the export itself lives
            for 7 days — so re-poll for a new link any time within that window,
            and after it the export is `expired` rather than re-signable.
          items:
            type: object
            properties:
              index: { type: integer, description: "1-based, in walk order." }
              rows: { type: integer }
              bytes: { type: integer, description: Compressed size of this part. }
              url: { type: [string, "null"] }
        expires_at:
          type: [string, "null"]
          format: date-time
          description: Set when the export becomes `ready`; 7 days later.
        error:
          type: [string, "null"]
          description: Populated only when `status` is `failed`.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: |
                Branch on this, not on `message`.

                `invalid_request` — a parameter is malformed; `message` says which.
                `invalid_cursor` — the cursor is unreadable, or belongs to a
                different `order`. Restart the walk without a cursor.
                `missing_key` — no usable `Authorization: Bearer` header.
                `invalid_key` — unknown, revoked or expired key.
                `api_not_enabled` — the account has no active API subscription.
                `council_not_in_plan` — reserved for keys restricted to a
                subset of bodies; no key is restricted today.
                `key_limit_reached` — reserved for key-creation limits, which
                are enforced in the dashboard rather than here.
                `not_found` — no such record, or not yours.
                `rate_limited` — circuit breaker; see `Retry-After`.
                `internal_error` — our fault. Safe to retry.
              enum:
                - invalid_request
                - invalid_cursor
                - missing_key
                - invalid_key
                - api_not_enabled
                - council_not_in_plan
                - key_limit_reached
                - not_found
                - rate_limited
                - internal_error
            message:
              type: string
              description: Human-readable, and subject to change. Do not parse it.
            docs_url: { type: string }

  responses:
    BadRequest:
      description: Malformed parameters, or an unusable cursor.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            invalid_request:
              value:
                error:
                  code: invalid_request
                  message: "meeting_type: Unknown meeting type(s): plannning. Valid: full_council, cabinet_executive, scrutiny, …"
                  docs_url: https://quoruminsight.com/docs/api
            invalid_cursor:
              value:
                error:
                  code: invalid_cursor
                  message: "This cursor was issued for order='updated' but the request asked for order='recent'. Keep the order constant while paginating."
                  docs_url: https://quoruminsight.com/docs/api
    Unauthorized:
      description: Missing, malformed, revoked or expired key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            default:
              value:
                error:
                  code: invalid_key
                  message: API key is not valid, has been revoked, or has expired.
                  docs_url: https://quoruminsight.com/docs/api
    Forbidden:
      description: The key is valid, but the account has no active API subscription.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            default:
              value:
                error:
                  code: api_not_enabled
                  message: This account does not have an active Quorum API subscription.
                  docs_url: https://quoruminsight.com/docs/api
    NotFound:
      description: |
        No such record, no such `council_id`, or the record belongs to another
        account.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            default:
              value:
                error:
                  code: not_found
                  message: "No council 'birmingam'. Call GET /api/v1/councils for the valid ids."
                  docs_url: https://quoruminsight.com/docs/api
    RateLimited:
      description: |
        The circuit breaker tripped: sustained traffic above 600 req/min on one
        key. Not a usage quota — almost always a client stuck in a retry loop.
        `Retry-After` says how long to wait.
      headers:
        Retry-After:
          description: Seconds until the current minute-window resets.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            default:
              value:
                error:
                  code: rate_limited
                  message: Sustained request rate exceeded 600/minute, which is far above normal use — this usually means a client is stuck in a retry loop. Retry in 24s, or contact support if you have a genuine need for this volume.
                  docs_url: https://quoruminsight.com/docs/api
    InternalError:
      description: Something broke on our side. Safe to retry.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            default:
              value:
                error:
                  code: internal_error
                  message: Something went wrong. Try again.
                  docs_url: https://quoruminsight.com/docs/api
