GeoGlobal — API Reference
Complete reference for every endpoint and MCP tool. Two surfaces, same backing data.
- REST API —
https://maps.dataacuity.co.za/api/v2/*(proxied throughmaps_api) — the recommended path for TagMe and Takemehome - MCP server —
geo_mcp:8000in-cluster, orhttp://197.97.200.106:5026/sseexternally — the recommended path for B! / Butler and any AI agent - Direct Valhalla —
valhalla:8002in-cluster — only call directly if you need raw access to features the REST proxy doesn't expose (e.g., isochrones, height service, optimized_route)
Auth: The REST API expects the same DataAcuity Keycloak bearer token your app already sends. The MCP server is unauthenticated inside the network and should not be exposed publicly without an auth layer.
Table of contents
REST Endpoints
All REST endpoints return JSON. All accept and return UTF-8. Coordinates are decimal degrees (WGS-84). Distances are kilometres unless noted.
The
/api/v2/*paths are NEW. They are not yet wired intomaps_api— seeGeoGlobal_Deployment.mdfor the proxy implementation plan. Until that ships, apps can call the MCP server directly over HTTP+SSE (see MCP Tools) or hit the Valhalla container at:5027for routing.
GET /api/v2/geocode
Find places by exact name. Returns top matches ranked by population.
Query parameters
| Name | Type | Default | Notes |
|---|---|---|---|
query |
string | required | Place name (case-insensitive, exact match) |
limit |
int | 5 | Max results, 1–50 |
include_interesting |
bool | false | If true, each result includes a nested interesting_nearby list |
within_km |
float | 10.0 | POI search radius when include_interesting=true |
max_interesting |
int | 5 | Cap on POIs returned per place |
Example
curl 'https://maps.dataacuity.co.za/api/v2/geocode?query=Cape%20Town&include_interesting=true&max_interesting=3'
Response (200)
[
{
"name": "Cape Town",
"country": "ZA",
"fclass": "P",
"fcode": "PPLA",
"population": 4772846,
"lat": -33.92584,
"lng": 18.42322,
"interesting_nearby": [
{
"name": "Castle Military Museum",
"description": "Museum at the Castle of Good Hope...",
"category": "tourism",
"subcategory": "museum",
"source": "osm",
"country": "ZA",
"lat": -33.9258,
"lng": 18.4275,
"interest_score": 0.85,
"tags": ["tourism", "museum"],
"meters": 361
}
]
}
]
Empty-result behavior: returns []. Never null.
GET /api/v2/reverse-geocode
Find the nearest populated places to a coordinate.
Query parameters
| Name | Type | Default | Notes |
|---|---|---|---|
lat |
float | required | Latitude (–90 to 90) |
lng |
float | required | Longitude (–180 to 180) |
limit |
int | 5 | Max results, 1–50 |
include_interesting |
bool | false | If true, each result includes interesting_nearby |
within_km |
float | 10.0 | POI search radius when include_interesting=true |
max_interesting |
int | 5 | Cap on POIs per place |
Example
curl 'https://maps.dataacuity.co.za/api/v2/reverse-geocode?lat=-33.9249&lng=18.4241&limit=3'
Response (200) — places sorted by distance ascending, with a meters field:
[
{
"name": "Cape Town",
"country": "ZA",
"fclass": "P",
"population": 4772846,
"lat": -33.92584,
"lng": 18.42322,
"meters": 88
},
{
"name": "Sea Point",
"country": "ZA",
"fclass": "P",
"population": 9132,
"lat": -33.91667,
"lng": 18.38333,
"meters": 3914
}
]
GET /api/v2/search
Fuzzy place search using PostgreSQL trigram similarity. Use this for partial input, autocomplete, and misspellings.
Query parameters
| Name | Type | Default | Notes |
|---|---|---|---|
query |
string | required | Partial or approximate name |
limit |
int | 10 | Max results, 1–50 |
include_interesting |
bool | false | Same as geocode |
within_km |
float | 10.0 | |
max_interesting |
int | 5 |
Example
curl 'https://maps.dataacuity.co.za/api/v2/search?query=Johanesburg&limit=3'
Response (200) — note the sim similarity score (0–1, 1 = exact):
[
{
"name": "Johannesburg",
"country": "ZA",
"fclass": "P",
"population": 9418183,
"lat": -26.20227,
"lng": 28.04363,
"sim": 0.846
}
]
Recommended: filter UI suggestions to sim >= 0.4 to avoid noise.
GET /api/v2/poi/nearby
Find interesting POIs near a coordinate. Use this when you already have a location and want to enrich it (without geocoding first).
Query parameters
| Name | Type | Default | Notes |
|---|---|---|---|
lat |
float | required | |
lng |
float | required | |
within_km |
float | 10.0 | Search radius, max 100 |
limit |
int | 10 | 1–50 |
theme |
string | none | Optional tag filter (e.g. history, nature, must-see, architecture, religion) |
Example
curl 'https://maps.dataacuity.co.za/api/v2/poi/nearby?lat=-33.9249&lng=18.4241&within_km=5&theme=history&limit=5'
Response (200) — POIs ranked by interest_score DESC, distance ASC:
[
{ "name": "Old Pump House", "category": "historic", "interest_score": 0.88, "meters": 6365, "lat": -33.97, "lng": 18.41, "tags": ["historic"] },
{ "name": "Woodhead Tunnel", "category": "historic", "interest_score": 0.88, "meters": 7115, "lat": -33.99, "lng": 18.43, "tags": ["historic"] },
{ "name": "Castle Military Museum", "category": "tourism", "interest_score": 0.85, "meters": 361 }
]
POST /api/v2/route
Turn-by-turn directions between two coordinates. Africa only.
Request body
{
"from_lat": -33.9249, "from_lng": 18.4241,
"to_lat": -26.2041, "to_lng": 28.0473,
"mode": "auto"
}
mode is one of: auto (default, driving), bicycle, pedestrian, motorcycle, truck.
Example
curl -X POST 'https://maps.dataacuity.co.za/api/v2/route' \
-H 'Content-Type: application/json' \
-d '{"from_lat":-33.9249,"from_lng":18.4241,"to_lat":-26.2041,"to_lng":28.0473,"mode":"auto"}'
Response (200)
{
"distance_km": 1399.32,
"duration_min": 733.0,
"mode": "auto",
"maneuvers": [
{
"instruction": "Drive east on Adderley Street.",
"distance_km": 0.144,
"duration_min": 0.4,
"street_names": ["Adderley Street"]
},
{ "instruction": "Turn left onto Strand Street.", "distance_km": 1.8, "duration_min": 3.2, "street_names": ["Strand Street"] }
]
}
Outside-Africa behavior: returns 400 with body {"error_code":171,"error":"No suitable edges near location"}.
POST /api/v2/quest
The synthesis tool. Finds the most interesting POIs near a starting point and routes through them in optimal order. Returns an ordered visit list plus total distance/time.
Request body
{
"lat": -33.9249,
"lng": 18.4241,
"theme": "history",
"within_km": 20,
"max_pois": 5,
"mode": "auto"
}
| Field | Type | Default | Notes |
|---|---|---|---|
lat / lng |
float | required | Quest start (also where the quest ends — it's a round-trip) |
theme |
string | none | Optional tag filter |
within_km |
float | 20 | POI search radius, max 100 |
max_pois |
int | 5 | 1–10 recommended |
mode |
string | auto |
auto, bicycle, pedestrian |
Example
curl -X POST 'https://maps.dataacuity.co.za/api/v2/quest' \
-H 'Content-Type: application/json' \
-d '{"lat":-33.9249,"lng":18.4241,"theme":"history","within_km":10,"max_pois":5,"mode":"auto"}'
Response (200)
{
"total_distance_km": 24.6,
"total_duration_min": 78.0,
"ordered_pois": [
{ "name": "Castle Military Museum", "category": "tourism", "interest_score": 0.85, "lat": -33.9258, "lng": 18.4275, "meters_from_start": 361 },
{ "name": "South African Maritime Museum", "category": "tourism", "interest_score": 0.85 },
{ "name": "Old Pump House", "category": "historic", "interest_score": 0.88 }
],
"leg_count": 6,
"mode": "auto",
"theme": "history"
}
If no POIs match within radius: returns {"error":"no POIs found nearby", ...} with HTTP 200.
If POIs were found but Valhalla routing failed (e.g. outside Africa): returns {"ordered_pois":[...], "routing_error":"...", "mode":"auto"} — caller still has the POI list.
MCP Tools
The MCP server (geo_mcp container) exposes nine MCP tools — for B!/Butler and any other MCP-aware client.
Transport: SSE at http://geo_mcp:8000/sse (inside cluster) or http://197.97.200.106:5026/sse (external).
Server name: geo-global.
Current version: v0.5 (2026-05-28 — adds interesting_nearby, isochrone, distance_matrix, snap_to_road).
Place lookup
| Tool | Args | Returns |
|---|---|---|
geocode |
query: str, limit=5, include_interesting=False, within_km=10.0, max_interesting=5 |
list[Place] |
reverse_geocode |
lat: float, lng: float, limit=5, include_interesting=False, within_km=10.0, max_interesting=5 |
list[Place] |
search_places |
query: str, limit=10, include_interesting=False, within_km=10.0, max_interesting=5 |
list[Place] |
POI discovery
| Tool | Args | Returns |
|---|---|---|
interesting_nearby |
lat: float, lng: float, within_km=10.0, limit=10, theme: str \| None = None |
list[Poi] |
theme examples: "history", "nature", "must-see", "architecture", "religion", "tourism", "museum", "viewpoint". Bounds: within_km clamped to [0.1, 100], limit clamped to [1, 50].
Africa routing & reachability
| Tool | Args | Returns |
|---|---|---|
route |
from_lat, from_lng, to_lat, to_lng, mode='auto' |
RouteResult |
discover_quest |
lat, lng, theme=None, within_km=20.0, max_pois=5, mode='auto' |
QuestResult |
isochrone |
lat, lng, contours_minutes: list[int]=[15], mode='auto', denoise=0.5, generalize_meters=100 |
IsochroneResult |
distance_matrix |
sources: list[{lat,lng}], targets: list[{lat,lng}], mode='auto' |
MatrixResult |
snap_to_road |
lat, lng, mode='auto', radius_meters=500 |
SnapResult |
mode accepts: "auto" (driving, default), "bicycle", "pedestrian", "motorcycle", "truck". Some tools also accept "bus", "taxi", "motor_scooter" where Valhalla supports them.
Schemas match the REST shapes above. The MCP server file at /home/geektrading/geo-mcp/server.py on .106 is the source of truth.
isochrone example call
{
"lat": -33.9249, "lng": 18.4241,
"contours_minutes": [10, 20, 30],
"mode": "auto"
}
Returns:
{
"center": {"lat": -33.9249, "lng": 18.4241},
"mode": "auto",
"contours_minutes": [10, 20, 30],
"polygons": [
{"time_minutes": 10, "geometry": {"type": "Polygon", "coordinates": [[...]]}},
{"time_minutes": 20, "geometry": {"type": "Polygon", "coordinates": [[...]]}},
{"time_minutes": 30, "geometry": {"type": "Polygon", "coordinates": [[...]]}}
]
}
Polygons are sorted smallest-contour first so a renderer can draw outer-to-inner. Caps: each contour ≤ 180 min; polygons always returns GeoJSON Polygon or MultiPolygon.
distance_matrix example call
{
"sources": [{"lat": -33.92, "lng": 18.42}, {"lat": -26.20, "lng": 28.04}],
"targets": [{"lat": -29.86, "lng": 31.02}, {"lat": -25.74, "lng": 28.19}],
"mode": "auto"
}
Returns:
{
"mode": "auto",
"sources_count": 2,
"targets_count": 2,
"matrix": [
[{"distance_km": 1657.3, "duration_min": 1011.8}, {"distance_km": 1399.5, "duration_min": 854.0}],
[{"distance_km": 571.7, "duration_min": 366.4}, {"distance_km": 62.4, "duration_min": 54.6}]
]
}
matrix[i][j] is sources[i] → targets[j]. null for either field means unreachable (e.g. across an ocean, outside Africa). Capped at 50 × 50 to protect the routing container.
snap_to_road example call
{ "lat": -33.92485, "lng": 18.42105, "mode": "auto" }
Returns:
{
"input": {"lat": -33.92485, "lng": 18.42105},
"snapped": true,
"snapped_lat": -33.92489,
"snapped_lng": 18.42098,
"distance_meters": 7,
"road": {
"name": "Adderley Street",
"alternate_names": [],
"road_class": "primary",
"speed_kph": 50,
"way_id": 5874021,
"use": "road"
}
}
If no road within radius_meters (default 500, max 5000): {"snapped": false, "reason": "no routable edge within radius"}.
MCP client registration example (for B! / Butler):
// in your MCP client config
{
"mcpServers": {
"geo-global": {
"url": "http://geo_mcp:8000/sse",
"transport": "sse"
}
}
}
Common types
Place
interface Place {
name: string;
country: string; // ISO 3166-1 alpha-2 ("ZA", "NG", "KE", ...)
fclass?: string; // GeoNames feature class — "P" = populated
fcode?: string; // GeoNames feature code — "PPLA" = first-order admin capital, "PPL" = generic
population?: number;
lat: number;
lng: number;
meters?: number; // distance from query coord (reverse_geocode + nearby only)
sim?: number; // trigram similarity (search only)
interesting_nearby?: Poi[];
}
Poi
interface Poi {
name: string;
description?: string;
category: string; // top-level OSM category — "tourism", "natural", "historic", "amenity"
subcategory?: string; // OSM subtag — "museum", "viewpoint", "place_of_worship"
source: string; // "osm" | "wikipedia" | "manual"
country: string;
lat: number;
lng: number;
interest_score: number; // 0.0 – 1.0
tags: string[]; // freeform — "tourism", "museum", "must-see", "history", "art", "nature"
meters?: number; // distance from query coord
}
RouteResult
interface RouteResult {
distance_km: number;
duration_min: number;
mode: string;
maneuvers: Maneuver[];
}
interface Maneuver {
instruction: string;
distance_km: number;
duration_min: number;
street_names?: string[];
}
QuestResult
interface QuestResult {
total_distance_km?: number; // missing if routing failed
total_duration_min?: number;
ordered_pois: Poi[];
leg_count?: number;
mode: string;
theme?: string;
routing_error?: string; // present if Valhalla failed but POIs were found
error?: string; // present if no POIs found at all
}
Errors
| HTTP | Body | Cause |
|---|---|---|
| 400 | {"error_code":171,"error":"No suitable edges near location"} |
Routing call outside Africa (or to a point not connected to the road network) |
| 400 | {"detail":"validation error..."} |
Missing or invalid query param / body field |
| 404 | {"detail":"Not Found"} |
Wrong path. Check the spelling — /api/v2/... |
| 429 | {"detail":"rate limit exceeded"} |
See Rate limits — back off and retry with jitter |
| 500 | {"error":"<exception>: <message>"} |
Backend error. Log the body, alert ops |
| 502 / 503 | empty or HTML | maps_api proxy lost contact with geo_mcp or valhalla. Retry once, then alert |
Rate limits & caching
maps_apienforces 60 requests / minute / IP by default (slowapi) — set higher per-app via env if needed- Response cache (Redis, in-cluster): 24h TTL on geocode/reverse/search (place data rarely changes), 1h on routes (network conditions don't change but graph rebuilds may)
discover_questis NOT cached (different POI candidate sets each call by design)- Always send a
Cache-Control: max-age=Nheader from the client if you want shorter TTLs (e.g., live location apps)
Versioning policy
/api/v2/is the current major version. Adding optional fields or new endpoints is non-breaking.- Removing a field, renaming a field, or changing a field's type is a breaking change → new
/api/v3/. - MCP tools follow the same discipline. Tool removals or arg-rename go through a deprecation cycle of at least one minor version.