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-scoped — requires the X-Session-Id
header. Body (max 64 KiB):
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. Inmode: "vector"it’s derived from the semantic index’s rank order; inmode: "lexical"it’s a weighted keyword-overlap score. Use it to order results within one response only — don’t compare it across requests or againstmatchScoreon 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.