> 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.

# Search

`POST /v1/search` searches your airline's catalog (plus the shared global
catalog) in natural language. You send a passenger-style query; the platform
parses it into structured constraints, ranks the matching titles semantically,
and tells you exactly how it understood the query. Optionally, a seatback
integration can [opt a search into behavior personalization](#personalization)
for the passenger who is asking.

## Request

```json
{
  "query": "romantic comedy from the 90s",
  "topK": 5,
  "kindOverride": null
}
```

| Field                    | Required | Rules                                                                                                                                                                                                                                                                                                                                                  |
| ------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `query`                  | yes      | 1–1000 characters. Longer is rejected with `422 request.validation`.                                                                                                                                                                                                                                                                                   |
| `topK`                   | no       | 1–100; defaults to 10. Out of range is a 422.                                                                                                                                                                                                                                                                                                          |
| `kindOverride`           | no       | `"Movie"`, `"TV"`, `"Audio"`, or `"Game"` — [any casing, plus synonyms](#kinds). Forces the content kind instead of the parsed one. Anything else is a 422 whose message lists the accepted kinds.                                                                                                                                                     |
| `flightMinutesRemaining` | no       | Integer, **≥ 1** when present (`0` or negative is a 422). The minutes left in the passenger's flight — it only takes effect when the query itself asks for [content that fits the flight](#time-aware). Omitting it never changes results for other queries; when the session is bound to a flight, the platform can [derive it](#time-aware) instead. |
| `seatId`                 | no       | [Member locator](/guides/sessions#seat--member-locators) for [personalization](#personalization). Omit for an unpersonalized search.                                                                                                                                                                                                                   |
| `memberSlot`             | no       | Which traveler on a shared seat; omit for solo — meaningful only with `seatId`.                                                                                                                                                                                                                                                                        |

The request body is capped at **64 KiB** (`413` above it). There is no
airline field — [your credential carries the airline](/guides/authentication).

Search is the platform's one **session-optional** endpoint: with no
`X-Session-Id` header it is a plain airline-scoped search (the unchanged
behavior for server-to-server callers). Send the header to opt into
[personalization](#personalization) — but a *malformed* value is then a
`400 session.unresolved`, [same as on the session-required endpoints](/guides/sessions#when-the-header-is-missing-or-invalid), never
silently ignored.

## How your query is understood

Every response echoes the parse in `parsed`, so you never have to guess what
the platform did with the text:

* `originalQuery` — your text, verbatim.
* `filters` — the structured constraints extracted from it: `yearFrom` /
  `yearTo`, `genres[]` (all listed genres must match), `cast[]`,
  `titleContains`, `contentKind`, `ratingMax`, [`entities[]`](#entities),
  [`runtimeMax`](#time-aware), [`scoreMin`](#quality),
  [`awardWinning`](#quality) — plus `airlineId`, the tenant your credential
  resolved to (server-side; you never send it).
* `semanticText` — the meaning left over after constraint extraction
  ("feel-good"), used for semantic ranking. Empty when nothing remains.
* `source` — which parser produced the filters: `"ai"` (the model parser, the
  primary path) or `"rules"` (the deterministic fallback).
* `fitsFlightTime` — `true` when the query asked for [content that fits the remaining flight](#time-aware).
* `recommendForMe` — `true` when the query asked for [personal picks](#personalization) rather than stating content constraints.

Parsing handles the phrasings passengers actually use:

* Decades: "90s" or "1990s" ⇒ `yearFrom: 1990, yearTo: 1999`.
* Compound genres: "romcom" ⇒ `genres: ["Romance", "Comedy"]`.
* Kind keywords: "shows", "series", "TV" ⇒ `contentKind: "TV"`.

> **Note**
>
> **How parsing runs.** Query parsing is **AI-primary**: a language model
> extracts the structured constraints — including `cast` names, with fuzzy
> correction of well-known names ("Tom Cruz" → "Tom Cruise") — and the
> response's `"source": "ai"` tells you the model produced them. If the model
> is briefly unavailable the platform **falls back to the deterministic rules
> engine** automatically (`"source": "rules"`) and never fails the request —
> but the rules engine extracts a narrower set: no `cast`, `titleContains`,
> or `ratingMax`, it never populates `entities`, `scoreMin`, or
> `awardWinning`, and it never flags `fitsFlightTime` or `recommendForMe`.
> Branch on `parsed.source` if that distinction matters to you.

## Content kinds \[#kinds]

`kindOverride` forces the content kind instead of the parsed one, and it
accepts all four kinds the catalog carries:

| Value   | Also accepted                   |
| ------- | ------------------------------- |
| `Movie` | any casing (`movie`, `MOVIE`)   |
| `TV`    | any casing (`tv`)               |
| `Audio` | `music`, `albums`, `audiobooks` |
| `Game`  | `games`                         |

`kindOverride` is the **documented channel for audio and game**: the query
parser does not infer those kinds from free text, so "play me some jazz"
without an override is not read as an audio request. Anything outside the
accepted set is a `422 request.validation` whose message lists the kinds.

> **Note**
>
> **The [catalog-serve](/guides/catalog-serve) `kind` filter is stricter, on
> purpose.** Serve takes the four canonical values — `Movie`, `TV`, `Audio`,
> `Game` — in any casing and **no synonyms**: `music` and `games` resolve on
> search's `kindOverride` and are a 422 on serve. The non-parity is
> deliberate (a free-text query surface and a structured filter parameter are
> not the same thing), not an oversight.

> **Note**
>
> **Audio and game titles are hidden from younger members today.** Audio and
> game content ratings carry no recognized scale yet and normalize to null,
> and an unrated title is treated as most-restrictive — so audio and game
> results are excluded for `Child`, `Tween`, and `Unknown` members and
> returned normally for `Adult` members. An audio or game search for a
> younger member returns an empty result set, honestly, rather than an error.

## Quality & acclaim queries \[#quality]

Queries that state a rating bar or ask for acclaim are understood as
constraints, and every result carries its scores:

| The query says                                   | What happens                                              |
| ------------------------------------------------ | --------------------------------------------------------- |
| "an IMDB rating of 7 or better", "rated above 8" | `parsed.filters.scoreMin` is set to that bar (0–10 scale) |
| "top rated", "critically acclaimed"              | `scoreMin` is set to the acclaim default, **7.5**         |
| "award winning", "Oscar winners"                 | `parsed.filters.awardWinning: true`                       |

* **The score bar follows the data-gap rule.** A title with a **known** score
  below the bar is excluded; a title with **no known score is not excluded**
  — exactly as with the rating and runtime filters. A data gap is not a
  violation.
* **Award matching is grounded.** A title qualifies only when **its own
  catalog data** carries award recognition — never inferred from popularity
  or acclaim.
* Matching results carry reason strings like `"IMDB 7.2"` and
  `"Award winner"`.
* **Quality phrases are never treated as franchise entities.** "Award winning
  action movies" is a quality constraint, not a search for a franchise called
  "award winning" — a deterministic guard, so these queries return a full
  lineup rather than a handful of accidental matches.

Every result item carries the scores it was judged on, whether or not the
query asked:

| Field           | Meaning                                                          |
| --------------- | ---------------------------------------------------------------- |
| `imdbRating`    | 0–10 scale (`7.2`). **Omitted when unknown** — never fabricated. |
| `rtTomatometer` | 0–10 scale (`8.3`). Omitted when unknown.                        |
| `awardWinner`   | Boolean; always present.                                         |

These are **catalog-supplied review and editorial data**, as carried by your
own catalog feed. Display them with the result — do not re-derive or
recompute them.

## Franchise & entity queries \[#entities]

Queries that name a franchise, character, or creator — "Bond films", "Marvel
movies" — resolve against what your catalog's own data actually carries, not
against words in titles:

* `parsed.filters.entities` lists the canonical entity names the query stated
  (`["James Bond"]`). It is always present, empty when the query names none.
* A title matches when **its own catalog data** — keywords, title, cast, or
  synopsis — carries the entity. A title keyworded "James Bond" matches even
  if its name contains no "Bond".
* Matching results carry a reason naming the franchise (e.g.
  `"James Bond franchise"`).
* An entity your catalog does **not** carry returns zero results — never
  invented or look-alike titles. Grounded-empty is the designed behavior, not
  a failure.

## Time-aware queries: "fits the flight" \[#time-aware]

When the seatback knows how long is left in the flight, send it — and when
the *query itself* asks for something finishable ("something I can finish
before we land"), the platform turns it into a runtime ceiling:

```bash
curl -X POST "https://api.staging.westiq.ai/v1/search" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query": "something I can finish before we land", "flightMinutesRemaining": 95, "topK": 3}'
```

* The ceiling is the **smaller** of `flightMinutesRemaining` and any explicit
  duration in the query ("under 2 hours" ⇒ 120). It lands in
  `parsed.filters.runtimeMax` (minutes; omitted when no ceiling applies).
* Titles whose **known** runtime exceeds the ceiling are excluded. Titles
  with **unknown** runtime are *not* excluded — a data gap is not a
  violation, the same rule the rating gate follows.
* Fitting results carry a reason like `"Runtime 92 min fits your 95 min"`.
* `parsed.fitsFlightTime: true` confirms the query was understood as
  flight-fit. If it is `true` on a request that sent **no**
  `flightMinutesRemaining` *and* the session is not bound to a flight, no
  ceiling was applied — treat that as the signal to start sending the field.
* A query that never mentions flight-time fit ignores
  `flightMinutesRemaining` entirely.

### When you don't send the minutes

If the session is [bound to a flight](/guides/flights) and a flight-fit query
arrives **without** `flightMinutesRemaining`, the platform derives the
remaining minutes from that flight's scheduled arrival time rather than
skipping the ceiling.

A request-supplied `flightMinutesRemaining` is **always authoritative** — the
aircraft knows its own delays, the schedule does not.

The response's **`flightMinutesRemainingServerDerived`** (boolean, always
present) tells you where the value came from: `true` only when the platform
derived it. Read it precisely — it reports the origin of the *remaining
minutes value*, not which ceiling ultimately applied. A tighter runtime limit
stated in the query text ("under 90 minutes") still wins while this flag
reads `true`.

## Personalization & personal picks \[#personalization]

Search is **unpersonalized by default**. A seatback integration opts into behavioral ranking per
request by sending the passenger's session and member locator alongside its
[event stream](/guides/behavioral-events):

```bash
curl -X POST "https://api.staging.westiq.ai/v1/search" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "X-Session-Id: seat-12A-f3d9c1" \
  -H "Content-Type: application/json" \
  -d '{"query": "thrillers from the 2000s", "seatId": "12A", "topK": 5}'
```

When the session and locator resolve a real behavior profile:

* Results get a **bounded additive re-rank** toward the member's observed
  affinities — a nudge on top of relevance, never a replacement for it.
* Re-ranked items carry a deterministic reason string, e.g.
  `"Matches your Thriller affinity"`.
* The response's **`personalized: true`** confirms a profile actually shaped
  this ranking.

`personalized: false` means no behavioral profile was found, including a member
with no observed behavior yet (cold start). It does not rule out the
[language or destination adjustments](#language-destination) described below.

**Personal-pick queries.** A query that asks for recommendations instead of
stating constraints — "show me movies you think I'd like", "what should I
watch", "surprise me" — flips ranking from semantic matching to the member's
**affinity-ranked lineup**, echoed as `parsed.recommendForMe: true`. Stated
filters still apply ("comedies you think I'd like" stays comedies). A cold
member gets a neutral, deterministic lineup — sensible, not random, and
`personalized` stays `false`. Because no semantic query is being ranked in
this mode, results carry `cosineScore: 0.0` and no `"semantic …"` reason —
each result instead includes `"lineup <score>"` as ranking evidence, plus an
affinity reason when one exists. `"lineup 0.50"` is the neutral score when no
preference signal is available. `results[].reasons` is non-empty for every result,
including a cold member's unfiltered recommendations.

Locator semantics are the platform-wide ones: [fail-closed, never loud](/guides/sessions#seat--member-locators) — an unknown seat or unbound
member personalizes nothing and errors nothing.

### Language and destination \[#language-destination]

For a session-bound semantic search, the member's declared
[`locale`](/guides/session-setup) and the registered flight's destination can
influence **ranking, never visibility**. These adjustments share the same bounded
personalization allowance as behavioral affinity: they can reorder near-ties,
not carry an irrelevant title past a clearly better match. Age filtering and the
flight's active catalog always take precedence.

* A title whose language matches the member's preference can receive a language
  adjustment, with a reason such as `language match: Portuguese`.
* A destination match must be grounded in the title's own data: the destination's
  city or country as a **whole word or phrase** in its title, keywords or synopsis,
  or the destination's principal language matching the title's language. Partial
  word overlap and cast names are not place evidence. Reasons identify the match,
  for example `destination match: Tokyo`.
* A declared language that is also the destination's principal language earns
  **one adjustment, not two**. The declared preference takes precedence.
* Destination knowledge covers major destinations; an unrecognized destination
  adds no adjustment. Missing language data adds no language adjustment.

`personalized` still reports whether a **behavioral profile** was found. It can remain `false`
even when language or destination affects ranking; inspect `reasons` for that
evidence. Sessionless calls do not receive these adjustments. The recommend-for-me
lineup remains behavioral-only. `cabinClass` is captured for the session with
no ranking or recommendation effect.

## Member age-gating on session-bound search \[#age-gating]

Sending `X-Session-Id` opts into more than personalization: it also opts into
**child-safety filtering for the identified member**.

* **With a session** — results are hard-filtered to what is age-appropriate
  for the member your `seatId` / `memberSlot` names, **before ranking**. The
  member's [age tier](/guides/sessions#age-tiers) comes from the session
  roster, never from the request.
* **Without a session** — a sessionless, machine-to-machine search is **not**
  age-filtered. Results are byte-identical to what they have always been.

Two fail-closed rules apply when a session *is* supplied:

* An **unresolved or unbound member** gets the most-restrictive tier. An
  unknown locator narrows results; it never widens them and never errors.
* A **title with no content rating** is treated as most-restrictive, so an
  unrated title is hidden from `Child`, `Tween`, and `Unknown` members. (This
  is what currently hides [audio and game titles](#kinds) from those
  members.)

So the same query can legitimately return different results for the same
airline depending on whether you sent a session — that is the gate working,
not an inconsistency.

## Flight-bound search \[#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), search results are intersected with
the flight's active set — on **every** search mode, semantic queries and the
recommend-for-me lineup alike.

The intersection can only ever **narrow**. It never widens the airline
catalog and never overrides member age-gating: an active title that is
age-inappropriate for the member stays hidden. A session with no flight
binding, a flight with no push, and every sessionless call keep full-catalog
behavior exactly as before.

Search is one of [three narrowed surfaces](/guides/flights#narrowing),
alongside scored catalog serve and suggestions. Together they carry the
guarantee end to end: a flight-bound session cannot be shown a title that is
not loaded on the aircraft.

## Results & scoring

Ranking is a fixed pipeline: structured filters prune the candidate set,
vector similarity ranks the survivors, and an exact-title containment match
adds a fixed boost on top of the semantic score.

Each result carries:

| Field                                        | Meaning                                                                                                                                                                                                                                  |
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `itemId`                                     | Catalog item id: a **kind prefix** plus the title id — `"M:"` movie, `"T:"` TV, `"A:"` audio, `"G:"` game (e.g. `"M:123"`, `"A:456"`). See the warning below.                                                                            |
| `title`, `year`, `kind`, `genres`, `cast`    | The catalog metadata that matched                                                                                                                                                                                                        |
| `score`                                      | Final ranking score (semantic + any exact-title boost + any [affinity nudge](#personalization))                                                                                                                                          |
| `cosineScore`                                | The raw semantic-similarity component                                                                                                                                                                                                    |
| `reasons`                                    | Non-empty human-readable match evidence, e.g. `"Year 1996"`, `"semantic 0.81"`, `"lineup 0.50"`, `"James Bond franchise"`, `"Runtime 92 min fits your 95 min"`, `"IMDB 7.2"`, `"Award winner"`, `"Matches your Thriller affinity"`       |
| `imdbRating`, `rtTomatometer`, `awardWinner` | [Quality signals](#quality) — the first two omitted when unknown                                                                                                                                                                         |
| `artwork`                                    | Optional `{ posterUrl?, heroUrl? }` copied from the title catalog. The block and absent members are omitted rather than returned as `null`. URLs are exact catalog-supplied HTTPS references; the platform does not fetch or proxy them. |

> **Warning**
>
> **If you switch on the `itemId` prefix, handle all four.** The prefix set is
> `M:` / `T:` / `A:` / `G:`. The `M:` and `T:` forms are byte-identical to
> earlier releases, so nothing you already parse changes — but a client that
> branches on the prefix, or validates it against a two-value pattern, will
> now meet `A:` and `G:` on any airline whose catalog carries [audio or game titles](#kinds). This is the one place in the four-kind rollout where an
> existing integration can actually break.

> **Note**
>
> **`candidateCount` is not a catalog-wide match total.** Retrieval is
> index-accelerated: ranking examines the query's nearest-candidate set —
> typically 3×`topK`, clamped to \[50, 300]; queries with a `cast` filter
> examine a wider set for recall — and `candidateCount` reports how many of
> those **examined** candidates survived the filters. Treat it as "matches
> among the examined nearest candidates". For catalog-size signals use
> `totalItems` and `coverage` (read the basis rule below) — never
> `candidateCount`. ([Shopping search](/guides/shopping-search)'s
> `candidateCount` is unaffected: that endpoint filters its whole catalog.)

### What `totalItems` counts \[#totalitems]

`totalItems` is **this request's** catalog: the titles the request could draw
from, before any query filter or age gate.

* On an ordinary request that is your visible catalog — your airline's titles
  plus shared global content.
* On a [flight-narrowed request](#flights) it is your visible catalog
  **intersected with the flight's active set**. `coverage` uses the same
  basis.

> **Warning**
>
> **`totalItems` is a per-request basis figure, not a library size.** It
> equals your airline's catalog only when the request is not narrowed — the
> moment a session binds a flight with a pushed active catalog, the same
> field reports a per-flight number. Don't cache it as a catalog statistic,
> and don't compare it across bound and unbound requests.

## Coverage, reported honestly

`coverage` tells you how complete the metadata behind your results is —
reported, never papered over:

```json
{
  "movieCount": 38,
  "tvSeriesCount": 7,
  "genreCoveragePct": 100,
  "castCoveragePct": 100,
  "synopsisCoveragePct": 100
}
```

The three `…Pct` fields are percentages on the **0–100 scale** (42.5 means
42.5%), never 0–1 fractions. They report how many of the items on [this request's basis](#totalitems) carry at least one genre, at least one cast
member, and a synopsis respectively — if a coverage number is low, semantic
ranking quality is limited by the catalog metadata, not by your queries.

## Acceptable use & content safety \[#content-safety]

Search serves in-flight entertainment discovery, and every request passes a
content-safety check before any model runs. A query refused on safety or
scope grounds — attempts to misuse the search surface rather than find
catalog content — returns **`422 content.refused`** with a friendly generic
message:

```json
{
  "schemaVersion": "1",
  "code": "content.refused",
  "message": "I can only help you explore what's available on this flight — try searching the movies, shows, music, or shopping catalog.",
  "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d"
}
```

Three things an integrator should build against:

* **Handle it as "show the message, don't retry".** The refusal is about the
  query text, not your integration — surface the message (or your own copy)
  and let the passenger rephrase. It is deliberately distinct from
  `422 request.validation` (*your request* broke a rule — fix and retry), so
  [branch on `code`](/guides/errors), not on the 422 status.
* **The message never says which check fired.** No rule identifier is
  disclosed, by design.
* **Refusal ≠ degradation.** If the AI parse is merely *unavailable*
  (throttled, transport fault, or a moderation-provider outage), search still
  degrades to the deterministic rules fallback and returns a normal `200`
  with `"source": "rules"` — a refused query is the only case that 422s.

Benign catalog and personalization queries — everything this guide documents
— are unaffected. The same check protects
[shopping search](/guides/shopping-search#content-safety); every other
model-backed endpoint surfaces content blocks in its own documented denial
shape.

## Worked example

```bash
curl -X POST "https://api.staging.westiq.ai/v1/search" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query": "romantic comedy from the 90s", "topK": 5}'
```

Abridged response (sandbox tenant):

```json
{
  "schemaVersion": "1",
  "parsed": {
    "originalQuery": "romantic comedy from the 90s",
    "filters": {
      "airlineId": "demo_airline",
      "yearFrom": 1990,
      "yearTo": 1999,
      "genres": ["Romance", "Comedy"],
      "entities": []
    },
    "semanticText": "",
    "source": "ai",
    "fitsFlightTime": false,
    "recommendForMe": false
  },
  "results": [
    {
      "itemId": "M:…",
      "title": "You've Got Mail",
      "year": 1998,
      "kind": "Movie",
      "score": 0.71,
      "reasons": ["Year 1998", "Genres: Romance, Comedy", "semantic 0.71"]
    },
    {
      "itemId": "M:…",
      "title": "Sleepless in Seattle",
      "year": 1993,
      "kind": "Movie",
      "score": 0.69,
      "reasons": ["Year 1993", "Genres: Romance, Comedy", "semantic 0.69"]
    }
  ],
  "candidateCount": 6,
  "totalItems": 45,
  "personalized": false,
  "flightMinutesRemainingServerDerived": false,
  "coverage": {
    "movieCount": 38,
    "tvSeriesCount": 7,
    "genreCoveragePct": 100,
    "castCoveragePct": 100,
    "synopsisCoveragePct": 100
  },
  "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d"
}
```

## Errors

Standard [envelope](/guides/errors): `422 request.validation` (query length,
`topK` range, `flightMinutesRemaining` below 1, bad `kindOverride`),
[`422 content.refused`](#content-safety), `400 session.unresolved` (a
malformed `X-Session-Id` — only when you send the header),
`429 rate.limited` over the [per-client request-rate limit](/guides/errors#rate-limits) (honor the `Retry-After` header),
`401` / `403`, `413 request.malformed` (body over 64 KiB), `500`.

Full request and response schemas are in the
[API Reference](/api-reference).