Maimaps API · v1 · Beta

Maimaps API

Map display, place search, routing, reverse geocoding, point details, and loccode resolution for Nigerian apps — one API key across the REST and map-display surfaces.

Version

/sdk/v1

Protocol

REST / JSON + vector tiles

Auth

API key — X-Api-Key header or ?key= param

Key formats

mk_live_… / mk_test_…

Coverage

Nigeria

Pricing

Monthly allowance, then credits

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:

Response 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. 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. 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. 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.

Request
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.

X-Api-Key header (REST)
GET /sdk/v1/search?q=garki%20market HTTP/1.1
Host: maps-staging-api.maiaddy.com
X-Api-Key: mk_test_your_key
?key= query param (map resources)
# 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_key

TEST vs LIVE

TEST keys behave identically but carry roughly 10% of LIVE monthly limits, and usage is recorded with 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 covers maneuver.location, waypoints[].location, and every GeoJSON coordinates array.
  • decodePolyline() returns [lat, lng] pairs, but <RoutePolyline /> and <RouteShape /> expect [lng, lat]. Flip before you render.
The flip
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/fonts on 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.

DimensionRate limit (per key)Monthly allowance
Map loads10/min10,000
Tiles600/minnever billed; 150,000/day soft ceiling
Search10 rps, burst 202,000
Routing5 rps, burst 101,000
Reverse geocode10 rps, burst 202,000
Point details10 rps, burst 202,000
Loccode10 rps, burst 20never billed
Place categories10 rps, burst 20never billed
  • 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, and X-RateLimit-Reset, plus Retry-After on a 429.

Errors

Failures return the same envelope with status: false and a machine-readable code:

Error body
{
  "status": false,
  "code": "rate_limited",
  "message": "Per-key rate limit hit. Retry after 2 seconds."
}
HTTPCodeFromMeaning
400INVALID_PARAMSServiceMissing or malformed origin/destination, or a bad loccode format.
400VALIDATION_ERRORServiceRequest body failed validation. The message lists the offending fields.
401invalid_api_keyGatewayMissing, malformed, revoked, or wrong-product key.
403restriction_violationGateway(Post-GA) origin or bundle-ID restriction failed.
404NOT_FOUNDServiceLoccode did not resolve, or no route/point exists for the input.
422ROUTING_FAILEDServiceThe engine ran but could not compute a route between these points.
429rate_limitedGatewayPer-key rate limit hit. Honor Retry-After.
429quota_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.
500INTERNAL_ERRORServiceUnhandled server error. Details are never leaked to the caller.
502ENGINE_UNAVAILABLEServiceMap engine unreachable, or a loccode origin/destination could not be resolved.
503auth_unavailableGatewayKey validation backend unreachable and the key is not in the stale cache.

Two code vocabularies reach you

Codes raised at the gateway (auth, rate limits, quota) are 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.

GET

Route

/sdk/v1/route

Compute 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_latrequired
number
WGS-84 latitude of the origin.
origin_lngrequired
number
WGS-84 longitude of the origin.
dest_latrequired
number
WGS-84 latitude of the destination.
dest_lngrequired
number
WGS-84 longitude of the destination.
modeoptional
string
Travel profile. drive (default) or walk.
Request
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

200 OK
Success
{
  "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

The 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.
POST

Route with waypoints

/sdk/v1/route

The 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_lngoptional
number
Origin coordinates. Omit if using origin_loccode.
origin_loccodeoptional
string
Origin as a loccode, for example FC2F 3KN. Resolves to the street midpoint.
dest_lat / dest_lngoptional
number
Destination coordinates. Omit if using dest_loccode.
dest_loccodeoptional
string
Destination as a loccode.
waypointsoptional
array
Ordered intermediate stops. Each is { order, latitude, longitude } and is visited in ascending order. Note these three fields are camelCase even though the enclosing body is snake_case.
modeoptional
string
Travel profile. drive (default) or walk.
Request
// 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

200 OK
Success
{
  "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" }
    ]
  }
}
GET

Reverse geocode

/sdk/v1/reverse-geocode

Turn 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

latituderequired
number
WGS-84 latitude in decimal degrees.
longituderequired
number
WGS-84 longitude in decimal degrees.
Request
const place = await client.reverseGeocode({
  latitude: 9.0579,
  longitude: 7.4951,
});

console.log(place.address, place.loccode);

Example response

200 OK
Success
{
  "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
  }
}
GET

Point details

/sdk/v1/point-details

The “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

latituderequired
number
WGS-84 latitude of the tapped point.
longituderequired
number
WGS-84 longitude of the tapped point.
osm_idoptional
string
Type-prefixed OSM id from a search result, for example W123456789. Pins the lookup to that feature.
osm_typeoptional
string
OSM element type: n (node), w (way), or r (relation).
search_nameoptional
string
Echoed back verbatim in the response. Use it to keep the label the user tapped consistent with what you render.
search_addressoptional
string
Echoed back verbatim, same purpose as search_name.
Request
// 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

200 OK
Success
{
  "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"
    }
  }
}
GET

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

coderequired
string
The loccode, for example FC2F 3KN. URL-encode the space as %20.
Request
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

200 OK
Success
{
  "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 }
  }
}
GET

Nearest loccode

/sdk/v1/loccodes/nearest

Find 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

latituderequired
number
WGS-84 latitude in decimal degrees.
longituderequired
number
WGS-84 longitude in decimal degrees.
Request
const nearest = await client.loccode.nearest({
  latitude: 9.0579,
  longitude: 7.4951,
});

console.log(nearest.formatted); // "FC2F 3KN"

Example response

200 OK
Success
{
  "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 }
  }
}
GET

Place categories

/sdk/v1/place-categories

The 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.

Request
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

200 OK
Success
{
  "status": true,
  "message": "",
  "data": [
    { "key": "market", "label": "Market", "status": "active" },
    { "key": "pharmacy", "label": "Pharmacy", "parentKey": "health", "status": "active" },
    { "key": "school", "label": "School", "status": "active" }
  ]
}
GET

Map display

/sdk/styles · /sdk/tiles · /sdk/sprites · /sdk/fonts

Map 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.

PathPurpose
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.
Request
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.