Journey planning

Plan a door-to-door journey between two coordinates. Results include full leg geometry, per-leg fare estimates, and live vehicle positions for buses already on the road.

Coverage: journey planning covers England, Scotland and Wales, by bus, coach, rail, Underground, metro, tram and ferry, and the whole island of Ireland, north and south, by bus, coach, Luas and rail (cross-border journeys like Belfast to Dublin included), and the Netherlands and Belgium by train, bus, tram, metro and ferry, planned together (Amsterdam to Brussels works). Great Britain, Ireland and the Netherlands with Belgium are planned as separate areas, so a journey from one to another returns 422 outside_coverage, as does an origin or destination in the Isle of Man. That's rather than an empty result, so you can tell "we can't plan here" apart from "no journeys found". In Ireland, train legs get live expected times from Irish Rail, and bus legs in the Republic get them from the NTA, with status: "cancelled" for a cancelled one. Northern Ireland's legs are scheduled. Luas legs get live times from TII's forecasts and a fare in euros (below); there are no other Irish fares. Dutch legs have live expected times and cancellations from OVapi, and no fares. Belgian legs are the same, with live times for SNCB, De Lijn and TEC (Brussels' STIB legs are scheduled). Times are in local time where the journey starts, named in the response's timezone, and a datetime without an offset is read as local time there: 09:00 from Amsterdam means 09:00 in Amsterdam.

  • Dates: journeys can be planned from a few days ago up to two months ahead.
  • Fares: rail fares cover the whole of Great Britain. Bus fares are estimated where fare data exists, currently operators in Yorkshire and West Sussex. Elsewhere fare is null or marked incomplete rather than guessed.
  • Updates: journey planning picks up each day's timetables every morning with no downtime. If the planner is briefly unavailable, /v1/journey.json returns 503 planner_unavailable with a Retry-After header; every other endpoint keeps working. A date outside the timetables is not an outage: it returns 400 date_out_of_range, which retrying won't change.
  • Leg paths: bus, tram and metro legs follow the route the operator publishes, and say so with leg_geometry_source: "shape". Where no route geometry is published the leg is drawn from stop to stop and says "planner".
GET /v1/journey.json?from_lat={lat}&from_lon={lon}&to_lat={lat}&to_lon={lon}

Parameters

NameDefaultDescription
from_lat, from_lonrequiredOrigin coordinate.
to_lat, to_lonrequiredDestination coordinate.
datetimenowISO 8601 departure time, or arrival time when arrive_by=true. Timetables run from yesterday to about two months ahead; a date outside that returns 400 date_out_of_range giving the first and last dates that can be planned.
arrive_byfalseTreat datetime as the latest acceptable arrival.
transit_modestram,railOptional transit sub-modes to include. Bus and coach are always searched. Pass an empty value (transit_modes=) for bus and coach only.
modeswalk,bicycleNon-transit alternatives returned alongside the transit result. Pass an empty value to skip them.
include_farestrueAttach fare estimates.
railcardPrice rail legs with a railcard: a code from /v1/rail/railcards.json, e.g. YNG.
include_livetrueAttach live vehicle positions.
include_intermediate_stopsfalseList every stop called at on each transit leg.
max_walk_distance—Metres. Caps the longest single walking leg.
max_total_walk_distance—Metres. Caps the total walked across the journey.
include_stepsfalseTurn-by-turn directions on each walking leg.
limit5Maximum itineraries, 1–20.

Fares

