Catalog Serve

The age-gated catalog read — unscored (GET) and per-member scored (POST).

/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 and the fail-closed locator rules: an unknown or unbound member is served the most-restrictive (child-safe) catalog, never the full one, and never an error.

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

Serve takes canonical values only — no synonyms. music, albums, and games resolve on search’s kindOverride 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.

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 — 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 — no X-Session-Id. All query parameters are optional:

ParamRules
kindMovie, 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.
limitInteger 1–500. Omitted = no cap (the full page). Out of range → 422.
offsetInteger ≥ 0. Omitted = 0. Negative → 422.
seatIdSeat locator. Untrusted — an absent or unbound seat resolves to the child-safe tier, never a 4xx.
memberSlotMember locator within the seat. Omit for a solo traveler.
sourceSystemOptional id-namespace token — the same sourceSystem your titles were centrally ingested 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. Omitted = no echo.
$curl "https://api.staging.westiq.ai/v1/catalog/serve?kind=Movie&limit=50&seatId=12A" \
> -H "Authorization: Bearer $ACCESS_TOKEN"
1{
2 "schemaVersion": "1",
3 "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d",
4 "items": [
5 {
6 "titleId": 2041,
7 "kind": "Movie",
8 "title": "The Quiet Coast",
9 "year": 1998,
10 "genres": ["Drama"],
11 "cast": ["A. Rivera"],
12 "rating": "PG",
13 "synopsis": "A retired lighthouse keeper…",
14 "runtimeMinutes": 96,
15 "language": "English",
16 "keywords": ["lighthouse", "slow cinema"],
17 "imdbRating": 7.4,
18 "rtTomatometer": 8.1,
19 "hasAwards": false
20 }
21 ],
22 "totalItems": 45,
23 "ageExcludedCount": 7,
24 "ageTierApplied": "Child"
25}
  • titleId is an integer (see the note on ids). 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

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:

FieldTypeSemantics
titleIdintegerOur opaque platform id — what suggestions and events take.
kindstringMovie / TV / Audio / Game.
title, year, genres, cast, rating, synopsisAs before. year: 0 = unknown; rating / synopsis omitted when unknown. genres entries are always the vocabulary’s canonical casing.
runtimeMinutesintegerWhole minutes — a movie’s feature length; a TV series carries its typical episode length. Omitted when unknown, never 0 as a placeholder.
languagestringThe title’s original-audio language as a canonical full English name ("English", "Japanese"). Omitted when unknown.
keywordsstring[]Free-form franchise/entity/theme tokens (["James Bond", "007"]). Empty = none known.
imdbRatingnumberIMDB rating on the 0–10 scale. Omitted when not rated / unknown, never 0.
rtTomatometernumberRotten Tomatoes Tomatometer, also on a 0–10 scale here (8.1, not 81). Omitted when unknown, never 0.
hasAwardsbooleantrue 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.
externalTitleIdstringYour id for this title — only when the request names a sourceSystem. See below.

Your own ids on serve responses

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:

$curl "https://api.staging.westiq.ai/v1/catalog/serve?sourceSystem=partner-cms&limit=50&seatId=12A" \
> -H "Authorization: Bearer $ACCESS_TOKEN"
1{
2 "titleId": 10000000,
3 "kind": "Movie",
4 "title": "Northbound",
5 "externalTitleId": "CMS-88421"
6}

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. An item without the echo is still fully servable; the omission means “not your id”, not “not available”.

POST — scored serve

The keystone personalization read: the age-gated catalog ranked for one member. This is session-scoped — it requires the X-Session-Id header. Body (max 4 KiB):

FieldRequiredRules
seatIdyesNon-blank, or 422 request.validation.
memberSlotnoOmit for a solo traveler.
kindnoMovie / TV / Audio / Game, any casing, or omit for no kind filter (422 otherwise).
limitno1–500, or no cap.
offsetno≥ 0.
sourceSystemnoSame rules and effect as on the GET: 1–64 lowercase letters, digits, and hyphens (422 otherwise); each item echoes your externalTitleId under that namespace.
$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 }'
1{
2 "schemaVersion": "1",
3 "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d",
4 "items": [
5 {
6 "titleId": 2041,
7 "kind": "Movie",
8 "title": "The Quiet Coast",
9 "year": 1998,
10 "genres": ["Drama"],
11 "cast": ["A. Rivera"],
12 "rating": "PG",
13 "runtimeMinutes": 96,
14 "language": "English",
15 "keywords": ["lighthouse", "slow cinema"],
16 "imdbRating": 7.4,
17 "rtTomatometer": 8.1,
18 "hasAwards": false,
19 "score": 82,
20 "matchReason": "Matches your Drama affinity"
21 }
22 ],
23 "totalItems": 45,
24 "ageExcludedCount": 7,
25 "ageTierApplied": "Adult",
26 "personaBound": true
27}

Everything from the GET item, 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).
  • personaBoundthe 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 and drive affinity with events to move it to true.

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

If the session is bound to a flight (via flightInstanceId at session setup) and that flight has a pushed 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.

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. 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). Note that a bad seatId is not an error on either verb — it fails closed to the child-safe tier.