Ads Decisioning

POST /v1/ads/decision — per-member, age-gated pre-roll ad selection. No pricing, no bidding.

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-scopedrequires the X-Session-Id header. Body (max 64 KiB):

FieldRequiredRules
seatIdyesNon-blank.
memberSlotnoOmit for solo.
titleContextyes{ titleId: int, title: string, genre?, rating? } — the title about to play.
adHistorynoAd ids already shown this session, oldest first. Default []. Drives the per-ad view cap and cooldown window.
adInteractionsno{ adId: reaction } map of prior reactions (e.g. "skipped"). Default {}. Advisory context passed to scoring — it does not mechanically include/exclude an ad.
$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" }
> }'
1{
2 "schemaVersion": "1",
3 "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d",
4 "selectedAd": {
5 "adId": "ad_electronics_headphones",
6 "brand": "Skyline Audio",
7 "headline": "Noise-cancelling, built for the cabin",
8 "offer": "15% off your next order in the app",
9 "category": "Electronics",
10 "tags": ["travel", "audio"],
11 "targetPersonas": [],
12 "excludeChildren": false
13 },
14 "matchScore": 80,
15 "reasoning": "Matches this passenger's electronics affinity",
16 "terminalMessage": "Enjoy the flight."
17}
  • 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

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 (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: 422 request.validation (blank seatId), 400 session.unresolved, 401 / 403, 413 request.malformed (body over 64 KiB), 500. There is no 429 for this endpoint today — a policy/ceiling denial surfaces as 500 internal.error, same as a genuine fault.