> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cyclemate.club/llms.txt
> Use this file to discover all available pages before exploring further.

# Multi-Modal Bike Directions API

> Get multi-step routes combining walking, bike-share, and personal bikes

## Overview

Get comprehensive routing options that integrate bike-share availability. Returns multiple route alternatives including:

* **Personal Bike**: Direct cycling route
* **Docked Bike-Share**: Walk to station → cycle → walk to destination. Pickup and drop-off stations are always in the **same system** — the drop-off is the nearest dock in the pickup station's system.
* **Docked E-Bike**: Same shape as docked bike-share, but the origin station is the nearest one with at least one e-bike available, and the cycling leg is recalculated at a higher e-bike speed. Marked with `is_electric: true` on the route option. Systems whose fleet is e-bike-only (e.g. Lime London) produce **only** this variant, never a classic docked option.
* **Dockless Bike-Share**: Walk to bike → cycle to destination (per provider)

## Authentication

This endpoint requires an API key. Include it in the `Authorization` header:

```
Authorization: Bearer YOUR_API_KEY
```

A valid user session is also accepted and grants higher rate limits — see [Rate Limits](#rate-limits).

## Request Body

<ParamField body="origin" type="array" required>
  Starting point as `[longitude, latitude]`
</ParamField>

<ParamField body="destination" type="array" required>
  Ending point as `[longitude, latitude]`
</ParamField>

<ParamField body="supersafe" type="boolean">
  Optional. Changes how the response is fanned out:

  * **Omitted (recommended for current clients).** The server returns both `supersafe=true` and `supersafe=false` **Personal** routes in a single response, tagged per-route (see the `supersafe` field on each route alternative below). When the two variants are geometrically identical (short trips, or trips already entirely on safe roads), only the `supersafe=true` route is returned — so clients may receive a single Personal route even with `supersafe` omitted. Other option types (Docked, E-bike, Dockless) are returned once at the default regime (`true`) — they involve walking legs and rental lookups, so toggling for them still requires a refetch. The client filters the Personal pair locally when the user flips their SuperSafe toggle.
  * **Provided (back-compat for older app builds).** Every option type — Personal, Docked, E-bike, Dockless — is computed under exactly that regime and only that regime is returned. This matches the pre-multi-variant contract.

  The value is also recorded on the analytics row so the search funnel can attribute searches back to a toggle position.
</ParamField>

<ParamField body="event_id" type="number">
  Optional event ID. If provided and the event has a route, includes the event route as a non-reroutable final cycling segment. **Exception:** when the referenced event has `is_solo_share = true`, the host's polyline is **not** appended — the response returns the same shape as a non-event request (direct origin → destination), with `event` metadata attached for context. Solo shares are live broadcasts, so a joining guest routes straight to the destination on their own optimal path instead of being detoured through the host's start point.
</ParamField>

<ParamField body="destination_id" type="number">
  Optional destination ID for enriched destination information in response
</ParamField>

<ParamField body="name" type="string">
  Optional display name for the destination (used if `destination_id` not provided). Stored on the analytics row as `destination_name`.
</ParamField>

<ParamField body="origin_name" type="string">
  Optional display name for the origin waypoint. Recorded verbatim on the analytics row as `origin_name`. The mobile/web planner forwards the literal string `"Current location"` when the origin is the device GPS fix — passing it lets us split that funnel from named-origin searches in analytics.
</ParamField>

<ParamField body="departure_time" type="string">
  Optional wall-clock departure time the rider plans to leave, as `"YYYY-MM-DDTHH:mm"` with **no** timezone offset (e.g. `"2026-06-03T21:00"`). The server interprets it in the route city's timezone to decide day/night routing: when the picked time lands in the dark window (30 minutes after sunset to 30 minutes before sunrise at the origin) **and the city has street-lighting data** (London today), every option is tagged `nighttime: true` and the Personal option fans out its night variants (see the `nighttime` field below). A departure shortly **before** the dark window also triggers the night fan-out when the ride is expected to still be going at nightfall: the server estimates the ride's duration as the origin→destination straight-line distance × 1.4 detour factor at cycling speed (15 km/h), and if departure + estimate lands inside the dark window at the destination, the Personal option fans out night-first exactly as if it were already night (a "dusk-crossing" ride). Cities without lighting data never go nighttime. Omit to time the route to **now**. An unparseable value returns `400`.

  The departure time also drives **time-based avoidances**: operators can mark a road as avoided on a recurring weekly window (e.g. a Saturday street market) or a one-time date range (e.g. a festival closure; may span multiple days). When the departure falls inside such a window, every cycling leg (personal and bike-share, both SuperSafe and Direct) routes around the road; walking legs are unaffected. These windows never change the map's safety coloring (routing-time only). One-time windows that are active at the departure time — or open within the next 60 minutes — and lie near a returned option's cycling legs are additionally surfaced on that option as `disruptions` (see below), so clients can explain the detour.

  Example: `"2026-06-03T21:00"`.
</ParamField>

<ParamField body="parking_type" type="string">
  Optional "navigate to parking" mode: `"ebike"` or `"regular"`. Any other value returns `400`. When set, the **Personal ("My bike") option only** changes shape — it becomes a cycling leg from the origin to the nearest qualifying parking spot near the destination, followed by a walking leg to the destination. The cycling leg's `endLabel` pill is `"Park bike"`.

  * `"ebike"` searches designated rental e-bike parking bays (`ebike_parking = true` — the borough council bay datasets synced daily by refresh-parking). Use this when the rider is on a rental e-bike (e.g. a Lime) that must be parked in a bay. **Gated by the destination borough's rule:** a bay is returned only when the destination falls inside a borough that *mandates* bay parking (`ebike_parking_rule = 'bay_mandatory'`, surfaced as `destination_parking` below). In a **free-floating** borough — where you may park anywhere — no bay is returned and the route stays direct, so the rider is never sent to a designated bay in a neighbouring borough. The borough rule is reported in `destination_parking` either way.
  * `"regular"` searches regular cycle parking racks/stands (TfL survey + NYC DOT data). **Not** gated by the borough rule (a rack is never mandated) — always the nearest rack within radius.

  The spot is resolved **once** per request (nearest within 1,500 m of the destination) and shared by every Personal variant in the fan-out. Every other option type (Docked, E-bike, Dockless) is completely unaffected.

  **Fallback:** for `"regular"`, or for `"ebike"` in a `bay_mandatory` borough, when no qualifying spot exists within 1,500 m the Personal option falls back to the plain direct cycling shape and carries `parking: null` — never an error. Clients should render a "no parking found near destination" notice off that field. For `"ebike"` in a `free_floating` borough the route is intentionally direct with `parking: null`; use `destination_parking` to tell the two apart (park-anywhere vs no-bay-found).

  **Event interplay:** when `event_id` resolves a real event route (the event has a route and is not a solo share), `parking_type` is ignored — the ride ends at the event, not a parking spot. Solo shares route direct and DO honor `parking_type`.
</ParamField>

<ParamField body="operator" type="string">
  Optional rental e-bike operator (London): `"lime"` or `"forest"`. Any other value returns `400`. Only meaningful with `parking_type: "ebike"` — ignored otherwise.

  London's operators serve **different, non-identical** borough sets (recorded in `helloapp_area_operator_coverage`). When `operator` is set, the e-bike bay search is restricted to bays inside a borough the operator serves. So when the destination is in a borough the operator **doesn't** serve, the nearest legal bay sits at the operator's coverage edge — the Personal option cycles to that bay and **walks the rest**, and `destination_parking.covered` is `false`. When the destination borough **is** served, behaviour is unchanged (nearest operator bay in a `bay_mandatory` borough; direct route in a `free_floating` one).
</ParamField>

## Response

Returns an envelope `{ search_id, routes, destination_parking }` where `routes` is an array of route alternatives, each containing a sequence of steps.

<ResponseField name="search_id" type="number | null">
  Identifier of the analytics row written for this request (`helloapp_route_search.id`). Pass this back when persisting a route to `helloapp_routehistory.route_search_id` so the search → ride funnel can be computed. May be `null` if the server failed to persist the analytics row; in that case the routes are still valid — analytics writes are best-effort.
</ResponseField>

<ResponseField name="destination_parking" type="object | null">
  The destination borough's rental e-bike parking rule. Set **only** when the request used `parking_type: "ebike"` **and** the destination fell inside a bounded area (a London borough). `null` for `"regular"` / no `parking_type`, or when the destination is in no bounded area (outside London).

  ```json theme={null}
  { "area_name": "Hackney", "ebike_parking_rule": "bay_mandatory" }
  ```

  * `"bay_mandatory"` — the borough requires ending a hire in a designated bay; the Personal option ends at the returned `parking` bay (or falls back to direct with `parking: null` if none is within 1,500 m).
  * `"free_floating"` — you may park anywhere in the borough; no bay is returned and the Personal option stays direct. Render this as "park anywhere in {area_name}" rather than a "no parking found" error.

  When the request also sent an `operator`, two more fields are present:

  ```json theme={null}
  { "area_name": "Hackney", "ebike_parking_rule": "bay_mandatory", "operator": "forest", "covered": false }
  ```

  * `operator` — the operator (`"lime"` | `"forest"`) the coverage was resolved for.
  * `covered` — whether that operator serves this borough. When `false`, the operator can't legally end a hire here, so the Personal option ends at a bay inside the operator's covered area (up to 5,000 m away) and walks the remaining distance to the destination. Render as "Outside {operator} area — park at {parking.address} and walk the rest".
</ResponseField>

<ResponseField name="routes" type="array">
  Array of route alternative objects

  <Expandable title="Route Alternative">
    <ResponseField name="name" type="string">
      Route name (e.g., "My bike", "Citi Bike", "Lime")
    </ResponseField>

    <ResponseField name="type" type="string">
      Route type:

      * `Personal` - Using your own bike
      * `GBFS` - Station-based bike-share (e.g., Citi Bike)
      * `TFL` - Transport for London bike-share
      * `Dockless` - Free-floating bike-share (e.g., Lime, Spin)
    </ResponseField>

    <ResponseField name="is_electric" type="boolean">
      Optional. `true` when the route uses e-bikes. Today this is set on the dedicated docked e-bike option (origin station filtered to those with `num_ebikes_available > 0`, cycling legs recalculated at the e-bike speed). Absent / `false` for classic bike-share, personal bike, and dockless options.
    </ResponseField>

    <ResponseField name="supersafe" type="boolean">
      Which SuperSafe regime this route was computed under (`true` = `p_supersafe=true`, the safer-bias edge weights; `false` = `p_supersafe=false`, the shorter/faster route). When the request omits `supersafe`, the Personal option ships with both variants so the client can filter locally rather than refetching when the user toggles their UI control. Other option types are single-regime per response (see the `supersafe` body field above).
    </ResponseField>

    <ResponseField name="nighttime" type="boolean">
      Whether this variant was routed on the nighttime cost network, which prefers lit streets (streets without OpenStreetMap lighting data, or tagged unlit, are penalised). The server sets this when the request lands in the dark window — from 30 minutes after sunset until 30 minutes before sunrise at the route origin — **and the city has street-lighting data** (London today; other cities are always `false`). When `supersafe` is omitted **and** it's nighttime, the **Personal** option ships three variants instead of two: `supersafe=true, nighttime=true` (the "SuperSafe™ Night" lit route), `supersafe=false, nighttime=true` ("Direct · Night"), and `supersafe=true, nighttime=false` (the standard "SuperSafe™" route). The same three-variant night-first fan-out is used for a **dusk-crossing** ride — one departing in daylight whose estimated arrival (see `departure_time` above) lands inside the dark window — so clients that default to the first Personal variant pick the lit route for rides that will finish after dark. By day the Personal option ships the usual two (both `nighttime=false`). Other option types carry `nighttime` matching the request's dark-window state.
    </ResponseField>

    <ResponseField name="label" type="string | null">
      Server-derived display label for the route card. Set for **Personal** variants only — one of `"SuperSafe™"`, `"SuperSafe™ Night"`, `"Direct"`, or `"Direct · Night"` (derived from `supersafe` × `nighttime`). `null` for bikeshare options; clients fall back to the brand name from `name`.
    </ResponseField>

    <ResponseField name="subtitle" type="string | null">
      Server-derived subtitle shown under the ETA. `"Prefers well-lit roads"` on night variants (`nighttime=true`), `null` otherwise.
    </ResponseField>

    <ResponseField name="has_live_availability" type="boolean">
      Docked options only (absent on Personal and Dockless). `false` when the option's bike-share system publishes **no live availability feed** — e.g. Lime London, whose "stations" are Hackney Council's mandatory parking bays synced from the council's open-data layer. For these options the router assumes a bike can be picked up and dropped off at any station (unlimited capacity), the pickup/dropoff `endLabel`s are always the generic `"Pick up bike"` / `"Drop off bike"` (never a numeric count), and clients must render a warning that bike availability isn't live. `true` for docked systems with real-time GBFS/TfL feeds.
    </ResponseField>

    <ResponseField name="parking" type="object | null">
      Personal options only, and only when the request set `parking_type`. The parking spot the route ends at:

      ```json theme={null}
      {
        "id": 70424,
        "feature_id": "EBP.HCK.lime_dockless_location.98",
        "lat": 51.526292,
        "lon": -0.076975,
        "borough": "Hackney",
        "address": "Calvert Avenue",
        "capacity": 0,
        "ebike_parking": true,
        "distance_meters": 140.86
      }
      ```

      `feature_id` is the provider asset id (borough e-bike bays are prefixed `EBP.<source-code>.`); `capacity` is `0` when the source publishes no capacity; `distance_meters` is the straight-line distance from the spot to the requested destination. `null` when parking was requested but no bay/rack was returned — either nothing within 1,500 m, or (for `parking_type: "ebike"`) the destination borough is `free_floating` so a bay is deliberately not returned. The option is then the plain direct cycling shape; use `destination_parking` to distinguish "park anywhere here" from "no parking found". Absent entirely when `parking_type` wasn't sent, and on all non-Personal options.
    </ResponseField>

    <ResponseField name="disruptions" type="array">
      **Temporary** road disruptions — one-time `graph_time_overrides` windows (manual curator entries plus the TfL Road Disruptions and DfT Street Manager roadworks feeds) — that are **active at the departure time or open within the next 60 minutes**, and whose pin lies within **\~150 m of this option's cycling legs**. Absent when none are nearby. Recurring **weekly** avoidances (street markets, school streets) are routing-only and never surfaced here. `/directions` returns the same field at the **top level** of its cycling responses (same shape and attach rule) — see `docs/api-reference/routing/directions.mdx`.

      One entry per **works site**: a disruption spanning several streets/ways appears once, pinned at the centroid of the affected ways' geometries.

      ```json theme={null}
      {
        "id": "street_manager:AB123456789-01",
        "source": "street_manager",
        "status": "active",
        "name": "Albert Bridge Road",
        "notes": "Road closed for street works — Thames Water (utility_repair)",
        "severity": "closed",
        "location": [-0.1663, 51.4776],
        "start_date": "2026-05-22",
        "start_minute": 1195,
        "end_date": "2026-07-29",
        "end_minute": 1260
      }
      ```

      * `id` — stable disruption id: `manual:<row id>` for curator entries, `<source>:<external_id>` for feed entries (TfL TIMS id / Street Manager permit reference). Use it as the pin key; the same disruption carries the same `id` on every option it decorates.
      * `source` — `"manual"` (curator entry, possibly created from a rider report), `"tfl"` (TfL Road Disruptions), or `"street_manager"` (DfT Street Manager permit).
      * `status` — `"active"` (the window covers the departure time; the router detoured around this road) or `"upcoming"` (the window opens within 60 minutes of departure; upcoming windows do **not** influence routing, so an upcoming pin can sit directly on the route — render it as "starts soon").
      * `name` / `notes` — affected street name and a human-readable summary; both nullable.
      * `severity` — `"closed"` (the street is effectively omitted from routing) or `"avoid"` (a lane closure, contraflow, temporary signals, or works incursion the router penalizes but may still traverse).
      * `location` — `[lon, lat]` centroid of the affected ways. For an L-shaped works site this can sit slightly off-street.
      * `start_date` / `start_minute` / `end_date` / `end_minute` — the **city-local** one-time window: dates as `YYYY-MM-DD`, minutes as wall-clock minutes of the day (`1195` = 19:55), one continuous start-inclusive / end-exclusive interval that may span multiple days and cross midnight. Same semantics as `graph_time_overrides`.
    </ResponseField>

    <ResponseField name="route" type="array">
      Array of route step objects

      <Expandable title="Route Step">
        <ResponseField name="step" type="number">
          Step number (1-indexed)
        </ResponseField>

        <ResponseField name="type" type="string">
          Step type: `walking` or `cycling`
        </ResponseField>

        <ResponseField name="route" type="object">
          Route object with geometry, distance, duration, and turn-by-turn instructions
        </ResponseField>

        <ResponseField name="reroutable" type="boolean">
          Whether this step can be rerouted (false for event routes)
        </ResponseField>

        <ResponseField name="startLabel" type="string">
          Optional display label rendered as a pill at the leg's first coordinate. Set by the router when a transition deserves a named callout (e.g. `"Start group ride"` on the event-route leg, `"Start route"` on a direct personal-cycling leg).
        </ResponseField>

        <ResponseField name="endLabel" type="string">
          Optional display label rendered as a pill at the leg's last coordinate. Set on walking↔cycling transitions:

          * On a walking leg that's followed by cycling, a pickup pill. For docked bikeshare, this shows inventory at the origin station — `"5 bikes"` for the classic option (count = `num_bikes_available`) or `"3 e-bikes"` for the e-bike option (count = `num_ebikes_available`); singular forms drop the `s` (`"1 bike"` / `"1 e-bike"`). For dockless (single bike) and personal routes (no station inventory), the label falls back to the generic `"Pick up bike"`.
          * On a cycling leg that's followed by walking, a dropoff pill. For docked bikeshare, this shows open-dock count at the destination station — `"5 docks"` (count = `num_docks_available`) or `"1 dock"` for singular. For dockless and personal routes, falls back to the generic `"Drop off bike"`.
          * Docked options whose system has `has_live_availability: false` (see that field above) always use the generic `"Pick up bike"` / `"Drop off bike"` labels — no counts exist, and the router never fabricates one.
          * Personal options with a parking destination (`parking_type` set and a spot found) label the cycling→walking transition `"Park bike"` — the rider keeps this bike, so the rental "Drop off bike" copy is never used.

          For event rides, the event-route leg ends with `"End group ride"` at the event's end coordinate when the event has a scheduled `start_time`, or `"End route"` when it has no start time (plain shared route); docked bikeshare events additionally append a cycling leg from the event end to a drop-off station, whose last coordinate is labelled with the same dock-count format. The route's final leg otherwise receives a default pill at the destination coordinate unless a more specific transition label already owns it — the dock-count pill for bikeshare routes that end on a cycling leg at a docked station, `"Drop off bike"` for dockless parkable destinations, and `"End"` for all other non-event routes. The router drops `endLabel` when the next leg's `startLabel` would stack at the same coordinate.
        </ResponseField>
      </Expandable>
    </ResponseField>

    <ResponseField name="event" type="object">
      Event information (if event\_id was provided)
    </ResponseField>

    <ResponseField name="destination" type="object">
      Destination information (if destination\_id or name was provided)
    </ResponseField>
  </Expandable>
</ResponseField>

## Request Example

### Basic Multi-modal Route

```bash cURL theme={null}
curl -X POST "https://api.cyclemate.com/multi-modal/" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "origin": [-73.9851, 40.7589],
    "destination": [-73.9712, 40.7614]
  }'
```

```javascript JavaScript theme={null}
const response = await fetch('https://api.cyclemate.com/multi-modal/', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    origin: [-73.9851, 40.7589],
    destination: [-73.9712, 40.7614],
  }),
});

const { search_id, routes } = await response.json();
console.log(`Found ${routes.length} route alternatives (search_id=${search_id})`);
```

### With Super-Safe Routing and Event

```javascript JavaScript theme={null}
const response = await fetch('https://api.cyclemate.com/multi-modal/', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    origin: [-73.9851, 40.7589],
    destination: [-73.9712, 40.7614],
    supersafe: true,
    event_id: 123,
    destination_id: 45,
  }),
});

const routes = await response.json();
```

```python Python theme={null}
import requests

url = "https://api.cyclemate.com/multi-modal/"
headers = {
    "Authorization": "Bearer YOUR_API_KEY"
}
data = {
    "origin": [-73.9851, 40.7589],
    "destination": [-73.9712, 40.7614],
    "supersafe": True,
    "event_id": 123
}

response = requests.post(url, headers=headers, json=data)
routes = response.json()
```

## Response Example

```json theme={null}
{
  "search_id": 1234,
  "routes": [
  {
    "name": "My bike",
    "type": "Personal",
    "supersafe": true,
    "route": [
      {
        "step": 1,
        "type": "cycling",
        "route": {
          "routes": [{
            "distance": 1640.2,
            "duration": 393.4,
            "geometry": {
              "type": "LineString",
              "coordinates": [[-73.9851, 40.7589], [-73.9712, 40.7614]]
            },
            "legs": [...]
          }],
          "waypoints": [...],
          "code": "Ok"
        }
      }
    ],
    "destination": {
      "name": "Central Park",
      "latitude": 40.7614,
      "longitude": -73.9712
    }
  },
  {
    "name": "My bike",
    "type": "Personal",
    "supersafe": false,
    "route": [
      {
        "step": 1,
        "type": "cycling",
        "route": {
          "routes": [{
            "distance": 1523.4,
            "duration": 365.2,
            "geometry": { "type": "LineString", "coordinates": [...] },
            "legs": [...]
          }],
          "waypoints": [...],
          "code": "Ok"
        }
      }
    ],
    "destination": {
      "name": "Central Park",
      "latitude": 40.7614,
      "longitude": -73.9712
    }
  },
  {
    "name": "Citi Bike",
    "type": "GBFS",
    "supersafe": true,
    "route": [
      {
        "step": 1,
        "type": "walking",
        "route": {
          "routes": [{
            "distance": 245.8,
            "duration": 180.0,
            "geometry": {...}
          }]
        }
      },
      {
        "step": 2,
        "type": "cycling",
        "route": {
          "routes": [{
            "distance": 1200.5,
            "duration": 288.0,
            "geometry": {...}
          }]
        }
      },
      {
        "step": 3,
        "type": "walking",
        "route": {
          "routes": [{
            "distance": 150.2,
            "duration": 110.0,
            "geometry": {...}
          }]
        }
      }
    ],
    "destination": {
      "name": "Central Park",
      "latitude": 40.7614,
      "longitude": -73.9712
    }
  },
  {
    "name": "Lime",
    "type": "Dockless",
    "route": [
      {
        "step": 1,
        "type": "walking",
        "route": {...}
      },
      {
        "step": 2,
        "type": "cycling",
        "route": {...}
      }
    ],
    "destination": {
      "name": "Central Park",
      "latitude": 40.7614,
      "longitude": -73.9712
    }
  }
  ]
}
```

## Route Types Explained

### Personal Bike Route

* Direct cycling route from origin to destination
* No walking segments unless event route is involved
* Always appears first in results

### Personal Bike Route with Parking (`parking_type` set)

* **Step 1**: Cycle from origin to the nearest qualifying parking spot within 1.5km of the destination (`"Park bike"` pill at the transition)
* **Step 2**: Walk from the parking spot to the destination
* The chosen spot is returned in the option's `parking` field; `parking: null` means nothing was found and the option fell back to the direct shape
* `parking_type: "ebike"` → designated rental e-bike bays only; `"regular"` → racks/stands only
* Ignored for event routes (except solo shares); all other option types are unchanged

### Docked Bike-Share Route (GBFS/TFL)

* **Step 1**: Walk from origin to nearest station with bikes available
* **Step 2**: Cycle from origin station to destination station
* **Step 3**: Walk from destination station to final destination
* Only included if suitable stations are found within 1.5km

### Docked E-Bike Route

* Same three-step shape as classic docked bike-share.
* Origin station is the nearest one with `num_ebikes_available > 0`. Destination station logic is unchanged (any open dock).
* Cycling-leg duration is recalculated at the e-bike speed (6.11 m/s / \~22 km/h) instead of the regular cycling speed.
* Marked with `is_electric: true`. Name is `${system_name} E-bike` (e.g. `"Citi Bike NYC E-bike"`).
* Included only when at least one nearby station within 1.5km has an e-bike available; otherwise omitted entirely.

### Dockless Bike-Share Route

* **Step 1**: Walk from origin to nearest available bike
* **Step 2**: Cycle from bike location to destination
* One route per provider (Lime, Spin, etc.)
* Only included if bikes are found within 1.5km

## Event Integration

When `event_id` is provided and the event has a defined route:

1. All cycling segments route to the event's starting point
2. An additional non-reroutable cycling step is added for the event route
3. Event details are included in each route alternative
4. Final walking segment (for docked bike-share) goes to nearest station to event endpoint

### Solo shares

Events flagged `is_solo_share = true` are live broadcasts rather than coordinated rides. For these, the multi-modal response returns the **same shape as a non-event request** — Personal is one cycling leg from origin to destination, docked is the standard walk → cycle → walk, dockless is walk → cycle. The host's polyline is **not** added as a step, and no leg is marked non-reroutable. The `event` field is still populated on each option so clients can label the route as "joining {host}'s ride." The host's actual route stays visible on the event page itself, where clients render it from `event.route_polyline` directly.

## Step-level semantics

Each leg's `route.routes[0].legs[0].steps` follows the same turn-by-turn shape as the [directions](./directions) endpoint. In particular:

* `step.distance` is the length of the road **after** the maneuver — to render *"in X metres, {instruction}"*, use the **previous** step's `distance`.
* `step.maneuver.location` equals `step.geometry.coordinates[0]`.
* `step.maneuver.bearing_before` is the heading at the end of the previous step's geometry (approaching the maneuver); `bearing_after` is the heading at the start of this step's geometry. On a curved approach these are not equal — do not assume continuity between consecutive steps.
* The final step of the final leg's instruction is `"Arrive at destination"`.
* Each step carries the same `voiceInstructions[]` / `bannerInstructions[]` (incl. the stacked `sub`) as the directions endpoint. These are generated **per leg independently** — the "then" lookahead never crosses a leg boundary, because every leg ends in its own arrive step.

See the directions doc for full field descriptions; the shape is identical.

## Notes

* Routes are automatically filtered to remove alternatives >3x slower than the personal bike route
* Bike availability is fetched in real-time from GBFS feeds and provider APIs — except for systems with `has_live_availability: false` (e.g. Lime London's Hackney bays), which have no availability feed at all: those options assume unlimited capacity and clients must warn that availability isn't live
* Distance calculations use the Haversine formula
* Walking speed assumed: 1.4 m/s (5 km/h)
* Cycling speed assumed: 4.17 m/s (15 km/h)
* E-bike cycling speed assumed: 6.11 m/s (22 km/h) — applied only to options with `is_electric: true`
* Maximum bike search radius: 1500 meters
* Results are ordered: Personal first (twice when the request omits `supersafe`: `supersafe=true` then `supersafe=false` — collapsing to once when the two variants are geometrically identical; once otherwise), followed by Docked bike-share (classic), Docked bike-share (e-bike, when available), and Dockless bike-share. Docked/E-bike/Dockless are always single-regime per response — at the regime the caller pinned, or `true` if they didn't. Clients filter on the `supersafe` field to render one Personal variant at a time.

## Error Responses

<ResponseField name="400" type="object">
  Invalid coordinates

  ```json theme={null}
  {
    "error": "origin and destination must be [lon, lat] arrays"
  }
  ```
</ResponseField>

<ResponseField name="404" type="object">
  No viable routes found

  ```json theme={null}
  {
    "error": "No viable bike-share route found"
  }
  ```
</ResponseField>

<ResponseField name="429" type="object">
  Rate limit exceeded. Includes a `Retry-After` response header with the number of seconds until the current window closes.

  ```json theme={null}
  {
    "error": "Rate limit exceeded",
    "retry_after_seconds": 42
  }
  ```
</ResponseField>

<ResponseField name="500" type="object">
  Server error

  ```json theme={null}
  {
    "error": "Error message",
    "details": "Additional details"
  }
  ```
</ResponseField>

## Rate Limits

Requests are rate-limited. Authenticated requests (valid user session) are allowed a higher rate than anonymous requests. Multi-modal is subject to a stricter rate limit than [directions](./directions) because each request runs multiple internal routing queries.

When the limit is exceeded, the response is `429 Too Many Requests` with a `Retry-After` header indicating the number of seconds to wait before retrying, and a matching `retry_after_seconds` field in the body. Clients should honor `Retry-After`.

## Performance

* Typical response time: 800-1500ms
* Bike availability queries: \~100-200ms
* Routing segments computed in parallel
* Results cached when possible