Each transit leg carries a fare (GBP, or null if we can't price it), and each itinerary a total:

"fare": { "amount_gbp": 3.45, "complete": true }

complete is false when at least one transit leg went unpriced — the total is then a lower bound, not a full estimate. The whole object is null when no leg could be priced at all, so an unpriceable journey never masquerades as a cheap one.

These are estimates, not tickets. Bus fares come from published NeTEx Adult Single data and don't account for multi-operator tickets, daily or weekly caps, or any concession. Bus operators without imported fare data always return null.

Luas legs carry fare_local, in euros, because it isn't added to the pounds in fare: the adult TFI Leap Card 90 minute fare, with the paper single (by Luas zones) and the Leap short fare as alternatives. A second Luas leg within 90 minutes of the first is 0 with included_in_earlier_leg: true. Prices are Luas's fares valid from 28 April 2025.

"fare_local": { "currency": "EUR", "amount": 2.0,
  "ticket": "TFI Leap Card, TFI 90 Minute Fare, adult",
  "alternatives": [ { "ticket": "Paper single, adult (3 zones)", "amount": 2.6 },
                    { "ticket": "TFI Leap Card Short Fare, adult (selected journeys up to about 3 km)", "amount": 1.5 } ],
  "fares_from": "2025-04-28" }

Rail fares are the cheapest standard class single valid on any train, from the Rail Delivery Group's fares data. That means an Anytime-type fare valid on any permitted route, or on a route the journey keeps to (a "not via Doncaster" fare counts when the train doesn't call there). Off-Peak and Advance fares can be cheaper, but depend on the time or the train, so they're left to /v1/rail/fares.json. Consecutive train legs are one ticket: the fare from the first station to the last goes on the first of them, and the others have "fare": null with "fare_included": true.

Live vehicles

A transit leg whose vehicle is already running gains a live block, sourced from the BODS SIRI-VM feed:

"live": {
  "vehicle_id": "SYRK:SYRK-19514",
  "fleet_id": "SYRK-19514",
  "delay_seconds": 0,
  "latitude": 53.3461,
  "longitude": -1.3479,
  "bearing": 36.0,
  "recorded_at": "2026-08-05T16:22:00+01:00",
  "stale": false
}

Only imminent legs match anything. A vehicle appears on its trip shortly before it departs and is gone once the trip finishes, so a journey later today simply has no live data to attach — there is nothing to report about a bus that hasn't set off. stale is true when the last fix is over two minutes old.

start_time and end_time on every leg carry the timetabled time, plus an estimated time and delay_s when the leg is live, and real_time is then true:

  • Bus, tram and coach: from the tracked vehicle's delay.
  • Trains within National Rail's two-hour live window: from Darwin. The leg also gains status (on_time, late, early, delayed, cancelled or partly_cancelled), platform at the boarding station, and delay_reason or cancel_reason.
"start_time": {
  "scheduled": "2026-10-07T17:22:00+01:00",
  "estimated": "2026-10-07T17:31:00+01:00",
  "delay_s": 540
},
"status": "late", "platform": "5",
"delay_reason": "This service has been delayed by a fault with the signalling system"

A train leg's trip_id is the same id the station boards and /v1/rail/services/{trip_id}.json use, so you can follow the train from the journey.

Walking limits

Two independent caps, both in metres. max_walk_distance limits the longest single walking leg — the useful one for a mobility limit, since a journey made of three separate 400m walks is very different from one with a single 1.2km walk. max_total_walk_distance limits the sum instead. Use either or both.

Every itinerary reports what it actually asks of you, so you can rank or reject without adding the legs up yourself:

"walk_distance_m": 536.0,
"longest_walk_m": 307.0

How the limit is enforced. OpenTripPlanner has no maximum walk distance of its own — its walk preferences only shape cost. So a limit does two things here: it raises walking's cost so the search prefers nearer stops, and it then discards anything that still exceeds the cap. The result is a real guarantee rather than a preference, at the cost of sometimes returning fewer itineraries than you asked for.

When a limit is in play the response says so, including how many options it removed:

"walk_limits": {
  "max_walk_distance": 500,
  "max_total_walk_distance": null,
  "excluded_itineraries": 9
}

An empty itineraries list alongside a non-zero excluded_itineraries means journeys exist but all of them walk too far — which is a different problem from nothing running, and worth telling your users apart.

Neither limit applies to the direct walking alternative under alternatives.walk. Filtering a walking route by how far it walks would leave someone who asked for one with nothing.

Climb and effort

Every walking leg carries a terrain block: how far it climbs and descends, its steepest stretch each way, a plain-English shape, and an estimate of the energy it takes.

