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

# Safe Bike Directions API

> Get turn-by-turn directions with optional super-safe bike routing

## Overview

Get directions between waypoints with support for cycling, walking, and driving profiles. Optionally use the `supersafe` flag for bike routing on a dedicated cycling network with safety prioritization.

## 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="coordinates" type="array" required>
  Array of \[longitude, latitude] waypoints. Minimum 2, maximum 25.

  Example: `[[lon1, lat1], [lon2, lat2]]`
</ParamField>

<ParamField body="profile" type="string" default="cycling">
  Routing profile. Options:

  * `cycling` - Optimized for bicycles
  * `walking` - Optimized for pedestrians
  * `driving` - Optimized for cars
  * `driving-traffic` - Driving with traffic data
</ParamField>

<ParamField body="supersafe" type="boolean" default="false">
  Enable super-safe bike routing using Cyclemate's network router. Only applies to `cycling` profile.

  When enabled, routes prioritize:

  * Protected bike lanes
  * Low-traffic streets
  * Bike paths and greenways
  * Reduced elevation gain
</ParamField>

<ParamField body="bearing" type="number">
  Optional constraint on the direction of travel as the route leaves the start point, in degrees `[0, 360)` (0 = north, 90 = east). Primarily useful for mid-ride reroutes: pass the user's current heading so the new route doesn't begin with a near-U-turn.

  All road segments within \~80m of the start point whose direction of travel is outside the server-configured tolerance (default ±45°) of `bearing` are treated as non-traversable in that direction only. Bidirectional roads with a legal direction remain traversable on their matching side. One-way roads are naturally dropped if their only legal direction is out of range.

  If the constraint leaves no traversable path out of the start bubble, the request returns `NoRoute` — in that case the client should retry without `bearing`. Fractional degrees are accepted and rounded to the nearest integer server-side. Cycling profile only.

  Example: `90` (traveling east).
</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) **and the city has street-lighting data** (London today), cycling routes use the lit-street-preferring nighttime costs (see `metadata.nighttime`). Omit to time the route to **now**. An unparseable value returns `400`. Affects cycling only; walking ignores it, and cities without lighting data never go nighttime.

  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 school street at drop-off) or a **one-time date range** (e.g. a festival closure from 15 Jan 2027 09:00 to 16:00 — one-time windows may span multiple days and cross midnight). When the departure falls inside such a window, cycling routes — both SuperSafe and Direct — route around the road; outside the window (or once a one-time window has passed) the road routes normally. Walking is unaffected, and these windows never change the map's safety coloring (routing-time only).

  Example: `"2026-06-03T21:00"` (9 PM local at the route).
</ParamField>

<ParamField body="options" type="object">
  Additional routing options (applied when not using supersafe)

  <Expandable title="Options Object">
    <ParamField body="geometries" type="string" default="geojson">
      Format of returned geometry: `geojson` or `polyline`
    </ParamField>

    <ParamField body="overview" type="string" default="full">
      Level of detail: `full`, `simplified`, or `false`
    </ParamField>

    <ParamField body="steps" type="boolean" default="true">
      Include turn-by-turn instructions
    </ParamField>

    <ParamField body="alternatives" type="boolean" default="false">
      Return alternative routes
    </ParamField>
  </Expandable>
</ParamField>

