---
name: "wanderlens-api"
description: "Use the WanderLens API to read, create, and edit profiles, trips, routes, and POIs in a user's own account with their API key"
domain: "wanderlens"
confidence: "high"
source: "WanderLens Convex HTTP implementation"
---

# WanderLens API skill

> **Purpose:** This file is the canonical human-readable export of the active WanderLens API and is intentionally written so another user can install it as a standalone personal skill.

Try this:

```bash
export WL_API_BASE="<apiBaseUrl-from-the-environment-table-below>"
export WL_API_KEY="wl_live_<32 hex characters>"

curl -sS "$WL_API_BASE/routes" \
  -H "Authorization: Bearer $WL_API_KEY"
```

## When to use this skill

Use this skill when a user wants to create, inspect, or update data in their own WanderLens account through the WanderLens API.

- Use the user's own API key and the correct environment-specific `apiBaseUrl`.
- Prefer this file for agent behavior and handoff guidance.
- If the companion `docs/ai/developer-api/capabilities.json` file is available, prefer it when you need strict machine-readable endpoint lookup.

## Required inputs

- A WanderLens API key in the format `wl_live_<32 hex characters>`
- A target environment or explicit API base URL
- The concrete data the user wants to create or update, such as profile fields, trip itinerary details, route name, or POIs

## Overview

This guide documents the active WanderLens API implemented by the Convex HTTP surface in WanderLens. If the companion `docs/ai/developer-api/capabilities.json` file is available, use it for machine-readable consumption.

The public API exposes authenticated CRUD for traveler profiles, trips, routes, and POIs. Trip editing is CRUD-only: external AI tools can inspect trips and submit their own metadata/day/activity patches, but the public API does **not** generate trips, days, routes, or stops. `POST /api/v1/routes` creates route metadata only, and you build the route by adding POIs with `POST /api/v1/routes/{routeId}/pois`.

The legacy .NET Swagger surface is frozen and must not be treated as the current API contract.

## Workflow

1. Resolve the target environment from the user's request, then use that environment's Convex `apiBaseUrl`.
2. Authenticate every request with `Authorization: Bearer <user-api-key>`.
3. Use `POST /api/v1/trips` for itinerary-style creation and prefer sending the full known itinerary in the initial request.
4. Use `POST /api/v1/routes` plus `POST /api/v1/routes/{routeId}/pois` for manual route building.
5. Search existing POIs first with `GET /api/v1/pois/search`, then create manual/custom POIs inline when search misses a stop or when a route benefits from niche route-shaping stops such as scenic viewpoints, trailheads, beach entries, park gates, bridges, ferry docks, regroup points, or bike-friendly rest stops.
6. Respect the public rate limit of 5 requests per second per user.

## Guardrails

- Send requests to the Convex `.convex.site` API host from the environment table, not to `<frontend>/api/v1`.
- Do not invent unsupported request fields or behaviors. Unknown PATCH fields return `400`; unsupported trip date edits such as `startDate` and `endDate` are rejected.
- Treat the trip editing API as a storage API. It stores activities you provide; it does not call WanderLens AI or generate routes/trips for you.
- The public routes API does not expose AI route generation or `transportMode`.
- For route POI creation, use `poiId` or `poi`. Do not use legacy `googlePlaceId` fields on the public routes API.
- Prefer rich inline custom `poi` payloads over thin placeholders when you add manual stops to a route.
- Fill as many supported custom POI fields as you know: `name`, `latitude`, `longitude`, `address`, `category`, `description`, `sourceRefs`, and request-level `notes`.
- Use `description` for what the stop is and why it belongs on the route. Use `notes` for route-specific context such as best approach, terrain, regroup value, bike parking, timing, or viewpoint direction.
- Keep all reads and writes scoped to the account behind the provided API key.

---

## Runtime contract

| Item | Value |
| --- | --- |
| Runtime | Convex HTTP router |
| Base path | `/api/v1` |
| API host | Environment-specific Convex `.convex.site` host |
| Auth | `Authorization: Bearer wl_live_<32 hex characters>` |
| Rate limit | 5 requests per second per user |
| Max active API keys | 5 per user |
| CORS | `Access-Control-Allow-Origin: *` |
| Standard JSON errors | `{ "error": "<message>" }` |
| OpenAPI export | Not generated yet |

## Maintainer references

The items below are repository-local references for keeping this export in sync. A consuming user does not need them to call the API.