"terrain": {
  "climb_m": 156,
  "descent_m": 10,
  "steepest_up_pct": 43.6,
  "steepest_down_pct": -5.4,
  "shape": "steep climb",
  "direction": "up",
  "calories_kcal": 168
}

Each itinerary sums its walks into walking, so two routes that walk the same distance can be told apart when one of them is up a hill:

"walking": {
  "climb_m": 180,
  "descent_m": 17,
  "steepest_up_pct": 43.6,
  "shape": "steep climb",
  "direction": "up",
  "calories_kcal": 208,
  "complete": true
}

shape is one of flat, gentle climb, steady climb, steep climb, gentle descent, steady descent, long descent, rolling or hilly, and direction is up, down, mixed or flat.

How it is measured. Heights come from Ordnance Survey's OS Terrain 50, a bare-earth model of Great Britain on a 50m grid. Each walk is sampled every 25m along its actual path and smoothed over 75m, and a rise counts as climb only once it clears 1.5m, so the model's small errors don't turn a riverside path into a hill. Calories use Minetti's measured cost of walking on a gradient, for a 70kg adult, including resting energy. That model knows a gentle downhill is easier than the flat and a steep one is not.

These are estimates for comparing routes, not measurements. A 50m grid softens sharp changes, so a short steep ramp or a flight of steps may read gentler than it is, and summits read a little low. Where any part of a walk has no height data (ferries, Northern Ireland) or the leg has no path, the leg has no terrain rather than a partial figure. complete is false when some of an itinerary's walks couldn't be measured, so its totals cover only the walks that could. Cycling and transit legs carry no terrain.

Reachable areas and travel times

Two endpoints answer "where can I get to?" rather than "how do I get there?". Both cover bus, coach, tram, metro, Underground, ferry and National Rail anywhere in Great Britain, for departures from now until the end of tomorrow.

Reachable area: /v1/journey/reachable.json

The area you can reach from a point within each cutoff, as GeoJSON ready to draw on a map, one feature per cutoff, largest first:

curl "https://api.transcapi.com/v1/journey/reachable.json?lat=53.3781&lon=-1.4623&cutoffs=15,30,45&datetime=2026-10-01T08:00:00" \
  -H "X-Api-Key: YOUR_API_KEY"
{
  "type": "FeatureCollection",
  "departure_time": "2026-10-01T08:00:00+01:00",
  "features": [
    { "type": "Feature",
      "properties": { "minutes": 45, "stops_reached": 3669, "area_km2": 198.6 },
      "geometry": { "type": "MultiPolygon", "coordinates": [ ... ] } },
    ...
  ]
}

Up to four cutoffs, each 5 to 90 minutes. max_walk (100–2000, default 800) caps the walk to and from a stop. Expect islands: a railway reaches a town 30 km away sooner than a bus reaches the suburb between.

Travel times to many places: /v1/journey/travel_times.json

Minutes by public transport from one point to up to 500 destinations in one request, for "homes within 40 minutes of the office" or sorting a list of venues by how easy they are to reach. POST a JSON body:

curl -X POST "https://api.transcapi.com/v1/journey/travel_times.json" \
  -H "X-Api-Key: YOUR_API_KEY" -H "Content-Type: application/json" \
  -d '{"from": {"lat": 53.3781, "lon": -1.4623},
       "to": [{"id": "meadowhall", "lat": 53.4172, "lon": -1.4125},
              {"id": "leeds", "lat": 53.7950, "lon": -1.5474}],
       "datetime": "2026-10-01T08:00:00", "max_minutes": 90}'
"results": [
  { "id": "meadowhall", "latitude": 53.4172, "longitude": -1.4125, "minutes": 9.6 },
  { "id": "leeds", "latitude": 53.795, "longitude": -1.5474, "minutes": 66.3 }
]

minutes is null where a destination can't be reached within max_minutes (at most 90). For up to 100 destinations there's a GET form too: ?from_lat=&from_lon=&to=lat,lon|lat,lon.

How they're worked out

