Skip to main content

Routing & Navigation

Turn-by-turn routing for Ghana's road network, powered by a self-hosted Valhalla engine. Because addresses are sparse in Ghana, routing here understands landmarks — the way people actually navigate.

Route between two points​

POST /v2/route accepts hex codes, GPS codes, or coordinates as endpoints:

curl -X POST "https://api.afrihex.com/v2/route" \
-H "X-API-Key: $AFRIHEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": { "hex": "AF-GH-7-0GXTQD5RFZZZZ" },
"to": { "hex": "AF-GH-7-0GXTQD5RJ0000" },
"mode": "driving"
}'

Travel modes: driving, foot, bicycle, motor_scooter, okada, and truck (motor_scooter and okada are aliases for motorbike routing). Omitting mode — or passing an empty string — defaults to driving. The response includes distance, duration, a polyline, and turn-by-turn steps:

Request object — POST /v2/route:

ParameterTypeRequiredDefinition
fromobjectrequiredOrigin — see Route endpoints
toobjectrequiredDestination — same shape
modestringoptionaldriving, foot, bicycle, motor_scooter, okada, or truck (default driving) — see Truck routing
truckobjectoptionalVehicle dimensions, only valid with mode: "truck" — see Truck routing
narrationstringoptionalstreet (default), landmark, or both — see Landmark narration
languagestringoptionalBCP-47 tag for verbal instructions (default en-US); 30+ languages supported
avoid_polygonsarray<array<[lng, lat]>>optionalNo-go areas. Each element is a closed exterior ring of [lng, lat] pairs — high-risk corridors for cash-in-transit, flood closures, custom zones
avoid_locationsarray<object>optionalIndividual road points to avoid as { lat, lng }, snapped to the nearest road segment
avoid_flood_zonesbooleanoptionalRoute around curated flood-prone zones (default false). Merged with any avoid_polygons you supply — best-effort, see the caution below
litebooleanoptionalReturn a minimal payload (default false) — polyline, distance, duration, and ETA only, with no steps, narration, or alternatives. Typically 80–90% smaller; use it for tracking screens and low-bandwidth clients
{
"success": true,
"data": {
"distance_m": 4604,
"duration_s": 687.079,
"eta_s": 1122.7864,
"traffic_note": "morning rush",
"coordinates": [ [-0.195223, 5.604169], [-0.195249, 5.604189] ],
"steps": [
{
"instruction": "Drive northwest on Continental Close.",
"verbal_instruction": "Drive northwest on Continental Close. Then Turn left onto Continental Road.",
"verbal_post": "Continue for 300 meters.",
"name": "Continental Close",
"near_landmark": "Roman Ridge",
"distance_m": 308,
"duration_s": 37.063,
"surface": "paved",
"surface_color": "#4CAF50",
"bearing_before": 308,
"bearing_after": 308,
"turn_class": "straight"
}
],
"landmarks_passed": [],
"recommended": true,
"recommend_reason": "avoids 2 flood-prone areas",
"rain_note": "heavy rain on the fastest option; a drier route wins by 4 min",
"rain_eta_penalty_s": 240,
"flood_crossings": 0,
"has_unpaved": true,
"unpaved_distance_m": 1072
}
}

Route endpoints​

from, to, and the endpoints of the matrix and isochrone calls all take the same object. Supply either hex or point, never both — sending both is a validation error.

