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

# Destination Search & Replace

Two calls curate points of interest (dining and experiences) for a member's destination:

* **`POST /v1/destination/search`** — a fresh, two-category candidate list.
* **`POST /v1/destination/replace`** — swap **one** place out for a new pick, without disturbing
  the rest of what you're showing.

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

## Search

Body (max 64 KiB):

| Field                | Required | Rules                                           |
| -------------------- | -------- | ----------------------------------------------- |
| `seatId`             | yes      | Non-blank.                                      |
| `memberSlot`         | no       | Omit for solo.                                  |
| `destinationCity`    | yes      | Non-blank. **Flat field** — see the note below. |
| `destinationCountry` | yes      | Non-blank. **Flat field.**                      |

> **Note**
>
> Search takes `destinationCity`/`destinationCountry` as **flat top-level fields**. Replace, and
> the [plan/extend endpoints](/guides/destination-plan), take a **nested** `destination: {city,
>   country, …}` object instead. The two shapes are genuinely different — don't reuse one request
> builder for both.

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

```json
{
  "schemaVersion": "1",
  "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d",
  "dining": [
    {
      "id": "place_trattoria",
      "name": "Trattoria da Enzo",
      "category": "dining",
      "neighborhood": "Trastevere",
      "matchScore": 82,
      "whyForYou": "Matches your love of local, unfussy spots"
    }
  ],
  "experiences": [
    {
      "id": "place_colosseum",
      "name": "Colosseum",
      "category": "cultural",
      "matchScore": 74
    }
  ]
}
```

Each place carries an `id`/`name` plus provider-merged-or-null fields (`category`, `neighborhood`,
`priceTier`, `address`, `phone`, `rating`, `reviewCount`, `imageUrl`, `sourceUrl` — omitted
individually when the places provider has no data for that field), a **code-clamped** `matchScore`
(0–100 integer), and optional model free text in `whyForYou`.

> **Note**
>
> On a **search** result, `category` is omitted whenever the places provider didn't supply one —
> don't treat it as guaranteed. On a **replace** result, it always falls back to a generic label
> when the provider gives nothing, so it's always present there.

## Replace

Body (max 64 KiB):

| Field              | Required | Rules                                                                                                                        |
| ------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `seatId`           | yes      | Non-blank.                                                                                                                   |
| `memberSlot`       | no       | Omit for solo.                                                                                                               |
| `destination`      | yes      | **Nested** `{ city, country, airport?, lat?, lon? }` — city and country non-blank.                                           |
| `dismissedPlaceId` | yes      | The place being swapped out. Non-blank.                                                                                      |
| `timeBlockSlot`    | no       | One of `morning` / `lunch` / `afternoon` / `evening`, if you want the replacement scoped to a slot. Any other value → `422`. |
| `pinnedPlaceIds`   | no       | Places to never suggest as the replacement. Default `[]`, max 100.                                                           |

```bash
curl -X POST "https://api.staging.westiq.ai/v1/destination/replace" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "X-Session-Id: seat-12A-f3d9c1" \
  -H "Content-Type: application/json" \
  -d '{
    "seatId": "12A",
    "destination": { "city": "Rome", "country": "Italy" },
    "dismissedPlaceId": "place_trattoria"
  }'
```

```json
{
  "schemaVersion": "1",
  "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d",
  "replacement": {
    "name": "Roscioli",
    "category": "dining",
    "matchScore": 79
  },
  "reasoning": "A similarly local pick, different neighborhood"
}
```

`replacement` (an itinerary-item shape carrying the same provider-merged-or-null fields as a
search result, plus `hours` when known) and `reasoning` are **omitted together** whenever no
replacement could be produced — never a fabricated place.

## Business logic

* **Search** fetches dining and experience candidates independently; if **both** categories come
  back empty, the platform makes **no model call** and returns the honest-empty shape
  (`dining: []`, `experiences: []`) at zero cost.
* **Replace**'s candidate pool is the **union** of both categories, minus `pinnedPlaceIds` and the
  `dismissedPlaceId` itself, fetched fresh on every call (not cached). The model picks exactly one
  replacement from that offered pool; if it names something outside the pool, the response is the
  same honest-null shape — not a fabricated place, and not an error.

> **Note**
>
> **Places-provider degradation is the current default in the sandbox, not an edge case.** If your
> environment's places provider is disabled, `/search` returns `dining: [], experiences: []` and
> `/replace` returns `replacement` omitted on every call — by design. Don't read an empty result as
> a bug during initial integration; confirm with your partner manager whether live place data is
> enabled for your environment.

### Place screening and categories

For Child, Tween and Unknown members, candidate screening checks every supplied usable category
for age-inappropriate venues, and checks names for explicit adult/nightlife terms.
If no usable category exists, the name also receives the broader venue screening.
Ordinary family venues such as golf clubs and kids clubs are not excluded merely for the word
"club"; nightlife venues remain excluded. The returned `category` uses the first usable category,
so an initial placeholder does not hide a later real category. This rule does not enable the places provider:
the disabled-provider empty results described above still apply.

## Errors

Standard [envelope](/guides/errors). Both: `400 session.unresolved`, `401` / `403`, `500`.
Search additionally: `422 request.validation` (blank `seatId`, `destinationCity`, or
`destinationCountry`). Replace additionally: `422 request.validation` (blank `seatId`, blank
`destination.city`/`destination.country`, blank `dismissedPlaceId`, an invalid `timeBlockSlot`, or
more than 100 `pinnedPlaceIds`). Both endpoints enforce the
[per-client request-rate limit](/guides/errors#rate-limits): over it, `429 rate.limited` with a
`Retry-After` header. Other policy/ceiling denials still surface as `500 internal.error`.