Skip to content

MapsForDevs Developer Guide

The single entry point for building with MapsForDevs. Skim the table, jump to the section that matches your stack.

Status: Tile API + Downloads are GA. Geocoding (forward + reverse) is in active build — see §9 for the contract; endpoint goes live phase 3.

SectionWhat you get
1. Auth modelAPI key types, when to use which, security rules
2. Tile APIRaw XYZ + style.json, MapLibre / Leaflet / OpenLayers init
3. Frontend frameworksReact, Vue, Svelte, Angular, vanilla
4. MobileiOS, Android, React Native, Flutter
5. Server-side proxyHide keys + cache: Node, Python, Go, Rails, Laravel, .NET
6. Downloads APIBuy → license token → fetch .mfdmap → MapLibre offline
7. Embed widgetCopy-paste iframe + JS API
8. 80+ language labelsRuntime locale switch with one expression
9. GeocodingForward + reverse address lookup (phase 3)
10. Errors + rate limits401 / 402 / 429 codes, retry-after, quota headers
11. Migrating from competitorsDrop-in style.json + key swap

Per-language quickstarts: docs/integrations/ — JavaScript, TypeScript, Python, Go, Rust, Swift, Kotlin, PHP, Ruby, Java, C#, Dart. Per-framework quickstarts: docs/frameworks/ — React, Vue, Angular, Svelte, Next.js, Nuxt. Mobile quickstarts: docs/mobile/ — iOS, Android, React Native, Flutter.

Endpoints (production):

ServiceBase URL
Tileshttps://tiles.mapsfordevs.com
Styleshttps://api.mapsfordevs.com/styles
Auth + accounthttps://api.mapsfordevs.com/auth
Downloadshttps://api.mapsfordevs.com/downloads
Embedshttps://api.mapsfordevs.com/embeds
Geocoding (phase 3)https://api.mapsfordevs.com/geocode

1. Auth model

We issue two key types. Use the one that matches where the key lives.

Key prefixWhere it livesWhat we checkUse for
mfd_pub_…Browser, mobile app, public sourceHTTP Referer (web) or app bundle ID (native) matches an allow-list you set in the dashboardTile requests from a webpage or mobile app
mfd_srv_…Backend server onlySource IP matches an allow-list and the request is signed with the key's secretServer-side rendering, batch geocoding, downloads, anything that issues tile URLs to your users

Rules:

  • A mfd_pub_* key in a browser is fine — that is what it is for.
  • A mfd_srv_* key in a browser is a leak. Rotate immediately if exposed.
  • Domain restrictions are mandatory on every public key. A key with no Referer allow-list is rejected.
  • Keys are shown once at creation. Only the first 12 chars are stored plaintext for identification; the rest is bcrypt-hashed. Store yours in a secret manager.
  • Rotate via the dashboard. Old key keeps working for 24h grace; after that, dead.

Secret signing for mfd_srv_*:

http
GET /tiles/10/512/512.pbf HTTP/1.1
Host: tiles.mapsfordevs.com
Authorization: Bearer mfd_srv_AbC123...
X-MFD-Timestamp: 1730000000
X-MFD-Signature: hex(hmac_sha256(secret, "GET\n/tiles/10/512/512.pbf\n1730000000"))

Timestamp must be within ±5 min of server clock. Replay window is 60s after that.

js
import { createHmac } from 'node:crypto';
function signMFD(method, path, secret) {
  const ts = Math.floor(Date.now() / 1000);
  const sig = createHmac('sha256', secret)
    .update(`${method}\n${path}\n${ts}`)
    .digest('hex');
  return { 'X-MFD-Timestamp': String(ts), 'X-MFD-Signature': sig };
}

2. Tile API

Two ways to consume tiles. Pick one.

We host pre-built styles. Hand the URL to MapLibre and you are done.

js
import maplibregl from 'maplibre-gl';
const map = new maplibregl.Map({
  container: 'map',
  style: `https://api.mapsfordevs.com/styles/standard.json?key=${PUB_KEY}`,
  center: [28.04, -26.20],
  zoom: 11
});

