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

# Ads Decisioning

`POST /v1/ads/decision` picks one ad (plus a runner-up) to show a member before a title plays,
scored against their viewing context and age tier. There is no bid/CPM auction on this
platform — ad selection is a **content-fit match**, never a monetary computation.

[Session-scoped](/guides/sessions#the-x-session-id-header) — **requires the `X-Session-Id`
header**. Body (max 64 KiB):

| Field            | Required | Rules                                                                                                                                                              |
| ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `seatId`         | yes      | Non-blank.                                                                                                                                                         |
| `memberSlot`     | no       | Omit for solo.                                                                                                                                                     |
| `titleContext`   | yes      | `{ titleId: int, title: string, genre?, rating? }` — the title about to play.                                                                                      |
| `adHistory`      | no       | Ad ids already shown this session, oldest first. Default `[]`. Drives the per-ad view cap and cooldown window.                                                     |
| `adInteractions` | no       | `{ adId: reaction }` map of prior reactions (e.g. `"skipped"`). Default `{}`. Advisory context passed to scoring — it does not mechanically include/exclude an ad. |

```bash
curl -X POST "https://api.staging.westiq.ai/v1/ads/decision" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "X-Session-Id: seat-12A-f3d9c1" \
  -H "Content-Type: application/json" \
  -d '{
    "seatId": "12A",
    "titleContext": { "titleId": 2041, "title": "The Quiet Coast", "genre": "Drama" }
  }'
```

```json
{
  "schemaVersion": "1",
  "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d",
  "selectedAd": {
    "adId": "ad_electronics_headphones",
    "brand": "Skyline Audio",
    "headline": "Noise-cancelling, built for the cabin",
    "offer": "15% off your next order in the app",
    "category": "Electronics",
    "tags": ["travel", "audio"],
    "targetPersonas": [],
    "excludeChildren": false
  },
  "matchScore": 80,
  "reasoning": "Matches this passenger's electronics affinity",
  "terminalMessage": "Enjoy the flight."
}
```

* **`alternativeAd`** — a same-shape runner-up ad, omitted whenever `selectedAd` is omitted or
  fewer than two ads were scored. Not shown in the example above (only present when a runner-up
  exists); a real response can carry it alongside `selectedAd`.
* **`poster`** — an optional ad-creative image URL, omitted when the ad has none.
* **`offer` is marketing copy, never a computed price.** It may contain discount language as
  prose (`"15% off…"`), but it is catalog text, not a structured monetary field — there is no
  price/bid/CPM field anywhere on `selectedAd`/`alternativeAd`.
* **`targetPersonas`** rides on the wire but is not currently used as a ranking or filter signal —
  it's reserved for a future use, always present (may be `[]`).
* **`maxViewsPerSession`** is omitted when the ad has no override; absence means the platform
  default (2 views per session) applies.

## Three distinct "no strong pick" outcomes

> **Warning**
>
> Not every non-ideal outcome looks the same — distinguish them by `reasoning`, not just by
> whether `selectedAd` is present:
>
> 1. **Pool empty (honest-null, cost-free).** If age-gating, the view cap, and the cooldown window
>    leave zero eligible ads, the platform makes **no model call** and returns `selectedAd`
>    omitted, `matchScore: 0`, `reasoning: "No eligible ads remained after the age gate, view cap,
>    and cooldown filters."`
> 2. **Below threshold (honest-null, after scoring).** The model *did* score the eligible pool, but
>    the top result scored under the match threshold. Same shape: `selectedAd` omitted,
>    `matchScore: 0`, `reasoning: "No ad cleared the match threshold for this passenger."`
> 3. **Fallback (a real pick, not a null).** If the model call itself fails *after* the request was
>    already billed (an output-guard refusal or unparseable response), the platform falls back to
>    a deterministic pick from the eligible pool rather than returning nothing — `selectedAd` **is
>    present**, `matchScore` is pinned to the match-threshold floor, and `reasoning: "fallback"`.
>    Treat a `"fallback"` reasoning as "we showed something safe," not as a genuine relevance
>    judgment.

## Age-gating

Ads reuse the platform's [age-tier model](/guides/sessions#age-tiers) (`Adult` / `Tween` /
`Child` / `Unknown`, with `Unknown` resolving to `Child`). Each ad carries `excludeChildren`; when
`true`, both `Child` and `Tween` are excluded, not just `Child`. An excluded ad is dropped before
scoring, same as the pool-empty case above if nothing survives.

## Errors

Standard [envelope](/guides/errors): `422 request.validation` (blank `seatId`), `400
session.unresolved`, `401` / `403`, `413 request.malformed` (body over 64 KiB), `500`. The
endpoint enforces the [per-client request-rate limit](/guides/errors#rate-limits): over it,
`429 rate.limited` with a `Retry-After` header. A policy/ceiling denial still surfaces as
`500 internal.error`, same as a genuine fault.