Shopping Curate & Rescore

POST /v1/shopping/curate and POST /v1/shopping/rescore — sectioned, per-member shopping with code-owned pricing.

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-scopedrequire the X-Session-Id header.

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):

FieldRequiredRules
seatIdyesNon-blank.
memberSlotnoOmit for solo.
destinationContextno{ originCity?, destinationCity?, destinationCountry?, tags?: string[] } — trip context that steers section relevance. All sub-fields optional.
moodnoFree text, advisory only.
$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" } }'
1{
2 "schemaVersion": "1",
3 "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d",
4 "sections": [
5 {
6 "id": "s1",
7 "title": "Picks for Tokyo",
8 "items": [
9 {
10 "productId": "prod_412",
11 "name": "Cashmere Travel Wrap",
12 "brand": "Aria",
13 "price": 42.0,
14 "category": "Fashion",
15 "matchScore": 80,
16 "matchReason": "Matches your comfort-travel affinity"
17 }
18 ]
19 }
20 ],
21 "totalCandidates": 214,
22 "ageExcludedCount": 12,
23 "ageTierApplied": "Adult",
24 "personaBound": true
25}
  • totalCandidates and ageExcludedCount are computed against your airline’s entire catalog — curate has no category/price pre-filter the way search does.
  • ageTierApplied is the resolved age tier (Unknown behaves as Child). personaBound is the same cold-start signal used elsewhere: false means no persona/affinity is bound yet, so section ordering is affinity-neutral rather than fabricated.

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):

FieldRequiredRules
seatIdyesNon-blank.
memberSlotnoOmit for solo.
visibleProductIdsyes1–80 product ids — the set you’re currently showing this member.
$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"] }'
1{
2 "schemaVersion": "1",
3 "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d",
4 "sectionUpdates": [
5 {
6 "sectionId": "s1",
7 "items": [
8 {
9 "productId": "prod_412",
10 "name": "Cashmere Travel Wrap",
11 "brand": "Aria",
12 "price": 42.0,
13 "category": "Fashion",
14 "matchScore": 88,
15 "matchReason": "Rising affinity signal"
16 }
17 ]
18 }
19 ],
20 "promotedCount": 1,
21 "demotedCount": 0
22}

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.

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.

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. 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). There is no 429 for either endpoint today — a policy/ceiling denial, or an output-guard rejection of model text, surfaces as 500 internal.error.