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:
| Parameter | Type | Required | Definition |
|---|---|---|---|
from | object | required | Origin — see Route endpoints |
to | object | required | Destination — same shape |
mode | string | optional | driving, foot, bicycle, motor_scooter, okada, or truck (default driving) — see Truck routing |
truck | object | optional | Vehicle dimensions, only valid with mode: "truck" — see Truck routing |
narration | string | optional | street (default), landmark, or both — see Landmark narration |
language | string | optional | BCP-47 tag for verbal instructions (default en-US); 30+ languages supported |
avoid_polygons | array<array<[lng, lat]>> | optional | No-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_locations | array<object> | optional | Individual road points to avoid as { lat, lng }, snapped to the nearest road segment |
avoid_flood_zones | boolean | optional | Route around curated flood-prone zones (default false). Merged with any avoid_polygons you supply — best-effort, see the caution below |
lite | boolean | optional | Return 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.
| Field | Type | Required | Definition |
|---|---|---|---|
hex | string | either | AfriHex code at resolution 7, e.g. AF-GH-7-0GXTJJB0ZZZZZ. Snapped to a known structure point when one exists, else the hex centroid |
point | object | either | Raw coordinate as { lng, lat }. Must fall inside the active country |
heading | integer | optional | Device 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_m | number | optional | Road 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:
| Field | Type | Definition |
|---|---|---|
height_m | number | Vehicle height in metres (max 6) |
width_m | number | Vehicle width in metres (max 4) |
length_m | number | Vehicle length in metres (max 35) |
weight_t | number | Vehicle weight in metric tonnes (max 100) |
axle_load_t | number | Per-axle load in metric tonnes (max 30) |
axle_count | integer | Number of axles (max 12) |
hazmat | boolean | Carrying 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: trueroutes 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 anyavoid_polygonsyou 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 the409below. - Rain now — penalise routes through heavy rain when a drier option beats
the fastest. The winner is flagged with
recommended: trueand a plain-languagerecommend_reason(e.g. "avoids 2 flood-prone areas");rain_noteexplains the trade-off andrain_eta_penalty_sshows the ETA seconds the rain added.recommendedis absent when the fastest route is also the cleanest. - Flood crossings —
flood_crossingscounts flood-zone crossings on the route. The same safety fields (recommended,rain_note,flood_crossings, …) are repeated on eachalternatives[]entry. - Warnings —
warningsis 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 asurface_color(see the#4CAF50/#FF9800values in the response above). The top level also reportshas_unpavedandunpaved_distance_m— both absent when the route is fully paved or when surface enrichment did not run (best-effort), so treat absenthas_unpavedas "unknown". - Lane guidance — each step may carry a
lanesarray ("keep left of 3 lanes") with per-laneindications,active(the recommended lane), andvalid(usable at the start of the maneuver). Only present where the road graph hasturn:lanesdata — 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), andturn_class(straight/slight/turn/sharp/uturn), plusverbal_alertandverbal_postvoice 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:
| Response | Meaning | Suggested UI |
|---|---|---|
200, no flood_avoidance_failed | Zones avoided | Normal |
200 + flood_avoidance_failed: true | Zones ignored; route may flood | Badge unsafe |
409 FLOOD_ALERT_ACTIVE | Live GMet flood warning and no safe route | Refuse, show the headline |
404 NO_ROUTE | Your own avoid_* constraints, or unreachable endpoints | Normal 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:
| Parameter | Type | Required | Definition |
|---|---|---|---|
sources | array<object> | required | Origins — see Route endpoints. Maximum 25 |
destinations | array<object> | optional | Destinations, same shape, maximum 25. Omit for a symmetric N×N matrix over sources |
mode | string | optional | driving, 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:
| Parameter | Type | Required | Definition |
|---|---|---|---|
location | object | required | Centre point — see Route endpoints |
contours | array<object> | required | Reachability rings — { "time_minutes": 15 }, 1–120 minutes each, 1–6 per request. Returned in request order |
mode | string | optional | driving, foot, bicycle, motor_scooter, okada, or truck (default driving) — no vehicle-dimensions object here, see Truck routing |
polygons | boolean | optional | true returns each contour as a filled GeoJSON Polygon; otherwise contours come back as LineStrings. Forced on by hex_codes |
generalize | number | optional | Douglas-Peucker simplification tolerance in metres — higher means smaller payloads with less detail (0 = none) |
hex_codes | boolean | optional | Also 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.
Navigation
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:
modesis 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_mis accepted for forward compatibility but currently ignored by the server — don't rely on it to bound walking.date(YYYY-MM-DD) andtime(HH:MM) default to now. A malformed value returns400 INVALID_PARAMrather 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 intermediatestops(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 initinerariesuses a vehicle, including an empty list — plus anoteexplaining 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_tripis set instead oflast_tripwhen 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 isnorth,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.