Behavioral Events

Batch passenger interaction signals into POST /v1/events — the batch model, rejection codes, idempotency, and the signal vocabulary.

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.

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

$curl -X POST "https://api.staging.westiq.ai/v1/events" \
> -H "Authorization: Bearer $ACCESS_TOKEN" \
> -H "Content-Type: application/json" \
> -d @batch.json
1{
2 "events": [
3 {
4 "id": "0d5f7a1e-3c2b-4f6a-9d8e-1b2c3d4e5f60",
5 "type": "title_click",
6 "timestampMs": 1751558400000,
7 "sessionElapsedMs": 424000,
8 "interactionSequence": 17,
9 "sessionId": "seat-12A-f3d9c1",
10 "seatId": "12A",
11 "data": { "titleId": "M:2041", "sourceRow": "trending" }
12 },
13 {
14 "id": "3e9d0c2f-5a4b-4c7d-8e9f-2c3d4e5f6071",
15 "type": "watch_progress",
16 "timestampMs": 1751558455000,
17 "sessionElapsedMs": 479000,
18 "interactionSequence": 18,
19 "sessionId": "seat-12A-f3d9c1",
20 "data": { "titleId": "M:2041", "progressPct": 45 }
21 },
22 {
23 "id": "7b1c4d6e-9f8a-4b5c-a1d2-3e4f50617283",
24 "type": "search_query",
25 "timestampMs": 1751558490000,
26 "sessionElapsedMs": 514000,
27 "interactionSequence": 19,
28 "sessionId": "seat-12A-f3d9c1",
29 "data": { "query": "something funny for kids", "resultsCount": 12 }
30 }
31 ]
32}

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

1{
2 "schemaVersion": "1",
3 "accepted": 3,
4 "duplicates": 0,
5 "rejected": []
6}

Every event object carries:

FieldRequiredNotes
idyesUUID; half of the idempotency identity
typeyesOne of the 50 signal types, snake_case string. Integer values are rejected.
timestampMsyesClient clock, Unix ms
sessionElapsedMsyesTime since session start, ms
interactionSequenceyesMonotonic counter within the session
sessionIdyesSeatback session id — see key safety
seatIdnoSeat label (“12A”). An id, never personal data.
memberSlotnoWhich member of a shared seat profile is interacting — a session-scope selector, never a loyalty or persistent identifier
datayesThe 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 — 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 happenedResult
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 internally200 — each problem is one entry in rejected[]; the rest of the batch processes normally
A null element in the events array200 — that element alone is rejected (event.malformed_payload)

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

CodeMeaningRetry?
event.malformed_payloadNull element, non-object data, payload doesn’t match the signal type’s schema (e.g. a non-numeric metric), or a key-unsafe sessionIdFix the event first
event.missing_fieldsA required field for this signal type is absent or explicitly null — the message names the missing fieldsFix the event first
event.pii_shapeA non-free-text field carried a value shaped like personal dataRemove the personal data — see PII rules
event.processing_errorUnexpected server fault while validating this eventSafe to resend
event.sink_errorUnexpected server fault while storing this eventSafe 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 titleIdproductIdplaceIdadId 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

50 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

SignalWeightRequired data fields
title_hover1titleId
title_click3titleId
detail_open3titleId
detail_read_time3titleId, durationMs
cast_click1titleId, personName
genre_browse7genreLabel
genre_row_dwell1genreLabel
scroll_depth1scrollPct, page
scroll_velocity1avgPxPerSec, page
search_query3query
filter_apply1filterType, filterValue
back_navigate1
watchlist_add7titleId
watchlist_remove1titleId

Consumption

SignalWeightRequired data fields
content_start7titleId
watch_progress1–7 (ramp)titleId, progressPct
content_pause3titleId, pausePointPct
content_abandon7titleId, abandonPointPct
content_complete12titleId

Commerce

SignalWeightRequired data fields
product_view3productId
product_detail3productId
product_cart7productId
product_category1category

Destination

SignalWeightRequired data fields
dest_card_click3placeId
dest_dwell1placeId, durationMs
dest_tab_visit1durationMs
dest_dismiss7placeId
dest_pin3placeId
dest_unpin1placeId
dest_duration_change3fromNights, toNights
dest_event_click3eventId
dest_shop_click7category
dest_content_click7contentId, placeId
dest_scroll_depth1deepestDay
dest_refresh3

Ads

SignalWeightRequired data fields
ad_impression3adId
ad_skip1adId, skipPointMs
ad_complete7adId
ad_cta_click12adId
ad_abandon1adId

Session & navigation

SignalWeightRequired data fields
tab_switch1fromTab, toTab
tab_return1tab
session_idle1idleDurationMs
time_on_page1screen, durationMs
session_start1

Cross-domain

SignalWeightRequired data fields
post_playback_view1titleId
post_playback_click7titleId, clickType
post_playback_next3fromTitleId, toTitleId
contextual_row_view1rowId
contextual_row_click7rowId, itemId