## Response

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

  <Expandable title="Route Object">
    <ResponseField name="distance" type="number">
      Total route distance in meters
    </ResponseField>

    <ResponseField name="duration" type="number">
      Estimated travel time in seconds
    </ResponseField>

    <ResponseField name="geometry" type="object">
      Route geometry as GeoJSON LineString
    </ResponseField>

    <ResponseField name="legs" type="array">
      Array of leg objects (one per waypoint pair)

      <Expandable title="Leg Object">
        <ResponseField name="distance" type="number">
          Leg distance in meters
        </ResponseField>

        <ResponseField name="duration" type="number">
          Leg duration in seconds
        </ResponseField>

        <ResponseField name="steps" type="array">
          Turn-by-turn instructions

          <Expandable title="Step Object">
            <ResponseField name="distance" type="number">
              Length of the road travelled **after** this step's maneuver, in metres — i.e. the length of `geometry.coordinates`, the road you end up on once the turn is complete (not the road leading into it).

              **Consequence for UIs:** to show *"in X metres, {maneuver.instruction}"*, use the **previous** step's `distance` (the road you're on until this turn), not this step's own. Using the step's own `distance` shows the length of the road you end up on after the turn, which is almost always wrong.
            </ResponseField>

            <ResponseField name="duration" type="number">
              Step duration in seconds, derived from `distance` at the profile's target speed (cycling 15 km/h, walking 5 km/h).
            </ResponseField>

            <ResponseField name="name" type="string">
              Street name after the maneuver (i.e. the name of the road whose length is `distance`).
            </ResponseField>

            <ResponseField name="maneuver" type="object">
              Turn instruction.

              * `type`: `"Start"`, `"Turn"`, `"Continue"`, `"roundabout"`, `"U-turn"`, or end-sentinel for the last step.
              * `modifier`: `"left"`, `"right"`, `"straight"`, `"sharp left"`, etc. On a `"roundabout"` step it's the **exit direction** (approach heading vs exit heading), so the arrow points roughly the way the rider leaves.
              * `instruction`: pre-rendered string, e.g. `"Turn left on East 1st Street"`. A roundabout reads `"At the roundabout, take the third exit"` (the road taken is named by the **following** step). The final step is always `"Arrive at destination"`.
              * `location`: `[lon, lat]` where the maneuver happens — **equal to `geometry.coordinates[0]`**.
              * `bearing_before`: inbound heading approaching the maneuver, in degrees — the heading at the **end** of the previous step's geometry. On a curved approach this differs from the previous step's `bearing_after`; do not assume they are equal.
              * `bearing_after`: outbound heading leaving the maneuver, in degrees — the heading at the **start** of this step's geometry.
            </ResponseField>

            <ResponseField name="exits" type="string">
              For a `"roundabout"` maneuver, the exit to take as a 1-based number string (e.g. `"3"` for the third exit) — equal to the number of roundabout edges the route traverses, since the circular way is split at each connecting road. Empty string (`""`) on every non-roundabout step.
            </ResponseField>

            <ResponseField name="geometry" type="object">
              Step geometry as GeoJSON `LineString`. `coordinates[0]` is the maneuver location; the total geodesic length equals `distance` (within \~2%). A roundabout step's geometry is the whole arc travelled around the circle.
            </ResponseField>

            <ResponseField name="lit" type="boolean">
              Whether this step's road is lit, from OpenStreetMap lighting (`ways.lit` — TRUE only when the way has explicit `lit=yes`-style tagging). Like `cycle_routes`, a multi-edge step reflects its first edge. Present on cycling steps; clients use it to highlight the lit portions of a route and compute a "% Lit" figure. Coverage is approximate (London only today).
            </ResponseField>

            <ResponseField name="voiceInstructions" type="array">
              Mapbox-style spoken cues for this step's maneuver, ordered far → near. Each entry:

              * `distanceAlongGeometry`: distance **remaining to the maneuver**, in metres, at which to speak the cue. Values **decrease** through the array (the largest fires first/earliest); each is ≤ the approach length (the **previous** step's `distance`). The smallest is an imminent "act now" cue at \~40 m (\~7 s of lead at cycling speed). The largest is an entry heads-up placed \~25 m **into** the approach (`approach − 25`), so the next turn is only announced once the rider has cleared the junction they just turned through. Long steps get several cues (e.g. a 1-mile approach → \~1575 m / 1000 m / 400 m / 120 m / 40 m); short steps get one.
              * `announcement`: the spoken text — the maneuver instruction, **unit-agnostic** (no distance baked in). The client prepends a localized `"In <distance>, …"` prefix for the non-imminent cues per the rider's imperial/metric setting. A merged compound step's `announcement` already reads `"Turn left, then turn right on B"`, so stacked audio needs no extra assembly.

              The arrive step carries **no** voice cues (the client owns the final "You have arrived").
            </ResponseField>

            <ResponseField name="bannerInstructions" type="array">
              Mapbox-style visual banner for this step (one entry today).

              * `distanceAlongGeometry`: the approach length over which the banner applies.
              * `primary`: the maneuver to display — `{ text, components: [{ text, type }], type, modifier }`. `components` splits the text into `"text"` and `"road-name"` fragments. For a merged compound step, `primary` is the **first** turn (so the big arrow is the first turn, not the combined text).
              * `sub` *(optional)*: the **stacked** "then" preview — the maneuver that follows quickly after this one (same shape as `primary`, with `modifier`). Present only on merged compound ("X, then Y") steps; drives the Google-Maps-style "Then ↰ \<road>" sub-row. Absent on ordinary steps.
            </ResponseField>
          </Expandable>
        </ResponseField>
      </Expandable>
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="waypoints" type="array">
  Snapped waypoint locations
</ResponseField>

<ResponseField name="code" type="string">
  Response code: `Ok` on success
</ResponseField>

<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 route is still valid — analytics writes are best-effort.
</ResponseField>

<ResponseField name="edge_ids" type="number[] | null">
  Ordered list of the internal graph edge ids (`ways.id`) this route traverses, in travel order. Cycling responses populate it; walking responses leave it `null`. The event editor concatenates `edge_ids` across a multi-stop route's segments and persists them on the event (`helloapp_event.edges_router_ids`) so event-edge generation rebuilds the route's edges with a cheap id lookup instead of re-snapping the polyline. Opaque to API consumers — only meaningful together with `graph_version`.
</ResponseField>

<ResponseField name="graph_version" type="string | null">
  The graph version (`ways.version`) that `edge_ids` belong to. Stamped alongside the ids so they can be detected as stale after a graph rebuild reassigns edge ids. `null` on walking responses.
</ResponseField>

<ResponseField name="subtitle" type="string | null">
  `"Prefers well-lit roads"` when the route was computed on the nighttime cost network (a cycling request that landed in the dark window — see `metadata.nighttime`), `null` otherwise. Walking routes never set this.
</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 route's cycling geometry**. **Absent** (not empty) when none are nearby, and never present on walking responses. One entry per **works site**, pinned at the centroid of the affected ways.

  Entries have the exact same shape and semantics as the per-option `disruptions` field on `/multi-modal/` — see [Multi-Modal Routing](/api-reference/routing/multimodal) for the field-by-field reference (`id`, `source`, `status`, `severity`, `location`, and the city-local window fields). `status: "upcoming"` windows do **not** influence routing, so an upcoming pin can sit directly on the route.
</ResponseField>

<ResponseField name="metadata" type="object">
  Routing metadata

  <ResponseField name="routing_engine" type="string">
    Identifier of the routing engine that produced the route. Always `network_router`.
  </ResponseField>

  <ResponseField name="profile" type="string">
    Routing profile used
  </ResponseField>

  <ResponseField name="waypoint_count" type="number">
    Number of waypoints
  </ResponseField>

  <ResponseField name="nighttime" type="boolean">
    Whether the route was computed on the nighttime cost network, which prefers lit streets (streets with no OpenStreetMap lighting data, or tagged unlit, are penalised). Set for **cycling** requests **in a city that has street-lighting data** (London today) that land in the dark window — from 30 minutes after sunset until 30 minutes before sunrise at the route origin (the city's local night, derived from the origin coordinates). Always `false` for walking, and always `false` in cities without lighting data (their nighttime costs equal the base cost, so there's no lit-road preference to surface).
  </ResponseField>
</ResponseField>

## Request Example

### Standard Cycling Route

```bash cURL theme={null}
curl -X POST "https://api.cyclemate.com/directions/" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "coordinates": [[-73.9851, 40.7589], [-73.9712, 40.7614]],
    "profile": "cycling",
    "options": {
      "steps": true,
      "overview": "full"
    }
  }'
```

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

const directions = await response.json();
console.log(`Route distance: ${directions.routes[0].distance}m`);
```

### Reroute With Current Heading

Use `bearings` when recomputing a route mid-ride so the new route leaves the user's location in their current direction of travel rather than asking them to U-turn:

```javascript JavaScript theme={null}
const response = await fetch('https://api.cyclemate.com/directions/', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    coordinates: [currentLocation, destination],
    profile: 'cycling',
    supersafe: true,
    bearing: currentHeadingDegrees,
  }),
});
```

### Super-Safe Bike Route

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

const directions = await response.json();
console.log(`Super-safe route: ${directions.routes[0].distance}m`);
```

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