FieldTypeRequiredDefinition
hexstringeitherAfriHex code at resolution 7, e.g. AF-GH-7-0GXTJJB0ZZZZZ. Snapped to a known structure point when one exists, else the hex centroid
pointobjecteitherRaw coordinate as { lng, lat }. Must fall inside the active country
headingintegeroptionalDevice compass bearing, 0–359 (0 = north). On dual carriageways (Ring Road, N1) it decides which side the endpoint snaps to, so a route doesn't open with a phantom U-turn. Omit when unknown
radius_mnumberoptionalRoad search radius in metres (0 = Valhalla's ~35 m default)

heading and radius_m apply to from / to on POST /v2/route; the matrix and isochrone endpoints use only hex / point.

Truck routing​

mode: "truck" switches to heavy-goods costing: it penalises tracks and living streets far harder than driving (a laterite track that merely slows a saloon can strand an artic), and honours the OSM hgv, maxheight, maxweight, maxwidth, and maxlength restrictions already in the road graph. It's accepted on /v2/route, /v2/route/matrix, /v2/route/optimized, /v2/route/isochrone, and /v2/route/match.

The optional truck object — POST /v2/route only — describes the vehicle so restriction-aware routing can actually route around what your vehicle breaches:

FieldTypeDefinition
height_mnumberVehicle height in metres (max 6)
width_mnumberVehicle width in metres (max 4)
length_mnumberVehicle length in metres (max 35)
weight_tnumberVehicle weight in metric tonnes (max 100)
axle_load_tnumberPer-axle load in metric tonnes (max 30)
axle_countintegerNumber of axles (max 12)
hazmatbooleanCarrying hazardous materials

Every field is optional — omit what you don't know rather than guessing, since an overstated dimension silently rules out roads the vehicle could legally use, and unset fields fall back to Valhalla's own truck defaults rather than a guess. Units are metres and metric tonnes, matching the OSM tags they're compared against. Sending truck with any mode other than "truck" returns 400 VALIDATION_ERROR rather than being silently ignored — if you're getting that error, check mode is actually set.

Matrix, optimized routes, isochrones, and map-matching accept mode: "truck" for costing, but don't take a truck dimensions object — they route with Valhalla's default truck profile, not your vehicle's actual size.

:::caution Ghana's OSM truck-restriction data is thin today

Measured 2026-08-12: 77 maxheight ways, 0 maxweight, 6 hgv nationwide. truck mode is wired correctly and does change routes where road class varies — a rural Bolgatanga pair returns 31.1 km for truck against driving's 32.2 km — but on trunk corridors it's currently identical to driving (Accra→Kumasi is 249 km either way), because the restriction data it would route around barely exists yet. Treat this as correct-and-ready infrastructure, not as something that will visibly steer a truck around low bridges today — that improves as Ghana's OSM coverage does.

:::

Route-aware conditions​

Routing can account for real-world conditions:

  • Flood zones — avoid_flood_zones: true routes around the recurring trouble spots along Accra's Odaw drainage basin (Kaneshie First Light, Kwame Nkrumah Circle/Odawna, Alajo, Agbogbloshie) and the Weija dam spillway. The zones are merged with any avoid_polygons you supply.
  • Weather alerts — official GMet heavy-rain / flood warnings covering the route surface in warnings. Advisory: alert areas are region-sized, so they never become avoid-polygons. The one case where an alert changes the outcome rather than annotating it is the 409 below.
  • Rain now — penalise routes through heavy rain when a drier option beats the fastest. The winner is flagged with recommended: true and a plain-language recommend_reason (e.g. "avoids 2 flood-prone areas"); rain_note explains the trade-off and rain_eta_penalty_s shows the ETA seconds the rain added. recommended is absent when the fastest route is also the cleanest.
  • Flood crossings — flood_crossings counts flood-zone crossings on the route. The same safety fields (recommended, rain_note, flood_crossings, …) are repeated on each alternatives[] entry.
  • Warnings — warnings is a top-level array of plain-language cautions: an endpoint sitting inside an unavoidable flood zone, a nearby zone that could not be excluded, flood avoidance abandoned entirely, or a GMet alert covering the route.
  • Surface quality — each step reports surface (paved / unpaved / unknown) and a surface_color (see the #4CAF50 / #FF9800 values in the response above). The top level also reports has_unpaved and unpaved_distance_m — both absent when the route is fully paved or when surface enrichment did not run (best-effort), so treat absent has_unpaved as "unknown".
  • Lane guidance — each step may carry a lanes array ("keep left of 3 lanes") with per-lane indications, active (the recommended lane), and valid (usable at the start of the maneuver). Only present where the road graph has turn:lanes data — most Ghana roads don't yet.
  • Maneuver detail — steps also report bearing_before / bearing_after (headings into/out of the maneuver), turn_angle (signed, +right/−left), and turn_class (straight / slight / turn / sharp / uturn), plus verbal_alert and verbal_post voice prompts for TTS.

:::caution avoid_flood_zones is best-effort, not a guarantee

Excluding every nearby zone can leave no legal path — across Accra the Odaw basin polygons cover most cross-town arteries. Rather than fail with 404 NO_ROUTE, the API retries without the zones and returns that route with flood_avoidance_failed: true plus a warning. Badge such a route as unsafe; do not present it as flood-avoiding. Prefer the flag over matching on warning text.

Your own avoid_polygons / avoid_locations are never dropped by this retry, so those can still legitimately produce 404 NO_ROUTE.

One exception, where the API refuses instead. If that degraded route would be served and an official GMet flood warning is in effect over the corridor, the request fails with 409 FLOOD_ALERT_ACTIVE rather than steering a navigator into a live, confirmed flood. The error message carries the GMet headline — show it. Dropping avoid_flood_zones lifts the refusal, so do that only on an explicit "go anyway" from the user, never automatically.

This is deliberately narrow: a route that successfully avoids the zones is still served during an alert — that's the route someone needs in order to leave — and requests that never set avoid_flood_zones are untouched.

:::

The four outcomes of avoid_flood_zones: true:

ResponseMeaningSuggested UI
200, no flood_avoidance_failedZones avoidedNormal
200 + flood_avoidance_failed: trueZones ignored; route may floodBadge unsafe
409 FLOOD_ALERT_ACTIVELive GMet flood warning and no safe routeRefuse, show the headline
404 NO_ROUTEYour own avoid_* constraints, or unreachable endpointsNormal no-route UI

Distance matrices​

POST /v2/route/matrix computes all-pairs travel times/distances for many origins and destinations in one call — perfect for optimising dispatch:

Request object:

ParameterTypeRequiredDefinition
sourcesarray<object>requiredOrigins — see Route endpoints. Maximum 25
destinationsarray<object>optionalDestinations, same shape, maximum 25. Omit for a symmetric N×N matrix over sources
modestringoptionaldriving, foot, bicycle, motor_scooter, okada, or truck (default driving) — no vehicle-dimensions object here, see Truck routing

Cells are indexed [source][destination]; -1 means the pair is unreachable.

curl -X POST "https://api.afrihex.com/v2/route/matrix" \
-H "X-API-Key: $AFRIHEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"sources": [{"hex":"AF-GH-7-0GXTQD5RFZZZZ"}], "destinations": [{"hex":"AF-GH-7-0GXTQD5VFZZZZ"}], "mode": "driving"}'
{
"success": true,
"data": {
"durations": [[687]],
"distances": [[4605]],
"sources": 1,
"destinations": 1
}
}

Isochrones​

POST /v2/route/isochrone answers "how far can I get in 30 minutes?" — returns a reachable area polygon:

Request object:

ParameterTypeRequiredDefinition
locationobjectrequiredCentre point — see Route endpoints
contoursarray<object>requiredReachability rings — { "time_minutes": 15 }, 1–120 minutes each, 1–6 per request. Returned in request order
modestringoptionaldriving, foot, bicycle, motor_scooter, okada, or truck (default driving) — no vehicle-dimensions object here, see Truck routing
polygonsbooleanoptionaltrue returns each contour as a filled GeoJSON Polygon; otherwise contours come back as LineStrings. Forced on by hex_codes
generalizenumberoptionalDouglas-Peucker simplification tolerance in metres — higher means smaller payloads with less detail (0 = none)
hex_codesbooleanoptionalAlso return the resolution-7 hex codes covered by each contour, in hex_codes (indexed like contours). This is what lets you join a drive-time ring straight onto your portfolio-zone or KYC data
curl -X POST "https://api.afrihex.com/v2/route/isochrone" \
-H "X-API-Key: $AFRIHEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"location": {"hex": "AF-GH-7-0GXTQD5RFZZZZ"}, "contours": [{"time_minutes": 15}, {"time_minutes": 30}], "mode": "driving"}'
{
"success": true,
"data": {
"geojson": {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"contour": 30,
"metric": "time",
"fillColor": "#bf4040",
"fillOpacity": 0.33
},
"geometry": { "type": "Polygon", "coordinates": [] }
}
]
}
}
}

