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

# Behavioral Events

`POST /v1/events` ingests batches of passenger interaction signals (title
clicks, watch progress, product views, …) from your seatback client. The
platform stores each event server-stamped with your airline, a derived signal
weight, and a retention TTL. This guide covers the semantics an SDK builder
needs; exact schemas are in the [API Reference](/api-reference).

## The batch model

One POST carries **1–500 events**; the body is capped at **2 MiB**. Batching
is the norm — a seatback session produces a steady trickle of signals, and
you flush them in small batches (see [client cadence](#client-cadence)).

```bash
curl -X POST "https://api.staging.westiq.ai/v1/events" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d @batch.json
```

```json
{
  "events": [
    {
      "id": "0d5f7a1e-3c2b-4f6a-9d8e-1b2c3d4e5f60",
      "type": "title_click",
      "timestampMs": 1751558400000,
      "sessionElapsedMs": 424000,
      "interactionSequence": 17,
      "sessionId": "seat-12A-f3d9c1",
      "seatId": "12A",
      "data": { "titleId": "M:2041", "sourceRow": "trending" }
    },
    {
      "id": "3e9d0c2f-5a4b-4c7d-8e9f-2c3d4e5f6071",
      "type": "watch_progress",
      "timestampMs": 1751558455000,
      "sessionElapsedMs": 479000,
      "interactionSequence": 18,
      "sessionId": "seat-12A-f3d9c1",
      "data": { "titleId": "M:2041", "progressPct": 45 }
    },
    {
      "id": "7b1c4d6e-9f8a-4b5c-a1d2-3e4f50617283",
      "type": "search_query",
      "timestampMs": 1751558490000,
      "sessionElapsedMs": 514000,
      "interactionSequence": 19,
      "sessionId": "seat-12A-f3d9c1",
      "data": { "query": "something funny for kids", "resultsCount": 12 }
    }
  ]
}
```

A successful batch returns **200** with the per-event tally:

```json
{
  "schemaVersion": "1",
  "accepted": 3,
  "duplicates": 0,
  "rejected": []
}
```

Every event object carries:

| Field                 | Required | Notes                                                                                                                         |
| --------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `id`                  | yes      | UUID; half of the idempotency identity                                                                                        |
| `type`                | yes      | One of the [51 signal types](#signal-vocabulary-reference), snake_case string. Integer values are rejected.                   |
| `timestampMs`         | yes      | Client clock, Unix ms                                                                                                         |
| `sessionElapsedMs`    | yes      | Time since session start, ms                                                                                                  |
| `interactionSequence` | yes      | Monotonic counter within the session                                                                                          |
| `sessionId`           | yes      | Seatback session id — see [key safety](#sessionid-key-safety)                                                                 |
| `seatId`              | no       | Seat label ("12A"). An id, never personal data.                                                                               |
| `memberSlot`          | no       | Which member of a shared seat profile is interacting — a session-scope selector, **never** a loyalty or persistent identifier |
| `data`                | yes      | The payload object for the signal type (`{}` is valid for the payload-less types)                                             |

## What the server adds — and why the wire has no such fields

Two fields you might expect are deliberately **absent** from the wire:

* **No airline/tenant field.** The platform stamps `airline_id` from your
  authenticated credential. A request cannot tag events for another airline,
  because there is nowhere to say so.
* **No weight field.** Signal weights drive personalization scoring, so they
  are derived server-side from the [weight table](#signal-vocabulary-reference)
  — a client can never manufacture engagement by declaring its own weights.

The platform also stamps identity/consent state (events are anonymous,
session-scoped in the current release), the batch's correlation id, receipt
time, and a retention TTL (**90 days** in the sandbox).

Storage is **fail-closed**: each event's `data` is bound to its signal type's
schema, and only the modeled fields survive. Unknown fields, nested objects,
and arrays of objects are dropped before storage — do not rely on unmodeled
fields persisting.

## Batch-level vs per-event failures

The boundary rule: **once the batch envelope parses, one bad event can never
fail the others.** Per-event problems ride inside the 200 result; only
envelope-level problems are HTTP errors.

| What happened                                                                                                                                                                  | Result                                                                                        |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
| Body isn't JSON, `events` isn't an array, or any event has an envelope-level TYPE error (non-numeric `timestampMs`, a `type` string outside the vocabulary, an integer `type`) | **400** `request.malformed` — the whole batch fails                                           |
| Envelope parsed, but the batch is empty, has more than 500 events, or is `{"events": null}`                                                                                    | **422** `request.validation` — the whole batch fails                                          |
| Envelope parsed and sized correctly; individual events are invalid, duplicated, or fail internally                                                                             | **200** — each problem is one entry in `rejected[]`; the rest of the batch processes normally |
| A `null` element in the events array                                                                                                                                           | **200** — that element alone is rejected (`event.malformed_payload`)                          |

> **Warning**
>
> Because one envelope-level TYPE error 400s the entire batch, validate field
> types client-side before sending — your SDK should make it impossible for
> one malformed event to take down 499 good ones.

## Per-event rejection codes

Each entry in `rejected[]` is `{"index": …, "code": …, "message": …}`, where
`index` is the event's position in the array you submitted (order preserved).

| Code                      | Meaning                                                                                                                                  | Retry?                                                 |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `event.malformed_payload` | Null element, non-object `data`, payload doesn't match the signal type's schema (e.g. a non-numeric metric), or a key-unsafe `sessionId` | Fix the event first                                    |
| `event.missing_fields`    | A required field for this signal type is absent or explicitly `null` — the message names the missing fields                              | Fix the event first                                    |
| `event.pii_shape`         | A non-free-text field carried a value shaped like personal data                                                                          | Remove the personal data — see [PII rules](#pii-rules) |
| `event.processing_error`  | Unexpected server fault while validating this event                                                                                      | **Safe to resend**                                     |
| `event.sink_error`        | Unexpected server fault while storing this event                                                                                         | **Safe to resend**                                     |

For the two `*_error` codes, the message is deliberately generic — quote the
batch's `X-Correlation-Id` when reporting one and we can see the full detail
in our logs.

## Idempotency & retries

Each event's storage identity is **(your airline, `sessionId`,
`timestampMs`, `id`)**. Submitting the same identity again — in the same
batch or any later one — counts in `duplicates` and is never stored twice.

* **Partial-send recovery is "resend the whole batch."** If connectivity
  drops mid-request and you can't tell what landed, resend everything:
  already-stored events tally as `duplicates`, the rest store normally.
* Within one batch, duplicates resolve deterministically: the **lowest index**
  claims the identity; later same-identity events report as duplicates.
* The same `id` with a **different** `timestampMs` or `sessionId` is a
  different identity and stores as a new event — reuse ids only for true
  retries of the same event.

## sessionId key safety

`sessionId` becomes part of the storage key, so it has a strict shape:
**1–128 characters, no `#`, no control characters.** A violating event is
rejected individually (`event.malformed_payload`, without echoing the value).
Pre-validate in your SDK; generated ids (UUIDs, `seat-12A-<hex>`, …) are
always safe.

## PII rules

The events surface is built so personal data cannot leak into storage:

* **Exactly one free-text field exists** in the entire contract: `data.query`
  on `search_query`. It is PII-scrubbed and then stored — a passenger who
  types an email address into search does not put that address in the store.
* **Every other string field is an id, enum, or metric by design.** If any of
  them (including `seatId` and `memberSlot`) carries a value shaped like
  personal data — an email, a loyalty number, a record locator — that event
  is rejected with `event.pii_shape`. Rejected, never scrubbed-and-kept.
* Unmodeled fields are dropped before storage (see the fail-closed note
  above), so there is no unguarded path into the store.

Never put names, loyalty ids, or contact details into id fields. `memberSlot`
selects *which member of a shared profile* is active ("slot-2"), it does not
identify *who they are*.

## Client cadence

The platform enforces batch size and shape — flushing cadence is your SDK's
job. The reference client behaves like this, and we recommend the same:

* **Flush** the buffered batch every **2000 ms** or at **5 buffered
  signals**, whichever comes first.
* **Dedup** repeats of the same signal type against the same target within
  **500 ms** (a double-tap is one click). The target is the first present of
  `titleId` → `productId` → `placeId` → `adId` in `data`.
* **Buffer offline.** Seatback connectivity comes and goes: hold signals
  through the gap and replay them in order on reconnect. Idempotency makes
  replays safe even when an earlier send partially landed.

## Signal vocabulary reference

51 signal types across 7 categories. **Weight** is what the platform assigns
server-side (higher = stronger personalization signal; `watch_progress` ramps
with actual progress: `clamp(floor(progressPct / 15), 1, 7)`). **Required
fields** must be present and non-null in `data`, or the event is rejected
with `event.missing_fields`; all other payload fields are optional.

### Discovery

| Signal             | Weight                         | Required `data` fields      |
| ------------------ | ------------------------------ | --------------------------- |
| `title_hover`      | 1                              | `titleId`                   |
| `title_click`      | 3                              | `titleId`                   |
| `trailer_play`     | 7 (high, assigned server-side) | `titleId`                   |
| `detail_open`      | 3                              | `titleId`                   |
| `detail_read_time` | 3                              | `titleId`, `durationMs`     |
| `cast_click`       | 1                              | `titleId`, `personName`     |
| `genre_browse`     | 7                              | `genreLabel`                |
| `genre_row_dwell`  | 1                              | `genreLabel`                |
| `scroll_depth`     | 1                              | `scrollPct`, `page`         |
| `scroll_velocity`  | 1                              | `avgPxPerSec`, `page`       |
| `search_query`     | 3                              | `query`                     |
| `filter_apply`     | 1                              | `filterType`, `filterValue` |
| `back_navigate`    | 1                              | —                           |
| `watchlist_add`    | 7                              | `titleId`                   |
| `watchlist_remove` | 1                              | `titleId`                   |

### Consumption

| Signal             | Weight     | Required `data` fields       |
| ------------------ | ---------- | ---------------------------- |
| `content_start`    | 7          | `titleId`                    |
| `watch_progress`   | 1–7 (ramp) | `titleId`, `progressPct`     |
| `content_pause`    | 3          | `titleId`, `pausePointPct`   |
| `content_abandon`  | 7          | `titleId`, `abandonPointPct` |
| `content_complete` | 12         | `titleId`                    |

### Commerce

| Signal             | Weight | Required `data` fields |
| ------------------ | ------ | ---------------------- |
| `product_view`     | 3      | `productId`            |
| `product_detail`   | 3      | `productId`            |
| `product_cart`     | 7      | `productId`            |
| `product_category` | 1      | `category`             |

### Destination

| Signal                 | Weight | Required `data` fields  |
| ---------------------- | ------ | ----------------------- |
| `dest_card_click`      | 3      | `placeId`               |
| `dest_dwell`           | 1      | `placeId`, `durationMs` |
| `dest_tab_visit`       | 1      | `durationMs`            |
| `dest_dismiss`         | 7      | `placeId`               |
| `dest_pin`             | 3      | `placeId`               |
| `dest_unpin`           | 1      | `placeId`               |
| `dest_duration_change` | 3      | `from`, `to`            |
| `dest_event_click`     | 3      | `eventId`               |
| `dest_shop_click`      | 7      | `category`              |
| `dest_content_click`   | 7      | `contentId`, `placeId`  |
| `dest_scroll_depth`    | 1      | `deepestDay`            |
| `dest_refresh`         | 3      | —                       |

### Ads

| Signal          | Weight | Required `data` fields |
| --------------- | ------ | ---------------------- |
| `ad_impression` | 3      | `adId`                 |
| `ad_skip`       | 1      | `adId`, `skipPointMs`  |
| `ad_complete`   | 7      | `adId`                 |
| `ad_cta_click`  | 12     | `adId`                 |
| `ad_abandon`    | 1      | `adId`                 |

### Session & navigation

| Signal          | Weight | Required `data` fields |
| --------------- | ------ | ---------------------- |
| `tab_switch`    | 1      | `fromTab`, `toTab`     |
| `tab_return`    | 1      | `tab`                  |
| `session_idle`  | 1      | `idleDurationMs`       |
| `time_on_page`  | 1      | `screen`, `durationMs` |
| `session_start` | 1      | —                      |

### Cross-domain

| Signal                 | Weight | Required `data` fields     |
| ---------------------- | ------ | -------------------------- |
| `post_playback_view`   | 1      | `titleId`                  |
| `post_playback_click`  | 7      | `titleId`, `clickType`     |
| `post_playback_next`   | 3      | `fromTitleId`, `toTitleId` |
| `contextual_row_view`  | 1      | `rowId`                    |
| `contextual_row_click` | 7      | `rowId`, `itemId`          |