> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.westiq.ai/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.westiq.ai/_mcp/server.

# Catalog Serve

`/v1/catalog/serve` returns your airline's visible catalog, **age-gated**. It
has two verbs:

* **`GET`** — the unscored read: the full visible catalog in a stable order,
  age-gated to a seat/member if you name one.
* **`POST`** — the scored serve: the same age-gated catalog **ranked for one
  member** by their bound persona and affinity.

Both apply the [age-tier model](/guides/sessions#age-tiers) and the fail-closed
[locator rules](/guides/sessions#seat--member-locators): an unknown or unbound
member is served the most-restrictive (child-safe) catalog, never the full one,
and never an error.

## Content kinds \[#kinds]

The `kind` filter accepts all four kinds the catalog carries — `Movie`, `TV`,
`Audio`, `Game` — on **both** verbs, in any casing. Omitting it applies no
kind filter at all.

> **Note**
>
> **Serve takes canonical values only — no synonyms.** `music`, `albums`, and
> `games` resolve on [search's `kindOverride`](/guides/search#kinds) but are a
> `422 request.validation` here. The difference is deliberate: a free-text
> query surface and a structured filter parameter are not the same thing.
> Don't assume parity because you learned the search vocabulary first.

> **Warning**
>
> **A child/tween serve for `Audio` or `Game` returns an empty list today.**
> The rating vocabulary carries no audio/game scale yet, so those titles are
> [ingested with a `null` rating](/guides/catalog-ingest#all-four-kinds-ingest-end-to-end)
> — and a null rating fails closed for every tier but `Adult`. So a serve
> for a `Child`, `Tween`, or `Unknown` member with `kind=Audio` or
> `kind=Game` returns an **empty `items` list with the exclusion counted
> honestly in `ageExcludedCount`** — never an error. Those titles stay hidden
> from younger members until a real rating vocabulary lands; they serve
> normally to `Adult` members.

## GET — unscored read

[Airline-scoped](/guides/sessions#endpoints-that-need-no-session) — no
`X-Session-Id`. All query parameters are optional:

| Param          | Rules                                                                                                                                                                                                                                                                                                                                                                        |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kind`         | `Movie`, `TV`, `Audio`, or `Game` (any casing). Anything else → `422 request.validation`, with a message listing the four accepted values. Omitted = **no kind filter**, so every kind is returned.                                                                                                                                                                          |
| `limit`        | Integer 1–500. Omitted = no cap (the full page). Out of range → 422.                                                                                                                                                                                                                                                                                                         |
| `offset`       | Integer ≥ 0. Omitted = 0. Negative → 422.                                                                                                                                                                                                                                                                                                                                    |
| `seatId`       | Seat locator. Untrusted — an absent or unbound seat resolves to the child-safe tier, never a 4xx.                                                                                                                                                                                                                                                                            |
| `memberSlot`   | Member locator within the seat. Omit for a solo traveler.                                                                                                                                                                                                                                                                                                                    |
| `sourceSystem` | Optional id-namespace token — the same `sourceSystem` your titles were [centrally ingested](/guides/catalog-ingest) under (1–64 lowercase letters, digits, and hyphens; anything else → `422 request.validation`). When present, each item echoes your own `externalTitleId` under that namespace — see [your own ids on serve responses](#external-ids). Omitted = no echo. |

```bash
curl "https://api.staging.westiq.ai/v1/catalog/serve?kind=Movie&limit=50&seatId=12A" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

```json
{
  "schemaVersion": "1",
  "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d",
  "items": [
    {
      "titleId": 2041,
      "kind": "Movie",
      "title": "The Quiet Coast",
      "year": 1998,
      "genres": ["Drama"],
      "cast": ["A. Rivera"],
      "rating": "PG",
      "synopsis": "A retired lighthouse keeper…",
      "runtimeMinutes": 96,
      "language": "English",
      "keywords": ["lighthouse", "slow cinema"],
      "imdbRating": 7.4,
      "rtTomatometer": 8.1,
      "hasAwards": false,
      "artwork": {
        "posterUrl": "https://media.example.com/posters/quiet-coast.webp",
        "heroUrl": "https://media.example.com/heroes/quiet-coast.webp"
      }
    }
  ],
  "totalItems": 45,
  "ageExcludedCount": 7,
  "ageTierApplied": "Child"
}
```

* `titleId` is an **integer** (see the [note on ids](/guides/sessions#the-x-session-id-header)).
  `year: 0` means an unknown year; every other unknown field is **omitted**
  (the API never sends a field as `null`, and never a `0` placeholder).
* `totalItems` is the size of what this request could serve **before** age
  exclusion; `ageExcludedCount` is how many the gate removed — reported
  honestly, never papered over. `totalItems − ageExcludedCount` is the
  servable total.
* `ageTierApplied` is the tier the server resolved for your locator (`Unknown`
  is shown as-is and behaves as `Child`).

### Item fields \[#item-fields]

Both verbs serve the same item shape (the scored POST adds `score` and
`matchReason` on top). Render runtime, language, and review badges straight
off the response — there is no metadata sidecar to fetch:

| Field                                                   | Type      | Semantics                                                                                                                                                                                                           |
| ------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `titleId`                                               | integer   | Our opaque platform id — what [suggestions](/guides/suggestions) and [events](/guides/behavioral-events) take.                                                                                                      |
| `kind`                                                  | string    | `Movie` / `TV` / `Audio` / `Game`.                                                                                                                                                                                  |
| `title`, `year`, `genres`, `cast`, `rating`, `synopsis` | —         | As before. `year: 0` = unknown; `rating` / `synopsis` omitted when unknown. `genres` entries are always the [vocabulary's canonical casing](/guides/catalog-ingest#genres).                                         |
| `runtimeMinutes`                                        | integer   | Whole minutes — a movie's feature length; a TV series carries its **typical episode length**. Omitted when unknown, never `0` as a placeholder.                                                                     |
| `language`                                              | string    | The title's original-audio language as a canonical full English name (`"English"`, `"Japanese"`). Omitted when unknown.                                                                                             |
| `keywords`                                              | string\[] | Free-form franchise/entity/theme tokens (`["James Bond", "007"]`). Empty = none known.                                                                                                                              |
| `imdbRating`                                            | number    | IMDB rating on the **0–10** scale. Omitted when not rated / unknown, never `0`.                                                                                                                                     |
| `rtTomatometer`                                         | number    | Rotten Tomatoes Tomatometer, also on a **0–10** scale here (`8.1`, not `81`). Omitted when unknown, never `0`.                                                                                                      |
| `hasAwards`                                             | boolean   | `true` when the title's own source data carries award recognition (award-body names ride in `keywords`). `false` = **no award data** — an absence of data, never a quality judgment.                                |
| `artwork`                                               | object    | Optional title artwork with `posterUrl` and/or `heroUrl`. The block is omitted when neither exists; absent members are omitted, never returned as `null`. Values are the exact HTTPS references supplied at ingest. |
| `externalTitleId`                                       | string    | **Your** id for this title — only when the request names a `sourceSystem`. See [below](#external-ids).                                                                                                              |

### Your own ids on serve responses \[#external-ids]

Name a `sourceSystem` on the request (GET query parameter, POST body field)
and each served item echoes the `externalTitleId` **you** ingested it under —
so you can key your own state on serve responses without holding a reverse
map of the catalog:

```bash
curl "https://api.staging.westiq.ai/v1/catalog/serve?sourceSystem=partner-cms&limit=50&seatId=12A" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

```json
{
  "titleId": 10000000,
  "kind": "Movie",
  "title": "Northbound",
  "externalTitleId": "CMS-88421"
}
```

`externalTitleId` is present only for titles with a **confirmed ingest
mapping** under that namespace. It is omitted for everything else — titles
you never ingested (the shared global catalog, another feed's titles), and
titles whose [ingest batch has not completed yet](/guides/catalog-ingest#resolvable). An item without the echo is still
fully servable; the omission means "not your id", not "not available".

## POST — scored serve \[#scored-serve]

The keystone personalization read: the age-gated catalog **ranked for one
member**. This is [session-scoped](/guides/sessions#the-x-session-id-header) —
it **requires the `X-Session-Id` header**. Body (max 16 KiB):

| Field          | Required | Rules                                                                                                                                                                            |
| -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `seatId`       | yes      | Non-blank, or `422 request.validation`.                                                                                                                                          |
| `memberSlot`   | no       | Omit for a solo traveler.                                                                                                                                                        |
| `kind`         | no       | `Movie` / `TV` / `Audio` / `Game`, any casing, or omit for no kind filter (422 otherwise).                                                                                       |
| `limit`        | no       | 1–500, or no cap.                                                                                                                                                                |
| `offset`       | no       | ≥ 0.                                                                                                                                                                             |
| `sourceSystem` | no       | Same rules and effect as [on the GET](#external-ids): 1–64 lowercase letters, digits, and hyphens (422 otherwise); each item echoes your `externalTitleId` under that namespace. |

```bash
curl -X POST "https://api.staging.westiq.ai/v1/catalog/serve" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "X-Session-Id: seat-12A-f3d9c1" \
  -H "Content-Type: application/json" \
  -d '{ "seatId": "12A", "kind": "Movie", "limit": 20 }'
```

```json
{
  "schemaVersion": "1",
  "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d",
  "items": [
    {
      "titleId": 2041,
      "kind": "Movie",
      "title": "The Quiet Coast",
      "year": 1998,
      "genres": ["Drama"],
      "cast": ["A. Rivera"],
      "rating": "PG",
      "runtimeMinutes": 96,
      "language": "English",
      "keywords": ["lighthouse", "slow cinema"],
      "imdbRating": 7.4,
      "rtTomatometer": 8.1,
      "hasAwards": false,
      "artwork": {
        "posterUrl": "https://media.example.com/posters/quiet-coast.webp"
      },
      "score": 82,
      "matchReason": "Matches your Drama affinity"
    }
  ],
  "totalItems": 45,
  "ageExcludedCount": 7,
  "ageTierApplied": "Adult",
  "personaBound": true
}
```

Everything from the [GET item](#item-fields), including the same optional `artwork` block, plus:

* `score` — a deterministic 0–100 affinity score. It is **code-computed, not
  model-generated**; items are returned in descending score order.
* `matchReason` — a short deterministic explanation; **omitted** when the scorer
  has no signal (e.g. cold-start).
* `personaBound` — **the honest cold-start signal.** `false` means no persona or
  affinity is bound for this member yet, so ordering is affinity-neutral rather
  than a fabricated ranking. Bind a persona with
  [`/v1/personas/generate`](/guides/personas#generate) and drive affinity with
  [events](/guides/behavioral-events) to move it to `true`.

> **Note**
>
> **Scoring never overrides child-safety.** The age gate excludes mature titles
> regardless of affinity — a high-affinity mature title is still excluded for a
> `Child`/`Tween`/`Unknown` member, and still counted in `ageExcludedCount`.

### Flight-bound serves \[#flights]

If the session is bound to a flight (via `flightInstanceId` at [session setup](/guides/session-setup)) and that flight has a [pushed active catalog](/guides/flights#active-catalog), the scored serve is intersected
with the flight's active set — it returns only titles actually loaded on the
aircraft. The intersection can only ever **narrow**, and it never overrides
the age gate.

On a narrowed request, `totalItems` reports the **narrowed basis**: your
visible catalog intersected with the flight's active set, before age
exclusion. `ageExcludedCount` counts removals from that same basis, so
`totalItems − ageExcludedCount` remains exactly the servable total.
Un-narrowed requests report unchanged numbers, so an integration that doesn't
use flight binding sees no difference.

> **Note**
>
> **The unscored `GET` is never flight-bound.** It accepts no session
> locator, so there is no binding to resolve — it always returns the full
> airline catalog, age-gated. Use the scored `POST` when you want the
> flight's active set.

## Errors

Standard [envelope](/guides/errors). The GET returns `401` / `403` / `422` /
`500`; the POST additionally returns **`400 session.unresolved`** when the
`X-Session-Id` header is missing or invalid (see
[Sessions](/guides/sessions#when-the-header-is-missing-or-invalid)). Note that a
bad `seatId` is **not** an error on either verb — it fails closed to the
child-safe tier.