Available styles: standard, light, dark, satellite-hybrid, outdoor (phase 2), nautical (phase 2).

2b. Raw vector XYZ

For your own style.json or non-MapLibre clients.

GET https://tiles.mapsfordevs.com/tiles/{z}/{x}/{y}.pbf?key=mfd_pub_…

Schema: MFD_standard — based on OpenMapTiles, layer names are stable. Full layer reference: docs/recipes/schema.md.

js
const map = new maplibregl.Map({
  container: 'map',
  style: {
    version: 8,
    sources: {
      mfd: {
        type: 'vector',
        tiles: [`https://tiles.mapsfordevs.com/tiles/{z}/{x}/{y}.pbf?key=${PUB_KEY}`],
        minzoom: 0,
        maxzoom: 17,
        attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> · © MapsForDevs'
      }
    },
    layers: [ /* your layer definitions */ ]
  }
});

Attribution lives in the style, not in the tiles. You must show it. See docs/recipes/attribution.md.

Leaflet (raster fallback, phase 2)

js
L.tileLayer('https://tiles.mapsfordevs.com/raster/{z}/{x}/{y}.png?key=' + PUB_KEY, {
  maxZoom: 17,
  attribution: '© OpenStreetMap · © MapsForDevs'
}).addTo(map);

OpenLayers

js
import { Map, View } from 'ol';
import VectorTileLayer from 'ol/layer/VectorTile';
import VectorTileSource from 'ol/source/VectorTile';
import MVT from 'ol/format/MVT';

new Map({
  target: 'map',
  view: new View({ center: [0, 0], zoom: 2 }),
  layers: [
    new VectorTileLayer({
      source: new VectorTileSource({
        format: new MVT(),
        url: `https://tiles.mapsfordevs.com/tiles/{z}/{x}/{y}.pbf?key=${PUB_KEY}`,
        maxZoom: 17
      })
    })
  ]
});

3. Frontend frameworks

Full quickstarts in docs/frameworks/. Below is the canonical pattern — every framework wraps it the same way.

text
┌──────────────────────────────────────────┐
│ env (Vite / Next / Nuxt)                 │
│   VITE_MFD_PUB_KEY=mfd_pub_…             │
└─────────┬────────────────────────────────┘

┌──────────────────────────────────────────┐
│ component                                │
│   onMount → new maplibregl.Map({...})    │
│   onUnmount → map.remove()               │
└──────────────────────────────────────────┘

React

jsx
import { useEffect, useRef } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';

export default function MapView() {
  const ref = useRef(null);
  useEffect(() => {
    const map = new maplibregl.Map({
      container: ref.current,
      style: `https://api.mapsfordevs.com/styles/standard.json?key=${import.meta.env.VITE_MFD_PUB_KEY}`,
      center: [28.04, -26.20], zoom: 11
    });
    return () => map.remove();
  }, []);
  return <div ref={ref} style={{ width: '100%', height: '100vh' }} />;
}

Vue 3

vue
<script setup>
import { onMounted, onUnmounted, ref } from 'vue';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';

const el = ref(null);
let map;
onMounted(() => {
  map = new maplibregl.Map({
    container: el.value,
    style: `https://api.mapsfordevs.com/styles/standard.json?key=${import.meta.env.VITE_MFD_PUB_KEY}`,
    center: [28.04, -26.20], zoom: 11
  });
});
onUnmounted(() => map?.remove());
</script>
<template><div ref="el" class="w-full h-screen" /></template>

Svelte 5

svelte
<script>
import { onMount } from 'svelte';
import maplibregl from 'maplibre-gl';
let el; let map;
onMount(() => {
  map = new maplibregl.Map({
    container: el,
    style: `https://api.mapsfordevs.com/styles/standard.json?key=${import.meta.env.VITE_MFD_PUB_KEY}`,
    center: [28.04, -26.20], zoom: 11
  });
  return () => map.remove();
});
</script>
<div bind:this={el} class="w-full h-screen"></div>