The response returns each contour as a styled GeoJSON polygon (drive-time rings). This drives the Service Areas drive-time zones.

Optimized routes​

POST /v2/route/optimized solves a multi-stop problem — visit N waypoints in the best order (traveling-salesman style). Accepts mode: "truck" for costing (no vehicle-dimensions object — see Truck routing).

GPX export​

POST /v2/route/gpx returns a GPX file you can drop straight into Google Maps / Garmin / any GPS device. There's also POST /v2/route/match and POST /v2/route/match/gpx for snapping a GPS track to the road network — both also accept mode: "truck" for costing.

POST /v2/navigate/route is the API behind a turn-by-turn navigation flow — the same Valhalla call as POST /v2/route/public, just returned as extended-OSRM for a turn-by-turn client (Ferrostar) to parse instead of AfriHex's own step shape. Public, no key required: a route planned while logged out can be navigated without hitting a login wall on "Navigate".

curl -X POST "https://api.afrihex.com/v2/navigate/route" \
-H "Content-Type: application/json" \
-d '{
"from": { "hex": "AF-GH-7-0GXTQD5RFZZZZ" },
"to": { "hex": "AF-GH-7-0GXTQD5RJ0000" },
"mode": "driving"
}'
{
"success": true,
"data": {
"distance_m": 4604,
"duration_s": 687.079,
"eta_s": 1122.79,
"steps": [
{
"instruction": "Drive northwest on Continental Close.",
"verbal_instruction": "Drive northwest on Continental Close. Then Turn left onto Continental Road.",
"name": "Continental Close",
"near_landmark": "Roman Ridge",
"distance_m": 308,
"duration_s": 37.063,
"turn_class": "straight"
}
]
}
}