Both scan every scheduled departure in order from the start time: the standard connection-scan method, over the day's full timetable. Times are from the timetable, not live running. Walking, to the first stop, between stops when changing (up to 400 m) and from the last stop, is estimated from straight-line distance with a 30% allowance for streets, at 1.3 m/s, the same walking speed journey planning uses. So an area's edges are good estimates rather than street-exact, and a travel time can differ by a few minutes from the journey planner's, which walks real streets. For the exact route, plan the journey.

Chance of making a connection

Every itinerary with a change carries connections, one per change, and connection_chance_pct, the chance of making all of them:

"connection_chance_pct": 25,
"connections": [
  { "at": "West Street/Carver Street",
    "from_line": "120", "from_mode": "BUS", "to_line": "52", "to_mode": "BUS",
    "spare_s": 120,
    "chance_pct": 25, "basis": "trip_history", "days": 20,
    "live_margin_s": null }
]

spare_s is the timetabled time between arriving and the next departure, less any walk between them. chance_pct says how often that change has actually worked:

  • trip_history: TransCAPI records when every tracked bus passes each timing point, and a trip is the same journey every day it runs. So this is the share of the last 28 days, out of days on which that exact bus was tracked, that you'd have made the change, taking its delay at the change stop (or the last timing point before) against the next vehicle's delay leaving. It needs at least 5 days.
  • route_history: otherwise, from how late that route usually runs at that time of day, assuming the next vehicle leaves on time.
  • null: arriving by train, tram or Underground, or on a bus TransCAPI doesn't track, there's no history to go on.

Where the bus you'd arrive on is running now and tracked, live_margin_s is the time to spare after its current delay: today's answer, beside the history's.

Two changes at 80% each make 64% for the journey. A 2-minute change onto a frequent service can look alarming and matter little; a 2-minute change onto the last train of the night is a different thing. Show the number beside the journey and let people judge. History builds from 4 August 2026 in northern England and West Sussex and from 30 September 2026 elsewhere, so figures outside those areas firm up over the coming weeks.

Accessibility: slopes and steps

Every itinerary carries accessibility:

"accessibility": {
  "steepest_walk_gradient_pct": 6.4,
  "gradient_checked": true
}

steepest_walk_gradient_pct is the steepest slope on any walk in the journey, uphill or down, measured from OS Terrain 50 over 25 m stretches. gradient_checked is false if any walk couldn't be measured.

max_gradient (a percentage, 1 to 50) excludes itineraries with a steeper walk. 8.3 is the 1-in-12 standard for ramps; 5 is gentle. Like the walking limits, it's enforced after the search, and the response says how many journeys it removed:

"gradient_limits": { "max_gradient_pct": 8.3, "excluded_itineraries": 4 }

An empty list with excluded itineraries means journeys exist but all of them involve a steeper walk: in a hilly city a gentle limit can rule out everything, which is worth telling people plainly.

What isn't here yet. The national timetables mark almost every bus stop, station and journey's wheelchair accessibility as unknown, so TransCAPI can't yet say which stations are step-free or which vehicles are accessible. Every UK local bus has had to be wheelchair accessible since 2017. Wheelchair-accessible routing that avoids steps isn't available yet: wheelchair=true returns 400 not_available. In the meantime max_gradient rules out steep walks.

Carbon

Every leg carries carbon, the greenhouse gas it's responsible for per passenger, and every itinerary a total, with what driving the same distance alone would emit beside it:

"carbon": {
  "co2e_g": 1428,
  "driving_alone_co2e_g": 7869,
  "complete": true,
  "source": "UK government GHG conversion factors 2026 (DESNZ)"
}

A leg's figure is its distance times the UK government's 2026 conversion factor for its mode, in kg CO2e per passenger-km. These are the factors UK companies use for their own travel reporting, so your totals will match theirs. The leg names the factor it used:

ModeFactorg CO2e per passenger-km
Bus outside LondonLocal bus (not London)125.5
Bus in LondonLocal London bus63.6
CoachCoach39.5
RailNational rail30.9
Tram, light rail, metroLight rail and tram21.2
UndergroundLondon Underground15.5
FerryFerry, foot passenger18.7
Car (comparison)Average car, unknown fuel, one occupant165.9 per vehicle-km