Angular 17+

ts
import { Component, ElementRef, ViewChild, AfterViewInit, OnDestroy } from '@angular/core';
import maplibregl from 'maplibre-gl';

@Component({
  selector: 'app-map',
  template: `<div #map class="w-full h-screen"></div>`
})
export class MapComponent implements AfterViewInit, OnDestroy {
  @ViewChild('map') el!: ElementRef;
  private map?: maplibregl.Map;
  ngAfterViewInit() {
    this.map = new maplibregl.Map({
      container: this.el.nativeElement,
      style: `https://api.mapsfordevs.com/styles/standard.json?key=${import.meta.env['NG_APP_MFD_PUB_KEY']}`,
      center: [28.04, -26.20], zoom: 11
    });
  }
  ngOnDestroy() { this.map?.remove(); }
}

Vanilla

html
<link href="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.css" rel="stylesheet">
<script src="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.js"></script>
<div id="map" style="width:100%;height:100vh"></div>
<script>
  new maplibregl.Map({
    container: 'map',
    style: 'https://api.mapsfordevs.com/styles/standard.json?key=mfd_pub_xxx',
    center: [28.04, -26.20], zoom: 11
  });
</script>

4. Mobile

Full quickstarts: docs/mobile/ios.md, docs/mobile/android.md, docs/mobile/react-native.md, docs/mobile/flutter.md.

PlatformLibraryOne-liner
iOSMapLibre Native iOSMLNMapView(frame: ..., styleURL: URL(string: STYLE_URL))
AndroidMapLibre Native AndroidmapView.getMapAsync { it.setStyle(STYLE_URL) }
React Native@maplibre/maplibre-react-native<MapLibreGL.MapView styleURL={STYLE_URL} />
Fluttermaplibre_glMapLibreMap(styleString: STYLE_URL)

For native iOS/Android, register your bundle ID / package name in the dashboard against the public key. We validate the platform's app-attest token (iOS) or Play Integrity (Android) when present, falling back to bundle/package check.


5. Server-side proxy

Two reasons to proxy through your backend:

  1. Hide keys — never put mfd_srv_* in a browser. If you want full control without exposing any key, proxy.
  2. Cache — pay us once for a tile, serve it from your CDN forever (within license terms).

Pattern (Node/Express):

js
import express from 'express';
import fetch from 'node-fetch';
import { createHmac } from 'node:crypto';

const app = express();
const KEY = process.env.MFD_SRV_KEY;
const SECRET = process.env.MFD_SRV_SECRET;

app.get('/tiles/:z/:x/:y.pbf', async (req, res) => {
  const path = `/tiles/${req.params.z}/${req.params.x}/${req.params.y}.pbf`;
  const ts = Math.floor(Date.now() / 1000);
  const sig = createHmac('sha256', SECRET).update(`GET\n${path}\n${ts}`).digest('hex');
  const upstream = await fetch(`https://tiles.mapsfordevs.com${path}`, {
    headers: { Authorization: `Bearer ${KEY}`, 'X-MFD-Timestamp': String(ts), 'X-MFD-Signature': sig }
  });
  res.set('Cache-Control', 'public, max-age=86400');
  res.set('Content-Type', 'application/x-protobuf');
  upstream.body.pipe(res);
});

app.listen(8080);

Equivalents for Python / Go / Rails / Laravel / .NET: docs/server/.

Caching contract: tiles are licensed for 90 days from fetch. After that, refetch. Do not redistribute tiles to non-licensed users.


6. Downloads API

Sell offline maps from your app, or use them yourself.

Flow:

text
buyer ── POST /api/downloads/purchase ─▶ MFD ─▶ { downloadId, licenseToken (15-min JWT) }
buyer ── GET  /api/downloads/{token}   ─▶ MFD ─▶ { url: signed-R2-url, expiresAt }
buyer ── GET  signed-R2-url            ─▶ R2  ─▶ stream of country.mfdmap
buyer  uses libmfdmap  ─────────────────▶  decrypt with token  ─▶  PMTiles

