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.
| Section | What you get |
|---|---|
| 1. Auth model | API key types, when to use which, security rules |
| 2. Tile API | Raw XYZ + style.json, MapLibre / Leaflet / OpenLayers init |
| 3. Frontend frameworks | React, Vue, Svelte, Angular, vanilla |
| 4. Mobile | iOS, Android, React Native, Flutter |
| 5. Server-side proxy | Hide keys + cache: Node, Python, Go, Rails, Laravel, .NET |
| 6. Downloads API | Buy → license token → fetch .mfdmap → MapLibre offline |
| 7. Embed widget | Copy-paste iframe + JS API |
| 8. 80+ language labels | Runtime locale switch with one expression |
| 9. Geocoding | Forward + reverse address lookup (phase 3) |
| 10. Errors + rate limits | 401 / 402 / 429 codes, retry-after, quota headers |
| 11. Migrating from competitors | Drop-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):
| Service | Base URL |
|---|---|
| Tiles | https://tiles.mapsfordevs.com |
| Styles | https://api.mapsfordevs.com/styles |
| Auth + account | https://api.mapsfordevs.com/auth |
| Downloads | https://api.mapsfordevs.com/downloads |
| Embeds | https://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 prefix | Where it lives | What we check | Use for |
|---|---|---|---|
mfd_pub_… | Browser, mobile app, public source | HTTP Referer (web) or app bundle ID (native) matches an allow-list you set in the dashboard | Tile requests from a webpage or mobile app |
mfd_srv_… | Backend server only | Source IP matches an allow-list and the request is signed with the key's secret | Server-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
Refererallow-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_*:
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.
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.
2a. Style URL (recommended)
We host pre-built styles. Hand the URL to MapLibre and you are done.
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.
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)
L.tileLayer('https://tiles.mapsfordevs.com/raster/{z}/{x}/{y}.png?key=' + PUB_KEY, {
maxZoom: 17,
attribution: '© OpenStreetMap · © MapsForDevs'
}).addTo(map);OpenLayers
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.
┌──────────────────────────────────────────┐
│ env (Vite / Next / Nuxt) │
│ VITE_MFD_PUB_KEY=mfd_pub_… │
└─────────┬────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ component │
│ onMount → new maplibregl.Map({...}) │
│ onUnmount → map.remove() │
└──────────────────────────────────────────┘React
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
<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
<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+
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
<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.
| Platform | Library | One-liner |
|---|---|---|
| iOS | MapLibre Native iOS | MLNMapView(frame: ..., styleURL: URL(string: STYLE_URL)) |
| Android | MapLibre Native Android | mapView.getMapAsync { it.setStyle(STYLE_URL) } |
| React Native | @maplibre/maplibre-react-native | <MapLibreGL.MapView styleURL={STYLE_URL} /> |
| Flutter | maplibre_gl | MapLibreMap(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:
- Hide keys — never put
mfd_srv_*in a browser. If you want full control without exposing any key, proxy. - Cache — pay us once for a tile, serve it from your CDN forever (within license terms).
Pattern (Node/Express):
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:
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.
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.
<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>| Param | Required | Notes |
|---|---|---|
lat, lng | yes | WGS84 |
zoom | no | default 13 |
label | no | URL-encoded, max 80 chars |
desc | no | URL-encoded, max 200 chars, plain text only |
style | no | light | dark | standard (default) |
lang | no | BCP-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:
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.
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
GET /geocode/forward?q=80+Sturdee+Ave+Rosebank&country=za&limit=5
Authorization: Bearer mfd_pub_…{
"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
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:
{ "ok": false, "error": "human readable message", "code": "rate_limited" }| HTTP | code | Meaning | Action |
|---|---|---|---|
| 400 | bad_request | Validation failed | Fix request, do not retry |
| 401 | unauthorized | Missing / invalid key | Check Authorization header |
| 402 | quota_exceeded | Monthly free tier used up | Upgrade plan |
| 403 | referer_blocked | Referer not in allow-list | Add domain in dashboard |
| 403 | key_revoked | Key rotated > 24 h ago | Use new key |
| 404 | not_found | Tile / object missing | Tile is genuinely empty (ocean) — show nothing |
| 429 | rate_limited | Bursting too fast | Honour Retry-After header |
| 451 | region_unavailable | Country not licensed | Contact sales |
| 5xx | internal_server_error | Our problem | Retry with exponential backoff |
Every response includes:
X-MFD-Quota-Limit: 1000000
X-MFD-Quota-Remaining: 998422
X-MFD-Quota-Reset: 1730419200Use them to display dashboards or pre-empt 402s.
Retry strategy (recommended client logic):
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)
| Was | Becomes |
|---|---|
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.Map | maplibregl.Map (drop-in) |
- 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)
- 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-gl→maplibre-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/:
| Recipe | Purpose |
|---|---|
schema.md | Full vector layer + attribute reference |
attribution.md | What you must show, where, how |
offline.md | Loading .mfdmap files in MapLibre |
languages.md | All 80+ supported language codes |
styling.md | Build your own style on top of MFD_standard |
shields.md | Country-aware highway shield rendering |
pmtiles-ranges.md | Self-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.