url = "https://api.cyclemate.com/directions/"
headers = {
    "Authorization": "Bearer YOUR_API_KEY"
}
data = {
    "coordinates": [[-73.9851, 40.7589], [-73.9712, 40.7614]],
    "profile": "cycling",
    "supersafe": True
}

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

## Response Example

```json theme={null}
{
  "routes": [
    {
      "distance": 1523.4,
      "duration": 365.2,
      "geometry": {
        "type": "LineString",
        "coordinates": [
          [-73.9851, 40.7589],
          [-73.9845, 40.7595],
          [-73.9712, 40.7614]
        ]
      },
      "legs": [
        {
          "distance": 1523.4,
          "duration": 365.2,
          "steps": [
            {
              "distance": 150.2,
              "duration": 36.0,
              "name": "West 52nd Street",
              "maneuver": {
                "type": "depart",
                "instruction": "Head east on West 52nd Street",
                "bearing_before": 0,
                "bearing_after": 90,
                "location": [-73.9851, 40.7589]
              },
              "geometry": {
                "type": "LineString",
                "coordinates": [
                  [-73.9851, 40.7589],
                  [-73.9845, 40.7589]
                ]
              }
            }
          ]
        }
      ]
    }
  ],
  "waypoints": [
    {
      "location": [-73.9851, 40.7589],
      "name": "West 52nd Street"
    },
    {
      "location": [-73.9712, 40.7614],
      "name": "Central Park West"
    }
  ],
  "code": "Ok",
  "search_id": 1234,
  "metadata": {
    "routing_engine": "network_router",
    "profile": "cycling",
    "waypoint_count": 2
  }
}
```

