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

# Sessions & Member Locators

The personalization endpoints work in terms of a **passenger session** and the
**members** seated within it. Search and events don't need any of this; the
theme, catalog-serve, suggestions, enrichment, and persona endpoints do. This
page defines the three shared concepts once — the per-endpoint guides link back
here rather than repeat them:

1. the **`X-Session-Id`** header that names the passenger session,
2. the **`seatId` / `memberSlot`** locators that name a member within it, and
3. the **age tiers** that gating is expressed in.

> **Note**
>
> [Completed Update Delivery](/guides/completed-delivery) is **not available on
> deployed endpoints and is disabled by default**. Its seven scoped routes use
> a canonical lowercase, non-nil session UUID in the URL, never `X-Session-Id` or
> seat/member fields. The session locator and receipt proof confer no authority.
> Delivery's leases, counters and acknowledgements are separate from the session
> locators and event-stream cursors described here.

> **Note**
>
> Your credential still carries your airline (see
> [Authentication](/guides/authentication)). Everything on this page is a
> **within-tenant locator** — it selects *which* of your own airline's
> sessions, seats, and members to act on. It never crosses airlines and never
> elevates privilege: a request can only ever reach the airline its credential
> is bound to.

## The `X-Session-Id` header

These endpoints are scoped to a single passenger session and require the `X-Session-Id` request
header:

| Endpoint                                                           | Method |
| ------------------------------------------------------------------ | ------ |
| [`/v1/catalog/serve` (scored)](/guides/catalog-serve#scored-serve) | POST   |
| [`/v1/suggestions`](/guides/suggestions)                           | POST   |
| [`/v1/enrichment`](/guides/enrichment)                             | POST   |
| [`/v1/personas/generate`](/guides/personas#generate)               | POST   |
| [`/v1/personas/session`](/guides/session-setup)                    | POST   |
| [`/v1/shopping/search`](/guides/shopping-search)                   | POST   |
| [`/v1/shopping/curate`](/guides/shopping-curate)                   | POST   |
| [`/v1/shopping/rescore`](/guides/shopping-curate)                  | POST   |
| [`/v1/ads/decision`](/guides/ads-decision)                         | POST   |
| [`/v1/destination/search`](/guides/destination-search)             | POST   |
| [`/v1/destination/replace`](/guides/destination-search)            | POST   |
| [`/v1/destination/plan`](/guides/destination-plan)                 | POST   |
| [`/v1/destination/extend`](/guides/destination-plan)               | POST   |

`/v1/shopping/hero-image`, `/v1/destination/hero-image`, and `/v1/destination/weather` are
model-free and **not** in this set — see [Endpoints that need no session](#endpoints-that-need-no-session).

The value is the **seatback passenger session id** — the same identifier your
client puts in the `sessionId` field of [behavioral events](/guides/behavioral-events). It is stable for the life of one passenger's
session, so every call for that passenger — binding members, generating a
persona, scoring the catalog, requesting suggestions — carries the same value
and is threaded together server-side.

```bash
curl -X POST "https://api.staging.westiq.ai/v1/suggestions" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "X-Session-Id: seat-12A-f3d9c1" \
  -H "Content-Type: application/json" \
  -d '{ "seatId": "12A", "titleIds": [2041] }'
```

> **Note**
>
> The personalization endpoints identify titles by **integer** `titleId`
> (`2041`), not the prefixed `"M:2041"` string that [search](/guides/search)
> results carry — the integer is the numeric part of that id.

### Value rules

The header value becomes part of a storage key, so it has the same strict shape
as an event `sessionId`, plus one addition:

* **1–128 characters.**
* **No `#`** (the key delimiter) and **no control characters**.
* **No `/`.**
* **Must not begin with `apx_`** (a reserved identity prefix).

Generated ids — a UUID, or a pattern like `seat-12A-<hex>` — are always safe.
Pre-validate in your client so a bad value never reaches the wire.

### When the header is missing or invalid

The request **fails closed** with `400 session.unresolved` — before any work is
done, and never by silently falling back to a non-session default:

```json
{
  "schemaVersion": "1",
  "code": "session.unresolved",
  "message": "No resolvable passenger session id for this endpoint. Provide a valid X-Session-Id header.",
  "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d"
}
```

`session.unresolved` is in the standard [error envelope](/guides/errors) — branch
on `code`, not on the message text.

## SSE endpoints name the session in the URL

The two server-sent-events routes are also session-scoped, but they take the
session id as the **`{id}` path segment**, not the header:

```text
GET /v1/sessions/{id}/stream
GET /v1/sessions/{id}/state
```

They do **not** read `X-Session-Id`. The path value follows the same key-safety
rules above (the `/` rule is moot — the URL structure handles it), and a
missing or unsafe value fails the same way, `400 session.unresolved`. Both
routes are covered in the [session events guide](/guides/session-events).

## Endpoints that need no session

`POST /v1/events`, `GET /v1/theme`, `GET /v1/catalog/serve`
(the unscored read), `GET /v1/personas` (the archetype gallery),
[`POST /v1/shopping/hero-image`](/guides/hero-images),
[`POST /v1/destination/hero-image`](/guides/hero-images), and
[`POST /v1/destination/weather`](/guides/destination-weather) are **airline-scoped**, not
passenger-session-scoped — the last three are also **model-free**. None take an `X-Session-Id`
header. Sending one is harmless; it is simply ignored.

The **partner operations surfaces** are airline-scoped for a stronger reason: they carry no
passenger context at all, by construction. [Catalog ingestion](/guides/catalog-ingest) (`POST`/`GET /v1/catalog/ingest/jobs…`) and the [flight endpoints](/guides/flights) (`PUT /v1/flights/{id}` and its `/active-catalog`) are
machine-to-machine: a catalog load and a boarding-time catalog push have no passenger session
to name. They take no `X-Session-Id`.

**[`POST /v1/search`](/guides/search) is the one session-*optional* endpoint.** With no
`X-Session-Id` header it behaves exactly as an airline-scoped, sessionless search — the
unchanged behavior for server-to-server callers. Send the header (with `seatId` /
`memberSlot` in the body) and the search **adopts the session** and opts into
[behavior personalization](/guides/search#personalization). Because the header is *read*
there, its value must be valid when present: a malformed `X-Session-Id` on `/v1/search` is a
`400 session.unresolved`, exactly as on the session-required endpoints — not ignored.

## Seat & member locators

Within a session, a member is identified by **seat** and **slot**:

| Locator      | Meaning                                                                                                                                                         |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `seatId`     | The physical seat — `"12A"`. Required to identify a member; omitting it (or leaving it blank) resolves to the most-restrictive tier, never an error.            |
| `memberSlot` | Which traveler on a shared-seat profile — e.g. two passengers watching on one seat. **Omit it for a solo traveler**; the solo member is resolved automatically. |

Both are passed as query parameters on `GET /v1/catalog/serve` and as body
fields on the scored/personalization POSTs — including
[`POST /v1/search`](/guides/search#personalization) when you opt a search into
personalization. Like `X-Session-Id`, they are within-tenant locators — a way
to say *which member*, never a trust assertion.

> **Warning**
>
> **Locators fail closed, never loud.** An unknown seat, an unbound member, or a
> malformed/crafted locator resolves to the **`Unknown` age tier — treated as
> the most restrictive (child-safe) set** — and returns results accordingly. It
> is **never** a `4xx`/`5xx`. So restricted or empty results from an
> unrecognized locator are the *designed* fail-closed behavior, not a failure to
> debug. Bind your members first (below) to get their real tier.

## Age tiers

Gating is expressed in four tiers. A member's tier is resolved server-side from
the age band you bind at session setup; it is never sent per-request.

| Tier      | Sees                                                                                                                                                                                                                                                  |
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Adult`   | Everything. An adult is not restricted by this gate — including titles with a missing or unrecognized rating.                                                                                                                                         |
| `Tween`   | Up to the PG-13-equivalent set (adds `PG-13`, `TV-14`, `13+`, and the international `12` / `12+` to the child set). Never `R` / `NC-17` / `TV-MA` — and the international `15` / `18` are likewise **adult-visible only**, above this band by design. |
| `Child`   | The most restrictive real set: `G`, `PG`, `U`, `U/A`, `TV-G`, `TV-PG` only.                                                                                                                                                                           |
| `Unknown` | The **fail-closed default** for any unresolved or unbound member — treated **identically to `Child`**.                                                                                                                                                |

Tier membership is checked case-insensitively against the [rating vocabulary](/guides/catalog-ingest#ratings); a rating outside a tier's set —
including one the vocabulary doesn't know at all — is excluded for that tier,
never guessed in.

Two rules follow from this and matter for integration:

* **Unknown age resolves to Child, not Adult.** Until a member is bound with a
  known age band, they see the child-safe catalog. This is deliberate
  child-safety, not a cold-start bug.
* **A title with a missing or unrecognized rating is excluded for every
  non-adult tier** — guessed out, never in.

To give a member their real tier, bind the seat's roster once per session with
[`POST /v1/personas/session`](/guides/session-setup), which takes each member's
`seatId` and `ageBand` and resolves an authoritative tier server-side. How each
endpoint *applies* a resolved tier (filtering, empty responses, exclusion
counts) is documented in that endpoint's own guide.