.mfdmap = encrypted PMTiles, AES-256-GCM with key derived from license token. The token is one-shot — once you redeem the URL, it is dead.

ts
const purchase = await fetch('https://api.mapsfordevs.com/downloads/purchase', {
  method: 'POST',
  headers: { Authorization: `Bearer ${PUB_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ countrySlug: 'south-africa', plan: 'perpetual' })
}).then(r => r.json());
// { ok: true, downloadId, licenseToken }

const dl = await fetch(`https://api.mapsfordevs.com/downloads/${purchase.licenseToken}`)
  .then(r => r.json());
// { ok: true, url, expiresAt }

await fetch(dl.url).then(r => r.arrayBuffer()).then(buf => writeFile('za.mfdmap', Buffer.from(buf)));

In MapLibre, point pmtiles:// at the decrypted file. Full client integration: docs/recipes/offline.md.


7. Embed widget

Free signup-less iframe — pin + label + description. No API key needed; rate-limited per IP.

html
<iframe
  src="https://embed.mapsfordevs.com/v1?lat=-26.2&lng=28.04&zoom=14&label=Office&desc=Open+9-17"
  width="600" height="400"
  style="border:0;border-radius:8px"
  loading="lazy"
  allowfullscreen></iframe>
ParamRequiredNotes
lat, lngyesWGS84
zoomnodefault 13
labelnoURL-encoded, max 80 chars
descnoURL-encoded, max 200 chars, plain text only
stylenolight | dark | standard (default)
langnoBCP-47, e.g. de, zh-Hant (see §8)

Save & share via dashboard → get a stable embeds/{id} URL with editable content.

JS API for postMessage events:

js
window.addEventListener('message', e => {
  if (e.origin !== 'https://embed.mapsfordevs.com') return;
  // e.data: { type: 'mfd-ready' | 'mfd-click' | 'mfd-move', ... }
});

8. Language labels

We ship 80+ languages on the map text. Switch at runtime with one MapLibre expression. No tile rebuild required.

js
function setLang(map, lang) {
  ['place_label', 'transportation_name', 'poi_label'].forEach(layer => {
    map.setLayoutProperty(layer, 'text-field', [
      'coalesce',
      ['get', `name:${lang}`],
      ['get', 'name:latin'],
      ['get', 'name']
    ]);
  });
}
setLang(map, 'de');   // Deutsch
setLang(map, 'ja');   // 日本語
setLang(map, 'ar');   // العربية

Available codes: en, de, fr, es, pt, it, nl, sv, da, no, fi, pl, ru, uk, cs, sk, hu, ro, bg, el, tr, ar, fa, he, hi, ja, ko, zh, zh-Hans, zh-Hant, th, vi, id, ms, … — full list: docs/recipes/languages.md.

name:latin is the universal romanized fallback. name is the local name as tagged in OSM.


9. Geocoding

Status: phase 3, contract frozen so you can build against it. Reverse goes live first; forward shortly after.

9a. Forward — address → coordinates

http
GET /geocode/forward?q=80+Sturdee+Ave+Rosebank&country=za&limit=5
Authorization: Bearer mfd_pub_…
json
{
  "ok": true,
  "items": [
    {
      "lat": -26.144,
      "lng": 28.043,
      "label": "80 Sturdee Avenue, Rosebank, Johannesburg, 2196, South Africa",
      "components": { "house_number": "80", "road": "Sturdee Avenue", "suburb": "Rosebank", "city": "Johannesburg", "postcode": "2196", "country_code": "za" },
      "confidence": 0.94,
      "type": "house"
    }
  ]
}

9b. Reverse — coordinates → address

http
GET /geocode/reverse?lat=-26.144&lng=28.043&zoom=18
Authorization: Bearer mfd_pub_…

Same response shape, single best match by default (limit=1).

Quotas

Free tier: 2 500 geocoding calls / month. Paid: $0.50 per 1 000 forward, $0.25 per 1 000 reverse. Stricter rate limits than tiles — see §10.


10. Errors + rate limits

All errors follow a single shape:

json
{ "ok": false, "error": "human readable message", "code": "rate_limited" }
HTTPcodeMeaningAction
400bad_requestValidation failedFix request, do not retry
401unauthorizedMissing / invalid keyCheck Authorization header
402quota_exceededMonthly free tier used upUpgrade plan
403referer_blockedReferer not in allow-listAdd domain in dashboard
403key_revokedKey rotated > 24 h agoUse new key
404not_foundTile / object missingTile is genuinely empty (ocean) — show nothing
429rate_limitedBursting too fastHonour Retry-After header
451region_unavailableCountry not licensedContact sales
5xxinternal_server_errorOur problemRetry with exponential backoff

Every response includes:

http
X-MFD-Quota-Limit: 1000000
X-MFD-Quota-Remaining: 998422
X-MFD-Quota-Reset: 1730419200

Use them to display dashboards or pre-empt 402s.

Retry strategy (recommended client logic):

ts
async function fetchWithRetry(url, opts, attempts = 4) {
  for (let i = 0; i < attempts; i++) {
    const r = await fetch(url, opts);
    if (r.status < 500 && r.status !== 429) return r;
    const delay = (Number(r.headers.get('Retry-After') ?? 0) || (2 ** i)) * 1000;
    await new Promise(res => setTimeout(res, delay));
  }
  throw new Error('fetch failed after retries');
}

11. Migrating from competitors

Three common starting points. All are drop-in.

From competitor A (Mapbox-style URL)

WasBecomes
style: 'mapbox://styles/mapbox/streets-v11'style: 'https://api.mapsfordevs.com/styles/standard.json?key=mfd_pub_…'
accessToken: 'pk....'(drop — key goes in style URL)
mapboxgl.Mapmaplibregl.Map (drop-in)
diff
- import mapboxgl from 'mapbox-gl';
- mapboxgl.accessToken = 'pk.xxx';
- const map = new mapboxgl.Map({
-   container: 'map',
-   style: 'mapbox://styles/mapbox/streets-v11'
- });
+ import maplibregl from 'maplibre-gl';
+ const map = new maplibregl.Map({
+   container: 'map',
+   style: `https://api.mapsfordevs.com/styles/standard.json?key=${PUB_KEY}`
+ });

From competitor B (raster XYZ)

diff
- L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { ... })
+ L.tileLayer('https://tiles.mapsfordevs.com/raster/{z}/{x}/{y}.png?key=mfd_pub_…', { maxZoom: 17 })

From competitor C (JS SDK)

If you used a one-line init('apikey') SDK, our equivalent is the style URL. Drop the SDK, init MapLibre with our style URL. You lose 0 features.

Migration checklist:

  • [ ] Sign up at mapsfordevs.com → create mfd_pub_* key with your domains.
  • [ ] Replace style URL.
  • [ ] Replace SDK import (mapbox-glmaplibre-gl, etc.).
  • [ ] Update attribution text — see docs/recipes/attribution.md.
  • [ ] Run your end-to-end tests.
  • [ ] Set up the analytics dashboard.

Estimated effort: under 30 minutes for a single-app codebase.


Recipes

Topics that span sections — kept in docs/recipes/:

RecipePurpose
schema.mdFull vector layer + attribute reference
attribution.mdWhat you must show, where, how
offline.mdLoading .mfdmap files in MapLibre
languages.mdAll 80+ supported language codes
styling.mdBuild your own style on top of MFD_standard
shields.mdCountry-aware highway shield rendering
pmtiles-ranges.mdSelf-host with PMTiles + R2 byte ranges

Support

  • Status page: https://status.mapsfordevs.com
  • Issues / feature requests: https://github.com/mapsfordevs/feedback
  • Email: support@mapsfordevs.com

License terms: docs/LICENSE_TERMS.md.