## Super-Safe Routing

When `supersafe: true` is enabled for cycling routes, the API uses Cyclemate's proprietary network router that:

1. **Prioritizes Safety**: Routes prefer protected bike lanes, bike paths, and low-traffic streets
2. **Local Knowledge**: Uses city-specific bike infrastructure data (NYC, LA, SF, Chicago, London)
3. **Elevation Aware**: Minimizes elevation gain when possible
4. **Real-time Updates**: Incorporates user feedback and recent infrastructure changes

## Error Responses

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

  ```json theme={null}
  {
    "error": "At least 2 coordinates are required"
  }
  ```
</ResponseField>

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

  ```json theme={null}
  {
    "error": "Invalid profile. Must be one of: cycling, walking, driving, driving-traffic"
  }
  ```
</ResponseField>

<ResponseField name="400" type="object">
  Cross-city or out-of-coverage routing. Includes `search_id` so the failed search can still be joined back to a future ride from a different attempt (the analytics row is written with `status='cross_route_not_supported'`).

  ```json theme={null}
  {
    "error": "Cross-city routing is not supported",
    "code": "CROSS_CITY_ROUTE",
    "search_id": 1235
  }
  ```
</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="503" type="object">
  Service unavailable

  ```json theme={null}
  {
    "error": "Routing service temporarily unavailable",
    "details": "Request timed out"
  }
  ```
</ResponseField>

## Rate Limits

Requests are rate-limited. Authenticated requests (valid user session) are allowed a higher rate than anonymous requests.

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

## Notes

* Coordinates must be in `[longitude, latitude]` format
* Maximum 25 waypoints per request
* Super-safe routing is only available in supported cities (NYC, LA, SF Bay Area, Chicago, London)
* Duration estimates assume average cycling speed of 15 km/h (9.3 mph)
* Turn-by-turn instructions are in English