Walking and cycling count as zero. These are national averages per passenger, not the particular vehicle: a busy bus does better than the average and an empty one worse. The driving comparison uses the journey's own distance, which can be a little longer or shorter than the road route. complete is false if a leg's mode has no factor, in which case the total leaves that leg out.

Turn-by-turn directions

With include_steps=true, every walking leg carries a steps array:

"steps": [
  {
    "relative_direction": "RIGHT",
    "absolute_direction": "NORTHEAST",
    "street_name": "Fargate",
    "distance_m": 69.0,
    "latitude": 53.3806,
    "longitude": -1.4699,
    "exit": null,
    "stay_on": false,
    "area": false
  }
]

relative_direction is the instruction — DEPART, CONTINUE, LEFT, SLIGHTLY_RIGHT, HARD_LEFT, CIRCLE_CLOCKWISE, STAIRS and others. absolute_direction is the compass bearing, which is what makes the first instruction usable before someone is moving and has no heading to work from. exit is set on roundabouts, stay_on when a road continues under a new name and no turn is needed, and area when crossing an open space such as a square with no street to name.

Steps are off by default because they roughly double the size of a response. Transit legs have no steps — use include_intermediate_stops for the calling points instead.

Alternatives and empty windows

Walking and cycling routes are computed alongside the transit search and returned under alternatives, keyed by mode. Transit is always the primary itineraries array; a walk-only result never appears there even when walking beats the bus, since it belongs under alternatives.walk. Walking and cycling alternatives are offered for routes up to 4 hours; beyond that the list is empty (nobody wants a 9-hour walk from Leeds to York).

Each transit search covers departures in the hour from your requested time.

When nothing runs in the requested window — late at night, after the last bus — the response sets next_available: true and returns the next departures found instead, which may be the following morning, rather than an empty list.

Example

curl "https://api.transcapi.com/v1/journey.json?\
from_lat=53.3811&from_lon=-1.4701&to_lat=53.4106&to_lon=-1.4290" \
  -H "X-Api-Key: YOUR_API_KEY"
{
  "request_time": "2026-08-05T15:25:40Z",
  "coverage": {
    "area": "Great Britain",
    "bbox": [49.8, -8.7, 61.0, 2.0]
  },
  "next_available": false,
  "itineraries": [
    {
      "start_time": "2026-08-05T16:36:36+01:00",
      "end_time":   "2026-08-05T17:09:14+01:00",
      "duration_s": 1958.0,
      "fare": { "amount_gbp": 2.80, "complete": true },
      "legs": [
        {
          "mode": "WALK",
          "transit": false,
          "distance_m": 306.86,
          "duration_s": 264.0,
          "from": { "name": "Origin", "latitude": 53.3811, "longitude": -1.4701, "atcocode": null },
          "to":   { "name": "Arundel Gate/AG6", "latitude": 53.38065, "longitude": -1.46629, "atcocode": "370023169" },
          "leg_geometry": "ki`dI..."
        },
        {
          "mode": "BUS",
          "transit": true,
          "headsign": "Maltby",
          "fare": 2.80,
          "stop_count": 13,
          "trip_id": "VJfde1c8a6f4a5b81dfb398429a6ef7793",
          "route": {
            "line": "X1",
            "operator": "First South Yorkshire",
            "operator_id": "OP153",
            "mode": "BUS"
          },
          "start_time": { "scheduled": "2026-08-05T16:41:00+01:00", "estimated": null, "delay_s": null },
          "from": { "name": "Arundel Gate/AG6", "atcocode": "370023169" },
          "to":   { "name": "Meadowhall Road/Weedon Street", "atcocode": "370020991" }
        }
      ]
    }
  ],
  "alternatives": {
    "walk":    { "itineraries": [] },
    "bicycle": { "itineraries": [] }
  }
}

Fares and live positions are enrichments, not the answer: if that data is temporarily unavailable the journey is still returned, with those fields null.

Check the changelog for updates as coverage expands.