Overview
The Maimaps API powers full map experiences: render vector maps, search places and addresses, compute routes, reverse-geocode coordinates, resolve tapped points, and look up Maiaddy loccodes — all authenticated with a single MAIMAPS API key.
Every successful REST response uses a stable envelope:
{
"status": true,
"message": "",
"data": { ... }
}status is a JSON boolean, not a string. /sdk/v1 is the stable contract: breaking changes require /sdk/v2, and additive fields are non-breaking, so parse defensively and ignore unknown keys.
Quickstart
From zero to a rendered map with your first search and route in three steps.
- 1
Create a MAIMAPS key
In the console, create an API key for the Maimaps product. TEST keys (mk_test_…) bill from a separate test balance; LIVE keys (mk_live_…) carry the monthly allowance.
- 2
Install an SDK
Official SDKs for JavaScript, React, React Native, and Flutter wrap every endpoint, inject your key on both hosts, and provide typed errors and retries.
- 3
Render a map, then search and route
Load the keyed style.json into MapLibre (one map load per fetch), then call search and route for your first results.
Replace mk_test_your_key with a key from the console.
import { MaimapsClient } from "@maimaps/js";
import maplibregl from "maplibre-gl";
const client = new MaimapsClient({ apiKey: "mk_test_your_key" });
// 1. Render a map (each keyed style.json fetch = one map load)
const map = new maplibregl.Map({
container: "map",
style: client.styleUrl({ mode: "dark" }),
transformRequest: (url) => ({ url: client.transformMapResource(url) }),
});
// 2. First search
const results = await client.search({
q: "garki market",
latitude: 9.0579,
longitude: 7.4951,
});
// 3. First route (OSRM-shaped: routes[].distance|duration|geometry|legs)
const trip = await client.route({
originLat: 9.0579,
originLng: 7.4951,
destLat: 9.0765,
destLng: 7.3986,
mode: "drive",
});Authentication
Keys are minted in this portal for the MAIMAPS product: mk_live_… for LIVE and mk_test_… for TEST. Pass the key with every request, either as the X-Api-Key header (preferred for REST) or as a ?key= query parameter (required for map resources, which cannot set headers portably). If both are present, the header wins.
GET /sdk/v1/search?q=garki%20market HTTP/1.1
Host: maps-staging-api.maiaddy.com
X-Api-Key: mk_test_your_key# Map resources can't set headers portably — pass the key as a query param.
GET /sdk/styles/maimaps/style.json?mode=dark&key=mk_test_your_keyTEST vs LIVE
environment=TEST. Use TEST keys for development and CI, then swap the key string at launch. Nothing else in your code changes.Keys are not secrets — but they are quota-bearing
Maimaps keys are embedded in client apps by design. Treat them like quota handles, not credentials: rotate from the console if abused. Origin and bundle-ID restrictions arrive at GA hardening.
Loccodes
A loccode is Maiaddy's address primitive, and the single thing most likely to surprise you: a loccode identifies a street, not a point. Resolving one returns a GeoJSON LineString for the whole street, plus a computed representative_point at its midpoint. When you route to a loccode, the backend routes to that midpoint.
The wire format is XXXX YYY: four characters, a space, three characters, for example FC2F 3KN. Anything else is rejected with 400 INVALID_PARAMS. Note that resolve echoes the unspaced key back in loccode and the display form in formatted.
Where loccodes show up
Search results carry a loccode field, reverse-geocode returns the loccode for the coordinate, route steps are enriched with the loccode of the street they run along, and both route endpoints accept a loccode in place of coordinates.
Coordinates & units
Requests are lat/lng. Responses are lng/lat.
This is the bug every integrator hits once. It is not a quirk of ours: request parameters follow the human convention, while response geometry follows GeoJSON and OSRM.
- Requests always take latitude before longitude:
latitude/longitude,origin_lat/origin_lng. - Response geometry is
[longitude, latitude]. That coversmaneuver.location,waypoints[].location, and every GeoJSONcoordinatesarray. decodePolyline()returns[lat, lng]pairs, but<RoutePolyline />and<RouteShape />expect[lng, lat]. Flip before you render.
import { decodePolyline } from "@maimaps/js";
const route = await client.route({
originLat: 9.0579,
originLng: 7.4951,
destLat: 9.0765,
destLng: 7.3986,
});
// decodePolyline returns [latitude, longitude] pairs...
const latLng = decodePolyline(route.routes[0].geometry);
// ...but <RoutePolyline /> and <RouteShape /> want [longitude, latitude].
const lngLat = latLng.map(([lat, lng]) => [lng, lat] as const);Units are consistent across the API: distances in meters, durations in seconds, bearings in degrees. The one exception is search results, where distance_km is kilometers, as the name says. Encoded polylines use precision 5.
Field casing is not uniform — parse, don't assume
Most response bodies are snake_case, but the route response is not. A route carries shareUrl while point details carries share_url, and inside a route step the maneuver is snake_case (bearing_before) while its parent is not. Likewise, waypoints[] in the route POST body is camelCase (order, latitude, longitude) even though the rest of that body is snake_case. The SDKs normalize all of this to camelCase for you; if you call the REST API directly, copy the sample bodies below rather than guessing.
Two hosts, one key
One key, two hosts
/sdk/v1/*on the API gateway — REST endpoints for search, routing, geocoding, point details, and loccodes./sdk/styles · /sdk/tiles · /sdk/sprites · /sdk/fontson the map host — style documents, vector tiles, sprites, and glyphs for display.
The SDKs hide the split behind one options object: environment: 'staging' | 'production' selects baked-in host defaults. Production hostnames are published before GA; until then, production requires explicit hosts.
Rate limits & quotas
Two separate things apply. Per-key rate limits cap how fast you can call, and a monthly allowance covers a number of requests before they start costing credits. Requests beyond the allowance are not blocked; they bill from your wallet at the published Maimaps rates.
- Tiles are never billed. The map load is the display unit, and tile limits exist purely as an abuse guard.
- The allowance belongs to your account, not to a single key, so issuing more keys does not multiply it. It renews at the start of each month and does not roll over.
- Allowances apply to LIVE keys. TEST keys bill from a separate test balance, priced the same, so you can rehearse the billing path before going live.
- Per-second limits are enforced at the edge. The allowance is consumed at validation time, so a request rejected for auth, rate limiting or entitlement never consumes it.
- Every limited route returns
X-RateLimit-Limit,X-RateLimit-Remaining, andX-RateLimit-Reset, plusRetry-Afteron a 429.
Errors
Failures return the same envelope with status: false and a machine-readable code:
{
"status": false,
"code": "rate_limited",
"message": "Per-key rate limit hit. Retry after 2 seconds."
}INVALID_PARAMSServiceMissing or malformed origin/destination, or a bad loccode format.VALIDATION_ERRORServiceRequest body failed validation. The message lists the offending fields.invalid_api_keyGatewayMissing, malformed, revoked, or wrong-product key.restriction_violationGateway(Post-GA) origin or bundle-ID restriction failed.NOT_FOUNDServiceLoccode did not resolve, or no route/point exists for the input.ROUTING_FAILEDServiceThe engine ran but could not compute a route between these points.rate_limitedGatewayPer-key rate limit hit. Honor Retry-After.quota_exceededGatewayA usage cap set on the key has been reached. Running out of the monthly allowance does not raise this; those requests bill from credits instead.INTERNAL_ERRORServiceUnhandled server error. Details are never leaked to the caller.ENGINE_UNAVAILABLEServiceMap engine unreachable, or a loccode origin/destination could not be resolved.auth_unavailableGatewayKey validation backend unreachable and the key is not in the stale cache.Two code vocabularies reach you
lower_snake_case. Codes raised by the navigation service behind it (validation, routing, upstream failures) are UPPER_SNAKE_CASE. Both come back through /sdk/v1 in the same envelope, so if you switch on code, handle both casings. The SDKs map the gateway codes to typed errors and surface the rest as MaimapsError with code and httpStatus intact.The SDKs raise typed errors (InvalidApiKeyError, RateLimitError carrying retryAfterSeconds, QuotaExceededError, ServerError, NetworkError) and automatically retry idempotent GETs only, honoring Retry-After. A POST is never retried for you.
Search
/sdk/v1/searchFree-text search across places, addresses, POIs, and loccodes. Passing a latitude and longitude biases results toward the user and populates distance_km on each hit. Coverage is Nigeria only.
Query parameters
qrequiredlatitudeoptional0 and disables proximity ranking.longitudeoptionallimitoptionalbboxoptionaltypeoptionalpoi, address, street, or loccode.categoryoptionalmarket.const results = await client.search({
q: "garki market",
latitude: 9.0579, // bias results toward the user
longitude: 7.4951,
limit: 5,
category: "market",
});
for (const r of results) {
console.log(r.name, r.loccode, r.distanceKm);
}Example response
{
"status": true,
"message": "",
"data": {
"query": "garki market",
"total": 2,
"results": [
{
"id": "W123456789",
"name": "Garki Modern Market",
"result_type": "poi",
"score": 0.94,
"latitude": 9.0331,
"longitude": 7.4899,
"address": "Garki, Abuja, FCT",
"loccode": "FC2F 3KN",
"maiaddy_id": "MA-FCT-0018422",
"entity_type": "business",
"category": "market",
"rating": 4.2,
"distance_km": 2.9,
"osm_id": "W123456789",
"osm_type": "w"
},
{
"id": "FC2F3KN",
"name": "Moshood Abiola Way",
"result_type": "loccode",
"score": 0.71,
"latitude": 9.0345,
"longitude": 7.4901,
"loccode": "FC2F 3KN",
"entity_type": "loccode",
"distance_km": 3.1,
"geometry": {
"type": "LineString",
"coordinates": [[7.4890, 9.0340], [7.4912, 9.0351]]
}
}
]
}
}Prefer maiaddy_id over osm_id
maiaddy_id is the canonical, stable key for a place. osm_id is type-prefixed (for example W123456789) and can change when upstream OpenStreetMap data is re-imported. Results of type loccode carry a geometry LineString; other types do not.
Route
/sdk/v1/routeCompute a route between two coordinates. The response is OSRM-shaped: routes[] each carry distance (meters), duration (seconds), geometry (an encoded polyline, precision 5), and legs[] with turn-by-turn steps. Steps are enriched with the loccode of the street they follow.
For waypoints or loccode endpoints, use the POST form instead.
Query parameters
origin_latrequiredorigin_lngrequireddest_latrequireddest_lngrequiredmodeoptionaldrive (default) or walk.const result = await client.route({
originLat: 9.0579,
originLng: 7.4951,
destLat: 9.0765,
destLng: 7.3986,
mode: "drive",
});
const [best] = result.routes;
console.log(best.distance, "m", best.duration, "s");Example response
{
"status": true,
"message": "",
"data": {
"shareUrl": "https://maps.maiaddy.com/r_Qw7nL4pR2x",
"code": "Ok",
"routes": [
{
"distance": 12840.6,
"duration": 1187.3,
"geometry": "yv|xAo}{_Ln@qJvBcOhAyH…",
"legs": [
{
"distance": 12840.6,
"duration": 1187.3,
"summary": "Moshood Abiola Way, Ahmadu Bello Way",
"steps": [
{
"distance": 412.0,
"duration": 51.2,
"name": "Moshood Abiola Way",
"mode": "driving",
"loccode": "FC2F 3KN",
"maneuver": {
"location": [7.4951, 9.0579],
"bearing_before": 0,
"bearing_after": 78,
"type": "depart",
"modifier": "straight"
}
}
]
}
]
}
],
"waypoints": [
{ "location": [7.4951, 9.0579], "name": "Moshood Abiola Way" },
{ "location": [7.3986, 9.0765], "name": "Ahmadu Bello Way" }
]
}
}code: “Ok” is the engine's, not the envelope's
data.codefield is OSRM's status string and reads "Ok" on success. It is not the error code from the envelope, which only appears when status is false. If the engine runs but cannot find a path you get 422 ROUTING_FAILED; if the engine is unreachable you get 502 ENGINE_UNAVAILABLE.Route with waypoints
/sdk/v1/routeThe POST form adds ordered waypoints and lets you use a loccode in place of coordinates at either end. Give each endpoint either a coordinate pair or a loccode; supply neither and you get 400 INVALID_PARAMS. A loccode endpoint resolves to its street midpoint.
Request body
origin_lat / origin_lngoptionalorigin_loccodeoptionalFC2F 3KN. Resolves to the street midpoint.dest_lat / dest_lngoptionaldest_loccodeoptionalwaypointsoptional{ order, latitude, longitude } and is visited in ascending order. Note these three fields are camelCase even though the enclosing body is snake_case.modeoptionaldrive (default) or walk.// routeAdvanced() posts the body form: loccode endpoints + ordered waypoints.
const result = await client.routeAdvanced({
originLoccode: "FC2F 3KN",
destLat: 9.0765,
destLng: 7.3986,
waypoints: [
{ order: 1, latitude: 9.0611, longitude: 7.4702 },
{ order: 2, latitude: 9.0688, longitude: 7.4310 },
],
mode: "drive",
});Example response
{
"status": true,
"message": "",
"data": {
"shareUrl": "https://maps.maiaddy.com/r_Qw7nL4pR2x",
"code": "Ok",
"routes": [
{
"distance": 12840.6,
"duration": 1187.3,
"geometry": "yv|xAo}{_Ln@qJvBcOhAyH…",
"legs": [
{
"distance": 12840.6,
"duration": 1187.3,
"summary": "Moshood Abiola Way, Ahmadu Bello Way",
"steps": [
{
"distance": 412.0,
"duration": 51.2,
"name": "Moshood Abiola Way",
"mode": "driving",
"loccode": "FC2F 3KN",
"maneuver": {
"location": [7.4951, 9.0579],
"bearing_before": 0,
"bearing_after": 78,
"type": "depart",
"modifier": "straight"
}
}
]
}
]
}
],
"waypoints": [
{ "location": [7.4951, 9.0579], "name": "Moshood Abiola Way" },
{ "location": [7.3986, 9.0765], "name": "Ahmadu Bello Way" }
]
}
}Reverse geocode
/sdk/v1/reverse-geocodeTurn a coordinate into a human address plus the loccode of the street it sits on. Use this for a GPS fix or a map tap where you only need the address. If you also need the underlying place, its category, and a share link, use point details instead.
Query parameters
latituderequiredlongituderequiredconst place = await client.reverseGeocode({
latitude: 9.0579,
longitude: 7.4951,
});
console.log(place.address, place.loccode);Example response
{
"status": true,
"message": "",
"data": {
"name": "Moshood Abiola Way",
"address": "Moshood Abiola Way, Garki, Abuja, FCT",
"street": "Moshood Abiola Way",
"house_number": "14",
"city": "Abuja",
"state": "FCT",
"country": "Nigeria",
"loccode": "FC2F 3KN",
"latitude": 9.0579,
"longitude": 7.4951
}
}Point details
/sdk/v1/point-detailsThe “what's here?” endpoint: resolve a tapped point, a long-press, or a dropped pin into a full place record plus its loccode and a shareable link. When you already have an osm_id from a search result, pass it along with osm_type to pin the lookup to that exact feature rather than whatever is nearest.
Query parameters
latituderequiredlongituderequiredosm_idoptionalW123456789. Pins the lookup to that feature.osm_typeoptionaln (node), w (way), or r (relation).search_nameoptionalsearch_addressoptional// Resolve a long-press / dropped pin into a place + loccode.
const details = await client.pointDetails({
latitude: 9.0579,
longitude: 7.4951,
osmId: "W123456789", // optional — from a search result
osmType: "w",
});
console.log(details.place.name, details.loccode?.loccode);Example response
{
"status": true,
"message": "",
"data": {
"latitude": 9.0579,
"longitude": 7.4951,
"search_name": null,
"search_address": null,
"share_url": "https://maps.maiaddy.com/p_xK9mP2aB3k",
"loccode": {
"loccode": "FC2F 3KN",
"street_name": "Moshood Abiola Way",
"highway_type": "primary",
"street_length": 1840.2,
"state": "FCT",
"lga": "Abuja Municipal",
"country": "NG",
"geometry": {
"type": "LineString",
"coordinates": [[7.4890, 9.0340], [7.4912, 9.0351]]
},
"representative_point": { "latitude": 9.0345, "longitude": 7.4901 }
},
"place": {
"name": "Garki Modern Market",
"result_type": "poi",
"maiaddy_id": "MA-FCT-0018422",
"entity_type": "business",
"loccode": "FC2F 3KN",
"osm_id": "W123456789",
"latitude": 9.0331,
"longitude": 7.4899,
"address": "Garki, Abuja, FCT",
"street": "Moshood Abiola Way",
"city": "Abuja",
"state": "FCT",
"country": "Nigeria",
"country_code": "NG",
"category": "market",
"type": "marketplace"
}
}
}Resolve a loccode
/sdk/v1/loccode/{code}Expand a loccode into its street geometry and metadata. Remember that a loccode is a street: you get the full LineString plus a representative_point midpoint to pin or route to. An unknown or malformed code returns 404 NOT_FOUND.
Path parameters
coderequiredFC2F 3KN. URL-encode the space as %20.const loccode = await client.loccode.resolve("FC2F 3KN");
// A loccode is a street, not a point: geometry is a LineString and
// representativePoint is its midpoint.
console.log(loccode.streetName, loccode.representativePoint);Example response
{
"status": true,
"message": "",
"data": {
"loccode": "FC2F3KN",
"formatted": "FC2F 3KN",
"street_name": "Moshood Abiola Way",
"highway_type": "primary",
"street_length": 1840.2,
"state": "FCT",
"lga": "Abuja Municipal",
"country": "NG",
"valid": true,
"coverage": true,
"geometry": {
"type": "LineString",
"coordinates": [
[7.4890, 9.0340],
[7.4901, 9.0345],
[7.4912, 9.0351]
]
},
"representative_point": { "latitude": 9.0345, "longitude": 7.4901 }
}
}Nearest loccode
/sdk/v1/loccodes/nearestFind the loccode of the street closest to a coordinate. This is the inverse of resolve, and the fastest way to turn a GPS fix into a Maiaddy address primitive. Returns the same body as resolve, or 404 NOT_FOUND outside coverage.
Query parameters
latituderequiredlongituderequiredconst nearest = await client.loccode.nearest({
latitude: 9.0579,
longitude: 7.4951,
});
console.log(nearest.formatted); // "FC2F 3KN"Example response
{
"status": true,
"message": "",
"data": {
"loccode": "FC2F3KN",
"formatted": "FC2F 3KN",
"street_name": "Moshood Abiola Way",
"highway_type": "primary",
"street_length": 1840.2,
"state": "FCT",
"lga": "Abuja Municipal",
"country": "NG",
"valid": true,
"coverage": true,
"geometry": {
"type": "LineString",
"coordinates": [
[7.4890, 9.0340],
[7.4901, 9.0345],
[7.4912, 9.0351]
]
},
"representative_point": { "latitude": 9.0345, "longitude": 7.4901 }
}
}Place categories
/sdk/v1/place-categoriesThe category taxonomy. Fetch it once at startup and cache it: thekey values are what search accepts in its category filter, and what search results report back. This endpoint is metered for rate limiting but is never billed.
const categories = await client.placeCategories.list();
// Use a category key to filter search results.
const markets = await client.search({ q: "garki", category: "market" });Example response
{
"status": true,
"message": "",
"data": [
{ "key": "market", "label": "Market", "status": "active" },
{ "key": "pharmacy", "label": "Pharmacy", "parentKey": "health", "status": "active" },
{ "key": "school", "label": "School", "status": "active" }
]
}Map display
/sdk/styles · /sdk/tiles · /sdk/sprites · /sdk/fontsMap assets are served from the map host, not the gateway, and authenticate with the ?key= query parameter because tile and glyph requests cannot set headers portably. The style document references the /sdk/* tile, sprite, and glyph URLs; the SDKs append your key to each via transformRequest / transformMapResource.
GET /sdk/styles/{family}/style.json?mode={light|dark}&key=…Style document. Each keyed fetch counts as one map load.GET /sdk/tiles/{z}/{x}/{y}.pbf?key=…Vector tiles.GET /sdk/tiles.json?key=…TileJSON.GET /sdk/sprites/…?key=… · GET /sdk/fonts/…?key=…Sprites and glyphs.GET /sdk/wordmark.json?key=… · GET /sdk/wordmark/…?key=…Maimaps wordmark art and layout manifest. Never billed. The SDKs embed the art inline and fetch these only to pick up updates.import maplibregl from "maplibre-gl";
// styleUrl() builds the keyed style.json URL on the map host.
// transformMapResource() appends the key to tile/sprite/glyph requests.
const map = new maplibregl.Map({
container: "map",
style: client.styleUrl({ family: "maimaps", mode: "dark" }),
transformRequest: (url) => ({ url: client.transformMapResource(url) }),
});Map loads, not tiles
Each keyed style.json fetch counts as one map load, the metered display unit. Tile requests are rate-limited but never billed, so panning and zooming costs you nothing. Re-mounting a map component does cost a map load, so avoid tearing the map down and rebuilding it on every route change.
The wordmark moves, it does not hide
Every Maimaps map renders the Maimaps wordmark. You can place it with wordmarkPosition (bottom-left by default, top-left, top-right, or bottom-right), but there is no option to remove it, and removing it is a licence violation.
SDKs
Four official SDKs wrap every endpoint on this page, handle key injection across both hosts, normalize the response casing to camelCase, and provide typed errors with rate-limit-aware retries. All four are in beta, so breaking changes can land in minor versions. Check each SDK page for its current version.