- `WanderLens.React/convex/http.ts`
- `WanderLens.React/convex/apiMiddleware.ts`
- `WanderLens.React/convex/apiProfile.ts`
- `WanderLens.React/convex/apiRoutes.ts`
- `WanderLens.React/convex/apiTrips.ts`
- `openspec/specs/api-key-auth/spec.md`
- `openspec/specs/http-api-routes/spec.md`
- `openspec/specs/http-api-trips/spec.md`
- `WanderLens.React/openspec/changes/trip-editability-enhancements/specs/trip-api-crud/spec.md`
- `openspec/specs/prompt-personalization/spec.md` for the profile API contract

## Environment endpoints

| Environment | Frontend | API base URL | Notes |
| --- | --- | --- | --- |
| Local dev | `http://localhost:1337` | `https://shiny-dinosaur-571.eu-west-1.convex.site/api/v1` | Local frontend still talks to a cloud-hosted Convex HTTP API. |
| Cloud dev | `https://dev.wanderlens.app` | `https://energized-gopher-195.eu-west-1.convex.site/api/v1` | Use this host for API key access. `dev.wanderlens.app/api/v1/*` is not the API origin. |
| Production | `https://wanderlens.app` | `https://proper-kudu-466.eu-west-1.convex.site/api/v1` | Use the Convex HTTP host unless a separate frontend proxy is explicitly introduced. |

- Local dev and cloud dev share the same non-production billing environment.
- Do not assume `<frontend>/api/v1` is wired to the backend. The active API is mounted on the Convex `.convex.site` host for each deployment.

## Capability groups

### API key authentication

- API key format: `wl_live_<32 hex characters>`
- Missing or invalid key: `401 {"error":"Invalid or missing API key"}`
- Revoked key: `401 {"error":"API key has been revoked"}`
- Rate limit exceeded: `429 {"error":"Rate limit exceeded. Maximum 5 requests per second."}`
- Successful authenticated requests update the key's `lastUsedAt` timestamp.

### Traveler profile

| Method | Path | Notes |
| --- | --- | --- |
| `GET` | `/api/v1/profile` | Returns `{ profile, tier, profileCompleteness }`, or `{}` when no profile exists. |
| `PUT` | `/api/v1/profile` | Partial merge update for stored traveler profile fields. Validation errors return `400` with `error` and `issues`. |

Supported profile fields:
`ageRange`, `homeCountry`, `languages`, `occupation`, `travelStyle`, `pace`, `budget`, `mobility`, `narrationTone`, `depthPreference`, `personalNote`, `visitedCountries`, `travelerInterests`

Example `PUT /profile` payload:

```json
{
  "homeCountry": "Finland",
  "languages": ["fi", "en"],
  "travelStyle": "balanced",
  "pace": "moderate",
  "budget": "mid-range",
  "narrationTone": "friendly",
  "depthPreference": "deep",
  "personalNote": "Prioritize scenic walks, saunas, and local food.",
  "travelerInterests": ["hiking", "architecture", "local-food"],
  "visitedCountries": ["SE", "NO", "IT"]
}
```

- `PUT /profile` is a partial merge update: omit fields that should stay unchanged.
- Validation failures return `400` with `error` plus an `issues` array.

### Trips

| Method | Path | Notes |
| --- | --- | --- |
| `GET` | `/api/v1/trips` | List trips available to the API key user. |
| `POST` | `/api/v1/trips` | Create a trip. Required: `title`, `destination`, `startDate`, `endDate`, `interests`. Optional: `notes`, `status`, `days`, `stays`. |
| `GET` | `/api/v1/trips/{tripId}` | Get full trip detail. |
| `PATCH` | `/api/v1/trips/{tripId}` | Update trip metadata. Supported fields: `title`, `notes`, `interests`, `status`, `destination`. `status` and `destination` require owner permission. |
| `DELETE` | `/api/v1/trips/{tripId}` | Delete an owned trip. Shared read/write access cannot delete. |
| `PATCH` | `/api/v1/trips/{tripId}/days/{dayNumber}` | Update day fields: `theme`, `notes`. |
| `POST` | `/api/v1/trips/{tripId}/days/{dayNumber}/activities` | Batch-create up to 20 activities for a day. |
| `PUT` | `/api/v1/trips/{tripId}/days/{dayNumber}/activities/reorder` | Reorder activities using `activityIds`. |
| `PATCH` | `/api/v1/trips/{tripId}/days/{dayNumber}/activities/{activityId}` | Update activity fields. |
| `DELETE` | `/api/v1/trips/{tripId}/days/{dayNumber}/activities/{activityId}` | Delete a day activity. |

