Shopping Search

POST /v1/shopping/search — natural-language product search, age-gated and member-aware.

POST /v1/shopping/search parses a free-text shopping query, ranks your airline’s product catalog against it, and age-gates the results — the shopping-catalog counterpart to content search.

Session-scopedrequires the X-Session-Id header. Body (max 64 KiB):

FieldRequiredRules
queryyesNon-blank, 1000 characters max.
topKno1–50. Default 10.
seatIdnoThe member’s seat. Omit for an anonymous/unbound search — see the age-gate note below.
memberSlotnoWhich member on a shared seat. Omit for solo, or when seatId is omitted.
$curl -X POST "https://api.staging.westiq.ai/v1/shopping/search" \
> -H "Authorization: Bearer $ACCESS_TOKEN" \
> -H "X-Session-Id: seat-12A-f3d9c1" \
> -H "Content-Type: application/json" \
> -d '{ "query": "gifts under $50 for my wife", "seatId": "12A" }'
1{
2 "schemaVersion": "1",
3 "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d",
4 "parsed": {
5 "categories": [],
6 "priceMax": 50,
7 "recipient": "partner",
8 "giftIntent": true,
9 "genderTarget": "female",
10 "semanticText": "gifts for my wife",
11 "source": "ai"
12 },
13 "results": [
14 {
15 "id": "prod_412",
16 "name": "Cashmere Travel Wrap",
17 "brand": "Aria",
18 "price": 42.0,
19 "category": "Fashion",
20 "score": 0.68,
21 "reasons": ["Under $50", "For her", "Gift-appropriate", "semantic rank 1 of 38"]
22 }
23 ],
24 "candidateCount": 38,
25 "totalItems": 214,
26 "ageExcludedCount": 3,
27 "mode": "vector"
28}

The parsed query

parsed.source is "ai" or "rules" — parsing is AI-primary with a deterministic rules fallback, and the fallback is always announced, never silent: if the AI parse can’t run (denied, throttled, or a provider fault), a synchronous rules parser takes over and reports source: "rules". The rules parser is deterministic text-in/struct-out and cannot itself fail. priceMax, recipient, and genderTarget are omitted when the query doesn’t imply them; giftIntent is always present (false by default).

recipient is not a closed set on the wire — a query naming a relationship the parser recognizes (partner, family member, colleague, self, …) resolves to a short label, but an AI parse can also assert other free-text values. Branch on it loosely.

Results and score

Each result carries the catalog fields (id, name, brand when known, price, category) plus:

  • score — a relative ranking-strength value, not a probability and not a literal cosine-similarity number. In mode: "vector" it’s derived from the semantic index’s rank order; in mode: "lexical" it’s a weighted keyword-overlap score. Use it to order results within one response only — don’t compare it across requests or against matchScore on other endpoints.
  • reasons — deterministic, code-generated short strings ("Under $50", "For her", "Gift-appropriate", a category label), always plus one trailing rank-explanation entry ("semantic rank {n} of {total}" or "lexical {score}"). Never model free text.

mode: vector vs. lexical

mode reports which ranking strategy actually ran: "vector" when the platform’s semantic/embedding-based index produced a ranking, "lexical" when it fell back to weighted keyword matching (e.g. the embedding call itself failed). Like parsed.source, this is an honest report of what happened for this request — don’t assume one mode over the other.

Age-gating: unconditional, and not all-or-nothing

The age gate runs on every request, even an anonymous one. Unlike the personalization POSTs, seatId/memberSlot are optional here — but there is no “skip the gate” path. A search with no seatId is evaluated as if the searcher were a Child: the platform’s most-restrictive tier, applied via the same age-tier model used everywhere else (Unknown collapses onto Child; only Adult is unrestricted).

This does not mean every result is blocked for non-adult tiers — it means each product’s minimum-age requirement is checked against the resolved tier, same as any other age-gated endpoint. ageExcludedCount is the number of items the gate removed, computed against the already-filtered candidate set (after category/price/gender/recipient narrowing) — a subset of candidateCount, not of totalItems. totalItems is your airline’s entire catalog size with no filtering applied at all, so don’t use it as an “available to this member” figure.

Errors

Standard envelope: 422 request.validation (blank query, query over 1000 characters, topK outside 1–50), 400 session.unresolved, 401 / 403, 500. There is no 429 for this endpoint today — a rate/ceiling denial is not yet distinguishable from any other server fault and surfaces as 500 internal.error.