MapJet API

MapJet is a shared app of the Substrio stack: geo services (IP location, address search and geocoding, city centres, time zones) and vector map tiles for any Substrio account. You call it with an account key of your own account, scoped to the mapjet app. Every call counts against your account's MapJet plan.

Get a key

MapJet's owner keeps the app open to other accounts' keys (account.app.open.set, PUT /v1/_accounts/{id}/apps/mapjet/keys-open with {"open": true}). With that, any account makes its own key for it:

curl -X POST https://<platform host>/v1/_accounts/<your account id>/keys \
  -H "Authorization: Bearer <your account key>" \
  -H "Content-Type: application/json" \
  -d '{"name": "mapjet-prod", "apps": ["mapjet"], "mode": "live"}'

The answer holds the key's secret once. The key works only at MapJet, as a customer of the app: it reads nothing else of your account. Give it its own limits if you want them (requests_per_minute, request_budget; see account.key.update). MCP: substrio_account_key_create with the same fields.

Call it

Send the key as a bearer token, at MapJet's host or with X-Substrio-Tenant: mapjet:

curl https://<platform host>/lookup/8.8.8.8 \
  -H "X-Substrio-Tenant: mapjet" \
  -H "Authorization: Bearer <key>"
Route What it answers Charged
GET /lookup/:ip The place of an IPv4 or IPv6 address (country, region, city, coordinates) and its time zone 1
POST /lookup {"ips": [...]}, at most 100: each address's place 1 an address
GET /timezone/:lat/:lon The IANA time zone at a point 1
GET /reverse/:lat/:lon The US city nearest a point (its centre, state, distance in km and time zone); 404 NO_PLACE past 2 degrees 1
GET /city/geocode?city=&state= A US city's centre 1
GET /address/autocomplete?q=&state=&limit= US address suggestions as you type 1
GET /address/geocode?q=&ip= A US address's coordinates (ip biases toward the caller's area) 1
GET /tiles/:z/:x/:y A vector map tile (Mapbox Vector Tile, gzip), 0 <= z <= 15; 204 where the set has none 1 tile
GET /tiles.json The tileset as TileJSON 3.0.0: its tile URL, zooms, bounds and attribution 0
GET /styles The map themes: id, title, description, accent 0
GET /styles/:theme A theme's MapLibre style (light, dark, streets, hybrid, terrain, mono) 0
GET /fonts/:stack/:range The styles' glyphs (Noto Sans), as MapLibre asks for them 0
GET /lib/:pkg/:file MapLibre GL JS 5.6.0 and MapJet's markers, for a <script> tag (no key) 0
GET /site/tiles/:z/:x/:y, GET /site/fonts/:stack/:range The tiles and glyphs of mapjet.net's own map (no key), Las Vegas only; 204 off it 0
GET /maps Your account's saved maps 0
POST /maps, PUT /maps/:id, DELETE /maps/:id Save a map (a theme, colours, options, a night theme), change it, delete it 0
GET /maps/:id/style, GET /maps/:id/night A saved map's day and night styles 0
GET /maps/:id/layers/:layer A saved map's data layer, its GeoJSON as saved 0
GET /usage Your account, your plan and what you used today and this month 0

A request MapJet refuses (400) is not charged. Tiles count on their own counter, tiles; every other route counts on requests.

Maps

Give MapLibre GL JS a theme's style and send the key with every request to MapJet:

const MAPJET = 'https://<platform host>';
const headers = { 'X-Substrio-Tenant': 'mapjet', Authorization: 'Bearer <key>' };
const map = new maplibregl.Map({
  container: 'map',
  style: `${MAPJET}/styles/dark`,
  transformRequest: (url) => (url.startsWith(MAPJET) ? { url, headers } : { url }),
});

