> 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 Curate & Rescore

Two calls drive the shopping surface for one member:

* **`POST /v1/shopping/curate`** — build a fresh, sectioned set of picks from the full catalog.
* **`POST /v1/shopping/rescore`** — re-score a set of products you're already showing (e.g. as new
  behavioral signal arrives), without re-sectioning from scratch.

Both are [session-scoped](/guides/sessions#the-x-session-id-header) — **require the
`X-Session-Id` header**.

> **Note**
>
> **No price field exists on either request, anywhere.** Pricing is always catalog-sourced and
> attached server-side after scoring — there is nowhere on the wire for a client (or a model) to
> assert a price. If you send one anyway, it is silently dropped during parsing; it never reaches
> the service.

## Curate

Body (max 4 KiB):

| Field                | Required | Rules                                                                                                                                            |
| -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `seatId`             | yes      | Non-blank.                                                                                                                                       |
| `memberSlot`         | no       | Omit for solo.                                                                                                                                   |
| `destinationContext` | no       | `{ originCity?, destinationCity?, destinationCountry?, tags?: string[] }` — trip context that steers section relevance. All sub-fields optional. |
| `mood`               | no       | Free text, advisory only.                                                                                                                        |

```bash
curl -X POST "https://api.staging.westiq.ai/v1/shopping/curate" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "X-Session-Id: seat-12A-f3d9c1" \
  -H "Content-Type: application/json" \
  -d '{ "seatId": "12A", "destinationContext": { "destinationCity": "Tokyo" } }'
```

```json
{
  "schemaVersion": "1",
  "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d",
  "sections": [
    {
      "id": "s1",
      "title": "Picks for Tokyo",
      "items": [
        {
          "productId": "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"
          },
          "matchScore": 80,
          "matchReason": "Matches your comfort-travel affinity"
        }
      ]
    }
  ],
  "totalCandidates": 214,
  "ageExcludedCount": 12,
  "ageTierApplied": "Adult",
  "personaBound": true
}
```

Each item can carry optional `artwork` with `imageUrl` and/or `heroUrl`, exactly as supplied
at product ingest. The block and absent members are omitted rather than returned as `null`; the
platform never fetches or proxies these references.

* `totalCandidates` and `ageExcludedCount` are computed against your airline's **entire** catalog
  — curate has no category/price pre-filter the way [search](/guides/shopping-search) does.
* `ageTierApplied` is the resolved [age tier](/guides/sessions#age-tiers) (`Unknown` behaves as
  `Child`). `personaBound` is the same [cold-start signal](/guides/catalog-serve#post-—-scored-serve-scored-serve) used elsewhere: `false` means no
  persona/affinity is bound yet, so section ordering is affinity-neutral rather than fabricated.

> **Warning**
>
> **Age-gating happens before the model ever sees a candidate, not after.** The catalog is hard
> age-gated in code first; an excluded product is never placed in the pool the model scores from,
> and the gate is re-applied to whatever comes back as defense-in-depth. `matchScore` (0–100) is
> always code-clamped regardless of what was asserted internally.

## Rescore

Body (max 16 KiB):

| Field               | Required | Rules                                                            |
| ------------------- | -------- | ---------------------------------------------------------------- |
| `seatId`            | yes      | Non-blank.                                                       |
| `memberSlot`        | no       | Omit for solo.                                                   |
| `visibleProductIds` | yes      | 1–80 product ids — the set you're currently showing this member. |

```bash
curl -X POST "https://api.staging.westiq.ai/v1/shopping/rescore" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "X-Session-Id: seat-12A-f3d9c1" \
  -H "Content-Type: application/json" \
  -d '{ "seatId": "12A", "visibleProductIds": ["prod_412", "prod_88"] }'
```

```json
{
  "schemaVersion": "1",
  "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d",
  "sectionUpdates": [
    {
      "sectionId": "s1",
      "items": [
        {
          "productId": "prod_412",
          "name": "Cashmere Travel Wrap",
          "brand": "Aria",
          "price": 42.0,
          "category": "Fashion",
          "artwork": {
            "imageUrl": "https://media.example.com/products/prod-412.webp"
          },
          "matchScore": 88,
          "matchReason": "Rising affinity signal"
        }
      ]
    }
  ],
  "promotedCount": 1,
  "demotedCount": 0
}
```

`sectionUpdates` is **partial** — only sections whose product set or scores actually changed are
present; regroup items into their existing section id, don't replace the whole layout.
`promotedCount`/`demotedCount` count items whose score moved by 8 or more since their last-known
value.

> **Warning**
>
> **Rescore re-gates the visible set before any model call — client-flagged "visible" items can be
> silently dropped.** The server re-resolves the member's age tier from scratch (never trusts the
> client) and re-applies the age gate to your `visibleProductIds` first. An id your client
> considers visible but that this member's tier may not see is dropped and **never sent to the
> model at all** — it simply won't appear in `sectionUpdates`. Treat `visibleProductIds` as
> advisory, not authoritative: always render from the platform's response, not your own tracked
> visible set.

A product with no prior curate/rescore state for this member is honestly dropped from the
response rather than assigned a fabricated section. Two members sharing one seat get fully
isolated curations — one member's signal never influences the other's.

> **Note**
>
> Both requests accept a couple of extra fields reserved for forward compatibility —
> `ShoppingCurateRequest.existingSectionIds` and `ShoppingRescoreRequest.enrichmentCycle`. Sending
> them is harmless; neither currently affects the response.

## Errors

Standard [envelope](/guides/errors). Both: `400 session.unresolved`, `401` / `403`, `500`.
Curate additionally: `422 request.validation` (blank `seatId`). Rescore additionally: `422
request.validation` (blank `seatId`, empty `visibleProductIds`, or more than 80). Both endpoints
enforce the [per-client request-rate limit](/guides/errors#rate-limits): over it,
`429 rate.limited` with a `Retry-After` header. A policy/ceiling denial, or an output-guard
rejection of model text, still surfaces as `500 internal.error`.