Trip mutation responses use `404 {"error":"Trip not found"}` for missing or inaccessible trips, and `403 {"error":"Forbidden"}` when the key can read the trip but cannot perform the write.

Create-trip payload structure:

```json
{
  "title": "Dolomites adventure week",
  "destination": {
    "name": "Cortina d'Ampezzo",
    "lat": 46.5405,
    "lng": 12.1357
  },
  "startDate": "2026-07-06",
  "endDate": "2026-07-12",
  "interests": ["hiking", "photography", "local-food"],
  "notes": "Prefer mountain huts, scenic drives, and sunset viewpoints.",
  "status": "planning",
  "stays": [
    {
      "checkIn": "2026-07-06",
      "checkOut": "2026-07-09",
      "city": "Cortina d'Ampezzo",
      "country": "Italy",
      "name": "Hotel de Len",
      "type": "hotel",
      "formattedAddress": "Via Cesare Battisti 66, Cortina d'Ampezzo",
      "lat": 46.5376,
      "lng": 12.1392
    }
  ],
  "days": [
    {
      "dayNumber": 1,
      "date": "2026-07-06",
      "theme": "Arrival and town orientation",
      "notes": "Keep the first day light after travel.",
      "activities": [
        {
          "title": "Hotel check-in",
          "type": "accommodation",
          "startTime": "15:00",
          "location": {
            "name": "Hotel de Len",
            "lat": 46.5376,
            "lng": 12.1392,
            "address": "Via Cesare Battisti 66, Cortina d'Ampezzo"
          }
        },
        {
          "title": "Sunset walk to the basilica",
          "type": "poi",
          "poiId": "<poiId-from-pois-search>",
          "startTime": "19:30",
          "location": {
            "name": "Basilica Minore dei Santi Filippo e Giacomo",
            "lat": 46.5389,
            "lng": 12.1363
          },
          "notes": "Best light is 20-30 minutes before sunset.",
          "isPinned": true
        },
        {
          "title": "Private via ferrata briefing",
          "type": "custom",
          "startTime": "21:00",
          "location": {
            "name": "Guide office",
            "lat": 46.5398,
            "lng": 12.1374
          },
          "description": "Manual/custom activity not backed by a public place listing.",
          "customDetails": {
            "difficulty": "moderate",
            "durationHours": 1.5,
            "equipmentNeeded": ["helmet", "harness"]
          }
        }
      ]
    }
  ]
}
```

Activity item shape for trip creation or batch add:

| Field | Type | Notes |
| --- | --- | --- |
| `title` | string | Required |
| `type` | `poi \| meal \| transport \| accommodation \| custom` | Required |
| `poiId` | string | Optional canonical WanderLens POI id for POI-backed activities |
| `location` | object | Optional `{ name, lat, lng, address?, placeId? }` |
| `startTime`, `endTime` | string | Optional |
| `duration` | number | Optional |
| `notes`, `description`, `markerIcon` | string | Optional |
| `isPinned` | boolean | Optional |
| `customDetails` | object \| null | Optional rich metadata for custom activities |

- `poiId` should be a canonical WanderLens POI id, typically returned by `GET /api/v1/pois/search`.
- `destination.placeId` and `activity.location.placeId` are optional opaque identifiers when the caller has them. The API does not require a Google-specific format.

Sub-resource payloads:

- `PATCH /trips/{tripId}` accepts any subset of `title`, `notes`, `interests`, `status`, `destination`.
- `PATCH /trips/{tripId}/days/{dayNumber}` accepts `theme` and `notes`; `null` or `""` clears those values.
- `POST /trips/{tripId}/days/{dayNumber}/activities` expects `{ "activities": [...] }` with at most 20 entries.
- `PUT /trips/{tripId}/days/{dayNumber}/activities/reorder` expects `{ "activityIds": ["tripActivityId1", "tripActivityId2"] }`.
- `PATCH /trips/{tripId}/days/{dayNumber}/activities/{activityId}` accepts any subset of `title`, `description`, `type`, `startTime`, `endTime`, `duration`, `location`, `notes`, `isPinned`. `description`, `startTime`, `endTime`, `duration`, `location`, and `notes` can be set to `null` to clear them.

Trip editing curl recipe:

```bash
# 1. Inspect the current trip and capture day/activity IDs.
curl -sS "$WL_API_BASE/trips/$TRIP_ID" \
  -H "Authorization: Bearer $WL_API_KEY"

# 2. Patch trip metadata. startDate/endDate are intentionally unsupported.
curl -sS -X PATCH "$WL_API_BASE/trips/$TRIP_ID" \
  -H "Authorization: Bearer $WL_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{"title":"Milan design weekend","notes":"Keep mornings flexible","interests":["architecture","design"]}'

# 3. Patch a 1-based day number.
curl -sS -X PATCH "$WL_API_BASE/trips/$TRIP_ID/days/2" \
  -H "Authorization: Bearer $WL_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{"theme":"Brera and modern design","notes":null}'

# 4. Add up to 20 activities. This stores your provided data only; no AI generation runs.
curl -sS -X POST "$WL_API_BASE/trips/$TRIP_ID/days/2/activities" \
  -H "Authorization: Bearer $WL_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{"activities":[{"title":"Triennale Milano","type":"poi","startTime":"10:00","duration":120},{"title":"Aperitivo near Brera","type":"meal","startTime":"18:30"}]}'

# 5. Update, reorder, or delete existing activities by activity id.
curl -sS -X PATCH "$WL_API_BASE/trips/$TRIP_ID/days/2/activities/$ACTIVITY_ID" \
  -H "Authorization: Bearer $WL_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{"duration":150,"location":null}'

curl -sS -X PUT "$WL_API_BASE/trips/$TRIP_ID/days/2/activities/reorder" \
  -H "Authorization: Bearer $WL_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{"activityIds":["activity_2","activity_1"]}'

curl -sS -X DELETE "$WL_API_BASE/trips/$TRIP_ID/days/2/activities/$ACTIVITY_ID" \
  -H "Authorization: Bearer $WL_API_KEY"
```

### Routes and POIs

| Method | Path | Notes |
| --- | --- | --- |
| `GET` | `/api/v1/routes` | List routes owned by the API key user. |
| `POST` | `/api/v1/routes` | Create a route container. Required: `name`. Optional: `description`, `city`, `theme`, `startLatitude`, `startLongitude`. Fill `startLatitude` and `startLongitude` whenever you know them so map-based product surfaces can place the route correctly. This does not generate stops or choose a transport mode. |
| `GET` | `/api/v1/routes/{routeId}` | Get route detail including POIs. |
| `POST` | `/api/v1/routes/{routeId}/share` | Create or refresh a share link. Returns `shareToken` and `shareUrl`. |
| `DELETE` | `/api/v1/routes/{routeId}/share` | Revoke the current share link. |
| `DELETE` | `/api/v1/routes/{routeId}` | Delete a route and its route-POI links. |
| `POST` | `/api/v1/routes/{routeId}/pois` | Add a POI to a route. Provide exactly one of `poiId` or `poi`. Use `poiId` for an existing WanderLens POI or `poi` for a new manual/custom POI. |
| `DELETE` | `/api/v1/routes/{routeId}/pois/{routePoiId}` | Remove a linked POI from a route. |
| `GET` | `/api/v1/pois/search` | Search POIs by `city`, `category`, and optional `limit`. |

Quick route-building flow:

1. Search existing public POIs with `GET /api/v1/pois/search`.
2. Create a route shell with `POST /api/v1/routes`.
3. Add stops with `POST /api/v1/routes/{routeId}/pois` using either `poiId` or an inline `poi` object.

- The backend schedules route geometry once a route has at least two linked POIs.
- Provide both `startLatitude` and `startLongitude` whenever you know them. They are optional, but routes without them cannot be positioned on map-based surfaces such as Explore.
- The current public routes API does not expose AI stop generation or `transportMode` selection.
- The current public routes API does not expose `PATCH /api/v1/routes/{routeId}` or a route-POI reorder endpoint.
- Route POI order follows insertion order until a future reorder capability exists.
- Route detail responses do not include share metadata. Use `/api/v1/routes/{routeId}/share` to manage share links.
- Prefer custom POIs aggressively for route quality when public search misses scenic or route-defining stops.
- Make custom POIs information-rich so the route remains useful when another user or agent reads it later.

Create-route payload:

```json
{
  "name": "Cortina viewpoints",
  "startLatitude": 46.5405,
  "startLongitude": 12.1357,
  "city": "Cortina d'Ampezzo",
  "theme": "Sunrise and sunset photography",
  "description": "A compact route focused on dramatic mountain panoramas."
}
```

Add-route-POI payloads:

Existing POI already known to WanderLens:

```json
{
  "poiId": "<poiId-from-pois-search>",
  "notes": "Visit close to golden hour."
}
```

New or manual/custom POI:

```json
{
  "poi": {
    "name": "Forest turnout sunrise viewpoint",
    "latitude": 46.575,
    "longitude": 12.091,
    "address": "SR48 mountain road turnout near Cortina",
    "category": "viewpoint",
    "description": "Quiet roadside turnout with wide mountain views, room to pause without blocking traffic, and strong sunrise light for photos.",
    "sourceRefs": [
      {
        "provider": "custom",
        "externalId": "forest-turnout-sunrise-1",
        "isPrimary": true
      }
    ]
  },
  "notes": "Use as a sunrise regroup stop. Approach from the paved shoulder, leave space for bike parking, and face east for the best view."
}
```

- Provide exactly one of `poiId` or `poi`.
- Use `poiId` values returned by `GET /api/v1/pois/search` or route detail responses.
- For manual/custom POIs, `poi.name`, `poi.latitude`, and `poi.longitude` are required.
- `poi.sourceRefs` is optional. When present, each entry must include non-empty `provider` and `externalId`.
- When building routes, create custom POIs proactively for scenic or practical stops that public search does not cover well.
- Rich custom POIs should usually include `address`, `category`, `description`, `sourceRefs`, and request-level `notes`, not only the minimum required fields.
- Keep the custom `description` factual and place-focused. Use `notes` for route-specific context such as bike access, rest value, timing, surface, or regroup instructions.
- `GET /pois/search` supports `city`, `category`, and `limit` query params. The backend clamps `limit` into the `1..50` range.

## Response conventions

- All endpoints return JSON.
- Success responses are plain arrays or objects, not a shared envelope.
- `OPTIONS` preflight exists for every public API path.
- `OPTIONS` includes `GET, POST, PUT, PATCH, DELETE, OPTIONS` in `Access-Control-Allow-Methods`.
- Most validation failures return `400 {"error":"..."}`.
- Profile validation can additionally return an `issues` array.
- Trip editing uses `403 {"error":"Forbidden"}` for read-only keys and owner-only fields attempted by trip editors.
- Invalid day numbers such as `abc` return `400 {"error":"Invalid day number"}`; positive day numbers outside the trip return `404 {"error":"Day not found"}`.
- Activity IDs that do not exist return `404 {"error":"Activity not found"}`; an existing activity addressed through the wrong day returns `400 {"error":"Activity does not belong to this day"}`.
- Reorder requests must include exactly all activity IDs for that day; IDs from another day return `400 {"error":"Activity IDs do not match this day"}`.
- Route detail access uses `404 {"error":"Route not found"}` for missing or unauthorized resources.
- `POST /routes/{routeId}/pois` can return `404 {"error":"POI not found"}` when `poiId` does not resolve.
- `POST /routes/{routeId}/pois` can return `409 {"error":"Source ref <provider>:<externalId> is already linked to another POI"}` when a source reference collides with another POI.
- `DELETE /routes/{routeId}/pois/{routePoiId}` uses `404 {"error":"Route POI not found"}` for a missing or inaccessible link.
- `POST /routes/{routeId}/share` returns `{ "shareToken": "...", "shareUrl": "..." }` and uses `404 {"error":"Route not found"}` for a missing or inaccessible route.
- Trip detail access uses `404 {"error":"Trip not found"}` for missing or unauthorized resources.

## Agent guidance

- Prefer `docs/ai/developer-api/capabilities.json` for automation and endpoint discovery.
- Read `environments[*].apiBaseUrl` for the full callable API origin, not just the shared `/api/v1` path prefix.
- When enough information is available, create the **full trip itinerary in the initial `POST /trips` call** instead of creating an empty shell and patching it repeatedly. Include known `stays`, every `dayNumber`, and all known activities in the intended order.
- Use the full activity model, not only POIs. Meals, transfers, accommodation check-ins, and user-defined stops belong in the itinerary too.
- When a stop is not backed by Google Places or search does not return it, do **not** drop it from the plan. Use trip activities with `type: "custom"` and/or create a manual route POI with an inline `poi` object.
- For route building, search existing POIs first, but actively create custom/manual POIs when the user references private venues, trailheads, ferry docks, scenic pullouts, cabins, regroup points, or other niche route-shaping locations.
- When you create a manual route POI, make it rich by filling `address`, `category`, `description`, `sourceRefs`, and route-level `notes` whenever you can infer them reliably.
- Do not assume `POST /routes` triggers AI stop selection or creates a bicycle route. The current public routes API does not expose AI generation or `transportMode`.
- Prefer this file for implementation guidance and handoff context.
- When the HTTP surface changes, update this file, `capabilities.json`, and the wrapper skill files together.