The themes: light (Daylight), dark (Midnight), streets, hybrid, terrain and mono (Ink). Every map shows the credit the style carries (MapJet, and "© OpenStreetMap contributors", which the data's licence requires).

Saved maps

Make a map of your own in the admin's Studio (or by POST /maps), then load it by its id. A change to the map is what every site that loads the id draws at its next load, with no deploy of yours.

{ "name": "Fleet", "base": "light", "night": "dark",
  "overrides": { "accent": "#00aa66", "highway": "#ff5500" },
  "options": { "density": "rich", "language": "en", "buildings": "3d", "pois": true, "rail": true, "boundaries": true } }
  • base, night: a theme; the night style carries your brand colours (accent, highway, major, park, boundary) over the night theme's own land, water and text.
  • overrides: accent, land, water, park, highway, major, building, text, boundary, each #rrggbb. Labels are held readable (4.5 : 1 on the land) whatever you pick.
  • options.density: none, minimal, normal, rich; language: local or en, es, fr, de, it, pt, nl, pl, ru, tr, ar, hi, ja, ko, zh; buildings: 3d, flat, none.
  • layers: your own data over the map, drawn in the order given, each { "name", "color": "#rrggbb", "clusters": true, "heatmap": true, "data": <GeoJSON> }. data is a FeatureCollection (points, lines, polygons). The save answers each layer's id; send { "id", "name", "color", ... } without data to keep a layer as it is and change only how it is drawn, or new data for a new layer. clusters groups nearby points into counted circles as the map zooms out, and heatmap draws their density; both take points, and both run in the viewer's browser. The style loads each layer from GET /maps/:id/layers/:layer, once: a layer's URL never changes its data, so the browser keeps it.

Follow the viewer's day or night setting:

const dark = matchMedia('(prefers-color-scheme: dark)');
const styleUrl = () => `${MAPJET}/maps/<id>/${dark.matches ? 'night' : 'style'}`;
const map = new maplibregl.Map({ container: 'map', style: styleUrl(), transformRequest });
dark.addEventListener('change', () => map.setStyle(styleUrl(), { diff: true }));

Markers

MapJet's markers draw a place in the map's theme accent: pin for an exact place (it drops in and pulses at its foot), approximate for an area a place may be in, such as an IP's city (a sonar ping at the true radius in metres, at every zoom). The animations run on the compositor, and stop for a visitor who asks for reduced motion.

const { pin, approximate } = await import(`${MAPJET}/lib/[email protected]/mapjet-markers.js`);
map.on('load', () => {
  pin(map, [-115.1398, 36.1699]);
  approximate(map, [-115.14, 36.17], 5000).setRadius(2500);
});

The same module has spotlight(map, [lng, lat], metres, { dim }), which dims the world around a place, and tour(map, stops, { loop, onEnd }), a camera flight through { center, zoom, bearing, pitch, duration, hold } stops (each stop without the flight for a viewer who asks for reduced motion).

Plans

Plan Requests Tiles
free (every account starts here) 1,000 a day, 10,000 a month 50,000 a month
starter 1,000,000 a month 5,000,000 a month
growth 10,000,000 a month 50,000,000 a month
unlimited no limit no limit

Past your plan, a call answers 429, nothing is counted, and the body says when it frees:

{"error": "Too Many Requests", "code": "USAGE_LIMIT", "counter": "requests",
 "limit": 1000, "used": 1000, "period": "day", "resets_at": 1798761600}

Other answers

Status Code When
401 CREDENTIAL_REQUIRED (the platform's) No key, or a key that is not an account key (an app's own key)
401 (the platform's) A key that is revoked or not scoped to MapJet
401 ACCOUNT_KEY_REQUIRED A caller the platform lets through with no account behind it (a signed-in person)
429 USAGE_LIMIT Past your plan (above)
429 (the platform's) Past your key's own requests_per_minute or request_budget
400 QUERY_NAME_REPEATED, PATH_NOT_DECODABLE A query name sent twice; a path that does not percent-decode
413 BODY_TOO_LARGE A request body past 16 MiB (a map save with its layers, a lookup batch); bodies past 1 MiB and chunked ones are read whole up to it
400 TILE_INVALID A tile off the grid: z, x, y not integers with 0 <= z <= 15 and 0 <= x, y < 2^z
404 NO_TILESET GET /tiles.json before any tiles are imported

Data

IP places: DB-IP's IP to City Lite (CC BY 4.0, "IP Geolocation by DB-IP"). US addresses: Netsyms' AddressDatabase. Time zones: tz-lookup's tables (built from timezone-boundary-builder). Map tiles: MapJet's own, served from MapJet, built from OpenStreetMap (ODbL, "© OpenStreetMap contributors").