POST /v2/navigation/arrival records when a navigator reaches the pin, scoring destination accuracy over time. Every field is optional and the handler is fail-open: missing or zero coordinates are silently dropped (204), never a 400 — send the canonical set (dest_lat, dest_lng, final_lat, final_lng, profile) and it just works. There's also a public route endpoint (POST /v2/route/public, IP-throttled) used by the QR-code navigation flow so anyone can get directions without an account.

Live traffic & transit​

GET /v2/route/traffic returns the current congestion feed — the effective traffic multiplier per road class for the active time bucket. Where the learned-traffic loop has enough samples the factor is learned; otherwise it falls back to the static Accra table:

curl "https://api.afrihex.com/v2/route/traffic" \
-H "X-API-Key: $AFRIHEX_API_KEY"
{
"success": true,
"traffic": {
"time_bucket": "morning",
"note": "morning rush",
"cells": [
{ "road_class": "motorway", "factor": 1.7, "severity": "moderate", "color": "#f9a825", "source": "learned" },
{ "road_class": "primary", "factor": 1.4, "severity": "light", "color": "#2e7d32", "source": "learned" },
{ "road_class": "secondary", "factor": 1.2, "severity": "light", "color": "#2e7d32", "source": "static" },
{ "road_class": "residential", "factor": 1.0, "severity": "free_flow", "color": "#2e7d32", "source": "static" }
]
}
}

POST /v2/route/transit plans a point-to-point trotro / bus trip on the Accra GTFS network (via the self-hosted OTP2 sidecar). Returns up to 3 itineraries with walk and transit legs, stop names, route names, and per-leg times. Public, no key required:

curl -X POST "https://api.afrihex.com/v2/route/transit" \
-H "Content-Type: application/json" \
-d '{
"from_lat": 5.6876, "from_lng": -0.1713,
"to_lat": 5.579, "to_lng": -0.2322,
"date": "2026-08-08", "time": "12:00"
}'

Transit details worth knowing:

  • modes is a case-insensitive substring selector — empty or containing "transit" enables transit routing (the default); anything else, e.g. "WALK", returns a walk-only plan.
  • max_walk_m is accepted for forward compatibility but currently ignored by the server — don't rely on it to bound walking.
  • date (YYYY-MM-DD) and time (HH:MM) default to now. A malformed value returns 400 INVALID_PARAM rather than silently falling back to "now".
  • Each transit leg carries a geometry — the decoded path as [lng, lat] pairs (OTP2 leg geometry, 5-digit precision polyline) — so you can draw the exact route on a map instead of a straight line. Ride legs also list their intermediate stops (name / lat / lng); walk legs have none.

No service, honestly​

OTP answers a query it can't serve with a plain walk rather than an error — Achimota to 37 Hospital at 05:00 comes back as a 91-minute walk, not a bus, because the Accra feed simply runs no trips that early. Don't infer "no service" from leg modes yourself; check the flags instead:

  • Each itinerary carries walk_only — true when every leg is a walk leg.
  • The response carries no_transit — true when nothing in itineraries uses a vehicle, including an empty list — plus a note explaining why.
  • The walk itself is still returned rather than suppressed: for two points a few hundred metres apart it's the right answer. The flags are what let you show "no transit needed, it's an 8 min walk" instead of "there is no service" when the distance is short.

When the miss looks like a clock problem — the request fell outside the day's service window near the start point — note is backed by real GTFS data instead of staying generic:

{
"no_transit": true,
"note": "too late — the last trotro near your start point today was the 102B from Terminal Madina Station at 19:40, already gone",
"last_trip": {
"short_name": "102B", "long_name": "Madina Station to Achimota Station",
"stop_name": "Terminal Madina Station", "time": "19:40"
}
}
  • first_trip is set instead of last_trip when the request fell before the day's first boardable departure near the start point ("too early — the first trotro … leaves at HH:MM").
  • Neither is set when the request time falls inside the service window but still found no route — that's a real network gap, not a time-of-day problem, and the generic note ("service is sparse outside daytime hours…") stands on its own.
  • Both are derived from the start point, not the destination, and from this feed's one representative trip per line per day — not a live "next bus" countdown.

POST /v2/navigation/arrival doubles as the learned-traffic signal — when the navigator sends origin + elapsed time, the arrival sample calibrates future route ETAs. Route responses now carry traffic_factor, traffic_severity, and traffic_color per step, so you can tint route lines by live conditions.

What leaves from here?​

GET /v2/transit/departures?lat=&lng=&radius_m= is the reverse of trip planning: the question a rider actually has while standing at a station — which trotro/bus lines can I board here, and where does each one go? Public, no key. radius_m defaults to 400, max 2000.

curl "https://api.afrihex.com/v2/transit/departures?lat=5.61652&lng=-0.22947&radius_m=400"
{
"routes": [
{ "short_name": "263A", "long_name": "Darkuman to Haasto", "mode": "BUS",
"destination": "Terminal Haasto", "dest_distance_m": 6300,
"stop_name": "Achimota Old Station", "stop_distance_m": 0 }
],
"count": 3,
"stops": ["Achimota Old Station"]
}

Sorted nearest boarding point first. Lines that terminate at the stop are deliberately excluded — at Achimota, 8 of the 12 routes serving the station arrive rather than depart, so offering them would send a rider toward a bus that's already finished its run; only lines that continue somewhere meaningfully far are returned, so a short list here is correct, not broken.

When count is 0, no_routes is set with a note distinguishing "no stop within radius_m" from "a stop nearby, but it's the end of every line it serves" — the advice differs (widen the search vs. walk to another station). Same Greater-Accra-only, daytime-service caveats as /v2/route/transit.

Static map images​

GET /v2/route/static?from=5.6037,-0.1870&to=5.5665,-0.2366 renders a route as a shareable map image — blue route line, green start pin, red end flag, and numbered amber pins for landmarks passed, with a small legend. It's public (IP-throttled, 15/min) so you can share images without an account. The output is always a fixed 800x500 PNG; width/height are not accepted. Great for WhatsApp sharing or receipts.

Landmarks​

Find places the way Ghanaians describe them:

  • GET /v2/landmarks/geocode?q=kaneshie%20market — fuzzy landmark search.
  • GET /v2/landmarks/geocode?near=5.557,-0.182&radius=2000 — landmarks near a point (useful for "tap a kind" POI lists).
  • GET /v2/landmarks/geocode?bbox=5.62,5.60,-0.185,-0.195 — viewport search: every landmark whose centroid falls inside the bounding box, so a map can render the POI dots it can actually see. Bbox ordering is north,south,east,west, matching /v2/map/tiles.
  • GET /v2/landmarks/geocode?near=…&has_photo=true — only landmarks with a resolved photo.
  • GET /v2/landmarks/around?lat=...&lng=...&radius=500 — landmarks near a point.
  • GET /v2/hexcode/{code}/landmarks — landmarks inside a hex cell.
  • POST /v2/route/along — landmarks within a corridor around a route polyline.

Landmark responses include POI detail fields where the source has them:

  • phone, website, opening_hours — contact + hours from OSM tags, for the POI detail cards.
  • photo_url — a Wikimedia Commons thumbnail when one is resolved (~130 landmarks and growing). The matcher can occasionally produce a wrong or foreign image, so treat photos as illustrative until matching is geo-verified.

Route health​

GET /v2/route/health reports whether the routing engine's extract is current — clients can warn when routes may be stale.

SDK​

const route = await afrihex.landmarks.geocode({ q: 'kaneshie market' })
console.log(route.matches[0].centroid) // { lat: 5.5641, lng: -0.2335 }

API reference​

See Routing & Navigation — API Reference for every endpoint in this group.