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

# Shopping Search

`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](/guides/search).

[Session-scoped](/guides/sessions#the-x-session-id-header) — **requires the `X-Session-Id`
header**. Body (max 64 KiB):

| Field        | Required | Rules                                                                                  |
| ------------ | -------- | -------------------------------------------------------------------------------------- |
| `query`      | yes      | Non-blank, 1000 characters max.                                                        |
| `topK`       | no       | 1–50. Default `10`.                                                                    |
| `seatId`     | no       | The member's seat. Omit for an anonymous/unbound search — see the age-gate note below. |
| `memberSlot` | no       | Which member on a shared seat. Omit for solo, or when `seatId` is omitted.             |

```bash
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" }'
```

```json
{
  "schemaVersion": "1",
  "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d",
  "parsed": {
    "categories": [],
    "priceMax": 50,
    "recipient": "partner",
    "giftIntent": true,
    "genderTarget": "female",
    "semanticText": "gifts for my wife",
    "source": "ai"
  },
  "results": [
    {
      "id": "prod_412",
      "name": "Cashmere Travel Wrap",
      "brand": "Aria",
      "price": 42.0,
      "category": "Fashion",
      "artwork": {
        "imageUrl": "https://media.example.com/products/prod-412.webp",
        "heroUrl": "https://media.example.com/products/prod-412-hero.webp"
      },
      "score": 0.68,
      "reasons": ["Under $50", "For her", "Gift-appropriate", "semantic rank 1 of 38"]
    }
  ],
  "candidateCount": 38,
  "totalItems": 214,
  "ageExcludedCount": 3,
  "mode": "vector"
}
```

## 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 is *unavailable*
(throttled, a transport fault, or a moderation-provider outage), a synchronous rules parser takes
over and reports `source: "rules"`. The rules parser is deterministic text-in/struct-out and
cannot itself fail. A query *refused* on safety/scope grounds is different — that returns
[`422 content.refused`](#content-safety), never a silent fallback.
`priceMax`, `recipient`, and `genderTarget` are omitted when the query doesn't imply them;
`giftIntent` is always present (`false` by default).

> **Note**
>
> `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`)
and optional `artwork` (`imageUrl` and/or `heroUrl`) exactly as supplied at ingest. The artwork
block and absent members are omitted rather than returned as `null`; the platform never fetches or
proxies these references. Each result also carries:

* **`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

> **Warning**
>
> **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](/guides/sessions#age-tiers) 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.

## Content safety \[#content-safety]

Like [catalog search](/guides/search#content-safety), every query passes a content-safety check:
a query refused on safety or scope grounds returns **`422 content.refused`** with a friendly
generic message and no rule identifier — handle it as "show the message, let the passenger
rephrase", distinct from a `422 request.validation`. Benign shopping queries are unaffected, and
an *unavailable* AI parse still degrades to the rules fallback with a normal `200`
(`"source": "rules"`), never a 422.

## Errors

Standard [envelope](/guides/errors): `422 request.validation` (blank `query`, `query` over 1000
characters, `topK` outside 1–50), [`422 content.refused`](#content-safety),
`400 session.unresolved`, `429 rate.limited` over the [per-client request-rate limit](/guides/errors#rate-limits) (honor the `Retry-After` header), `401` / `403`, `500`. A
policy/ceiling denial still surfaces as `500 internal.error`.