> 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 Plan & Extend

Two calls build a multi-day itinerary for a member:

* **`POST /v1/destination/plan`** — a fresh itinerary, starting at day 0.
* **`POST /v1/destination/extend`** — append more days to an itinerary your client is already
  holding.

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

> **Note**
>
> **Content-category safety on these two endpoints is code-enforced.** Like every other
> age-gated surface in this API (catalog, suggestions, shopping, ads), `/plan` and `/extend`
> filter the composed output in **code, after the model runs**: for a restricted member —
> `Child`, `Tween`, or `Unknown` tier, with `Unknown` treated as `Child`, per the
> [age-tier model](/guides/sessions#age-tiers) — any nightlife, bar, casino, gambling, or
> adult-category item is **dropped from the response**, regardless of what the model composed.
> You may serve `/plan` and `/extend` to members of every tier.
>
> Two consequences to design for: a restricted member's time blocks and arrival
> recommendations can honestly contain **fewer items** than an adult's for the same trip —
> sparser by design, never padded with substitutes — and adult members' itineraries are
> unchanged by this filter.

## Plan

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.                             |
| `tripDurationNights` | yes      | Integer, **1–7 inclusive**. There is no date field — see [Dates](#dates-are-entirely-code-computed) below. |
| `origin`             | no       | Same locator shape as `destination`; used only for arrival-narrative context.                              |

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

```json
{
  "schemaVersion": "1",
  "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d",
  "destination": { "city": "Lisbon", "country": "Portugal" },
  "days": [
    {
      "dayIndex": 0,
      "date": "2026-08-01",
      "dayLabel": "Day 1",
      "description": "Arrival day",
      "timeBlocks": [
        { "slot": "morning", "items": [{ "id": "place_cafe_a_brasileira", "name": "Café A Brasileira", "category": "cafe" }] },
        { "slot": "lunch", "items": [{ "name": "Time Out Market", "category": "dining" }] },
        { "slot": "afternoon", "items": [{ "name": "Belém Tower", "category": "cultural" }] },
        { "slot": "evening", "items": [{ "name": "Fado dinner in Alfama", "category": "dining" }] }
      ]
    }
  ],
  "arrival": {
    "transitInstructions": "Take the Aerobus into the city center",
    "jetLagTip": "Stay hydrated on arrival",
    "eveningRecs": [{ "id": "place_cafe_a_brasileira", "name": "Café A Brasileira", "category": "cafe" }]
  },
  "weatherAvailable": true
}
```

* `heroImage` is declared on the response but **always omitted today** — call
  [`/destination/hero-image`](/guides/hero-images) separately for a banner image.
* `days[].description` and each item's descriptive text are model free text, output-guarded.

## Extend

Body (max 64 KiB):

| Field            | Required | Rules                                                                                                                                                                      |
| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `seatId`         | yes      | Non-blank.                                                                                                                                                                 |
| `memberSlot`     | no       | Omit for solo.                                                                                                                                                             |
| `destination`    | yes      | Same nested locator shape.                                                                                                                                                 |
| `existingDays`   | yes      | Integer ≥ 0 — how many days your client already holds.                                                                                                                     |
| `newTotalDays`   | yes      | Integer ≥ 0 — the total day count you want after this call. `newTotalDays - existingDays` must not exceed 7.                                                               |
| `pinnedPlaceIds` | no       | Places to keep out of the new days. Default `[]`, max 100.                                                                                                                 |
| `weather`        | no       | A client-supplied forecast for the new days (same shape as the response's `days[].weather`). If you supply one, the server trusts it verbatim instead of fetching its own. |

```bash
curl -X POST "https://api.staging.westiq.ai/v1/destination/extend" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "X-Session-Id: seat-12A-f3d9c1" \
  -H "Content-Type: application/json" \
  -d '{
    "seatId": "12A",
    "destination": { "city": "Lisbon", "country": "Portugal" },
    "existingDays": 3,
    "newTotalDays": 5
  }'
```

```json
{
  "schemaVersion": "1",
  "correlationId": "req_0197c0a1b2c3d4e5f60718293a4b5c6d",
  "newDays": [
    { "dayIndex": 3, "date": "2026-08-04", "dayLabel": "Day 4", "timeBlocks": [] }
  ]
}
```

`newDays` carries **only the newly generated days** — never the full itinerary; your client
already holds the earlier days, and the server holds no itinerary state of its own between calls.

> **Note**
>
> A `newTotalDays` that isn't greater than `existingDays` is a valid, cost-free request: it
> short-circuits to `{"newDays": []}` with zero model calls. If the model returns more days than
> you asked for, the response is clipped to the exact count; if fewer, it's left honestly short —
> never padded.

## Dates are entirely code-computed

Neither request has a date field. `/plan` sends `tripDurationNights`; `/extend` sends
`existingDays`/`newTotalDays`. The server resolves "today" from its own clock at request time, and
each day's `date` is pure arithmetic from that anchor — the model is never asked for a date at
all.

> **Warning**
>
> **`/extend` re-anchors "today" on every call — it does not remember the original plan's start
> date.** If you call `/extend` on a different calendar day than the original `/plan` call, the
> new days' dates are computed from *that* call's "now," which may not line up with day 0 of the
> itinerary you're extending. This is fine for extending within the same session, but don't assume
> date continuity across a stale, multi-day-old plan.

## Place metadata: provider-merged or null

Every itinerary item's `address`, `phone`, `hours`, `rating`, `reviewCount`, `imageUrl`, and
`sourceUrl` start unset. They are filled in **only** when the model-asserted place name matches an
entry from the places provider's results for that destination, fetched in parallel with the model
call — never guessed, never fabricated. **The model itself generates the place names and
categories from its own knowledge; the places-provider pool is used only for this metadata
overlay, never shown to the model as a candidate list to choose from.** A places-provider match
(or lack of one) only affects whether `address`/`phone`/etc. get filled in — the overlay itself
filters nothing. Content-category filtering for restricted members is a separate, code-enforced
step on the composed output (the note at the top of this page).

## Weather

`weatherAvailable` (also the field name on the dedicated [`/destination/weather` endpoint](/guides/destination-weather)) reports whether the platform could resolve a forecast for
this trip: coordinates are resolved from `lat`/`lon` → `airport` → `city`/`country`, in that order,
against a small reference table. If resolution fails, or the weather provider call itself fails,
`weatherAvailable` is `false` and **every** day's `weather` is `null` — never a fabricated
forecast. This is a separate code path from the dedicated weather endpoint, though the semantics
match.

> **Note**
>
> If your environment's places provider is disabled, expect every itinerary item's provider-merged
> fields to be null on every call — legitimate honest degradation, not a bug. The weather provider
> is keyless and always on, so `weatherAvailable` should behave normally regardless.

## Errors

Standard [envelope](/guides/errors). Both: `400 session.unresolved`, `401` / `403`, `500`.
Plan additionally: `422 request.validation` (blank `seatId`, blank `destination.city`/`.country`,
or `tripDurationNights` outside 1–7). Extend additionally: `422 request.validation` (blank
`seatId`, blank `destination.city`/`.country`, a negative `existingDays`/`newTotalDays`, a delta
over 7, 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`.