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://api.mapjet.net/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 to MapJet’s API host, https://api.mapjet.net (at any other host of the
platform, add X-Substrio-Tenant: mapjet; the examples carry it so they work at either):
curl https://api.mapjet.net/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": [...]}, any number (the request body limit is the bound): 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 /address/validate?address= |
One US address checked: valid, corrected (with what differs), not_found (with up to 3 suggestions) or invalid; its standardized form, parts and point |
1 |
POST /geocode/batch |
Any number of US addresses (the request body limit is the bound): a CSV (Content-Type: text/csv, your columns kept and latitude, longitude, matched_address, match_score, match_status added) or {"addresses": [...]} |
1 an address |
GET /tiles/:release/:z/:x/:y |
A vector map tile (Mapbox Vector Tile, gzip) of the whole planet, 0 <= z <= 15, at the release GET /tiles.json names; 204 where the set has none; cached a year (a new import is a new release, so new URLs). See Tiles |
1 tile |
GET /tiles.json |
The tileset as TileJSON 3.0.0: its tile URL, zooms, bounds and attribution; cache-control: no-store, so a map always reads the current release |
0 |
GET /styles |
The map themes: id, title, description, accent | 0 |
GET /styles/:theme |
A theme’s MapLibre style (atlas, meridian, neon, titanium, glass, riso; the old ids light, dark, streets, hybrid, terrain and mono draw their closest) |
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.json, GET /site/tiles/:release/:z/:x/:y, GET /site/fonts/:stack/:range |
The TileJSON, tiles and glyphs of mapjet.net’s own map (no key), Las Vegas only; 204 off it |
0 |
POST /site/contact |
mapjet.net’s Contact Us form (no key): name (1 to 100 characters), email, message (1 to 2000), optional company, volume, plan; stored, then 201; 400 CONTACT_INVALID otherwise |
0 |
GET /site/here, GET /site/demo/:kind |
mapjet.net’s live demos (no key): the caller’s own IP answer; lookup?ip=, autocomplete?q= (3 to 64 characters, 5 results), reverse?lat=&lon=, timezone?lat=&lon=, each the answer of its route above |
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 /locations, GET /locations/:set?after=&limit= |
Your location sets (name, count, bounds); one set and a page of its locations in id order | 1 |
PUT /locations/:set, POST /locations/:set |
{"locations": [{id, lat, lon, name?, address?, props?}]}: the set replaced; added to or replaced by id |
1 a 100 locations |
DELETE /locations/:set, DELETE /locations/:set/:id |
A set; one location | 1 |
GET /locations/:set/nearest?lat=&lon= (or ip=, or address=&state=) &limit= |
The nearest of the set (limit 1 to 50, 10 by default), nearest first, each with distance_km |
1, 2 from an IP or an address |
GET /locations/:set/within?km=&lat=&lon= (or ip=, address=) &limit=&after= |
Every location within km (any distance), nearest first, a page of limit (1 to 500, 100 by default); more when there are more, and next, the page after’s after= |
1, 2 from an IP or an address |
GET /zones, GET /zones/:set |
Your zone sets (name, zones, vertices); one set back as the GeoJSON you sent | 1 |
PUT /zones/:set, DELETE /zones/:set |
A GeoJSON FeatureCollection of Polygons and MultiPolygons: the set replaced; the set deleted | 1 a 1,000 vertices; 1 |
GET /zones/:set/at?lat=&lon= (or ip=, ip=me, address=&state=) |
The zones holding the point, each its id and properties |
1, 2 from an IP or an address |
POST /zones/:set/check |
{"points": [{lat, lon, id?, device?}]}, any number (the request body limit is the bound): each point’s zones; with a device, what it entered and exited since its last check |
1 a point |
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 put your publishable key in the URL of every request to MapJet, as
access_token:
const MAPJET = 'https://api.mapjet.net';
const token = 'access_token=<publishable key>';
const map = new maplibregl.Map({
container: 'map',
style: `${MAPJET}/styles/dark`,
transformRequest: (url) =>
url.startsWith(MAPJET) ? { url: url + (url.includes('?') ? '&' : '?') + token } : undefined,
});
In the URL, not an Authorization header: a request with no custom header needs no CORS preflight, so each
tile, glyph and style loads in 1 round trip instead of 2. The browser caches preflights per URL, and every tile has
its own URL. The key is read from the URL only on the routes a page loads (/tiles.json, /tiles/...,
/styles/..., /fonts/..., a saved map’s style and layers, /address/autocomplete, and the locator’s reads),
only from one of its origins, and MapJet’s own code never sees it. Put only a publishable key there: a secret key in
a URL is refused 400 SECRET_KEY_IN_URL. A server sends its secret key as Authorization: Bearer <key>.
The themes: atlas (the classic map), meridian (the everyday web map), neon (navy night lit in electric blue),
titanium (graphite, silver and gold), glass (frosted ice) and riso (a two-ink
print). Each draws buildings in 3D from z15: walls graded by height and lit by the
theme’s light, a roof of its own tone and, on Neon, Titanium, Glass and Riso, a lit
roof edge. A map saved on an old theme id draws its closest: light, streets and
terrain Atlas, dark Neon, hybrid Titanium, mono Meridian.
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:localoren,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> }.datais a FeatureCollection (points, lines, polygons). The save answers each layer’sid; send{ "id", "name", "color", ... }withoutdatato keep a layer as it is and change only how it is drawn, or newdatafor a new layer.clustersgroups nearby points into counted circles as the map zooms out, andheatmapdraws their density; both take points, and both run in the viewer’s browser. The style loads each layer fromGET /maps/:id/layers/:layer, once: a layer’s URL never changes its data, so the browser keeps it.layers[].choropleth: colour a layer’s features (points, lines or polygons) by a number among their properties,{ "property": "pop", "scale": "quantile" }.quantile(the default) sorts the layer’s values and cuts them into five classes of about a fifth each;linearruns smoothly from the least value to the greatest. The colours run from a light tint of the layer’scolorto a dark shade of it; a feature with no number at the property is drawn grey. The save finds the breaks once and answers them in the layer ("choropleth": { "property": "pop", "scale": "quantile", "breaks": [3, 5, 7, 9] }): a value under 3 is the first class, 3 up to 5 the second, and 9 and over the last. A kept layer keeps its breaks while its property and scale are the same; change either and the save finds them again from the layer’s stored data. Send the layer withoutchoroplethfor one colour again. Breaks are never sent: the save owns them, and each view of the map costs nothing more.
curl -X POST https://api.mapjet.net/maps \
-H "X-Substrio-Tenant: mapjet" -H "Authorization: Bearer <key>" -H 'Content-Type: application/json' \
-d '{"name": "Census", "base": "atlas", "layers": [{"name": "Tracts", "color": "#0ea5e9",
"choropleth": {"property": "pop"},
"data": {"type": "FeatureCollection", "features": [
{"type": "Feature", "properties": {"pop": 1200}, "geometry": {"type": "Polygon", "coordinates": [[[-115.2,36.1],[-115.1,36.1],[-115.1,36.2],[-115.2,36.1]]]}}]}}]}'
A save is refused, and nothing of it kept:
| Status | Code | When |
|---|---|---|
| 400 | MAP_INVALID |
the map’s shape: a name, theme, colour, option or layer member that is not one (the message names it, as layers[0].choropleth.scale: quantile or linear) |
| 400 | LAYER_INVALID |
a layer’s own fault: its GeoJSON (the feature and position named), an id the map does not hold, clusters or a heatmap over lines or polygons, a choropleth property no feature holds a number at |
| 404 | NO_MAP |
no map of your account has this id |
| 404 | NO_LAYER |
GET /maps/:id/layers/:layer: no map of your account has this layer |
| 507 | MAP_NOT_STORED |
your storage cannot hold the save’s layers; the layers it had written are removed |
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).
Embed
Put a saved map (or a theme) in any page with one element, <mapjet-map>. It is a standard web component, so
React 19 and Vue 3 render it as it is: the same element is the React and the Vue component.
<script type="module" src="https://mapjet.net/lib/[email protected]/mapjet-embed.js"></script>
<mapjet-map map="Ab3dE" api-key="<publishable key>" style="height:420px"></mapjet-map>
| Attribute | What it sets |
|---|---|
map |
A saved map’s id; or theme, a theme id from GET /styles |
api-key |
Your key: on a public page, a publishable key (it reads, never writes, and answers only your site’s origins) |
center, zoom, pitch, bearing |
The camera: "lon,lat", 0 to 22, 0 to 85, -360 to 360 |
night |
auto (the viewer’s light or dark setting, followed as it changes; the default), on or off |
interactive |
false for a still map |
The element’s map property is its MapLibre map once drawn, for your own layers and markers. A wrong attribute
draws one line saying which, in place of the map. MapLibre is loaded once a page, however many maps it holds.
React 19, with the script tag above once in your page’s <head> (api-key, since key is React’s own):
export const StoreMap = ({ id }) => <mapjet-map map={id} api-key={MAPJET_KEY} style={{ height: 420 }} />;
Vue 3: tell the compiler the element is not a Vue component, then use it in any template:
// vite.config.js
vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('mapjet-') } } })
<mapjet-map :map="id" :api-key="key" style="height:420px" />
Types for both (JSX IntrinsicElements, Vue GlobalComponents) are at
/lib/[email protected]/mapjet-embed.d.ts: copy the file into your project.
Share links and iframes. https://mapjet.net/lib/[email protected]/map.html#map=Ab3dE&key=<publishable key>
draws the map full-window; any attribute above can follow in the same way (&zoom=12&night=off). The key is in
the link’s hash, which the browser never sends: it reaches no server, log or Referer. Embed it as it is:
<iframe src="https://mapjet.net/lib/[email protected]/map.html#map=Ab3dE&key=<publishable key>"
width="100%" height="420" style="border:0" loading="lazy" title="Our stores"></iframe>
The page runs only MapJet’s own files (its Content-Security-Policy: no inline script, no eval) and any site
may frame it. Every file is served cached for a year, and a change to the map shows at its next load, with no
change to the link.
Locations
Upload the places your customers look for (stores, branches, depots, charging points), then ask for the
nearest to a point, a visitor’s IP or a typed address. A set is yours alone: its name (1 to 32 of
A-Z a-z 0-9 _ . -) is under your account, and another account’s set of the same name never meets it. An
account holds any number of sets, each of any size: a set grows by POST, and the only bounds are the request body limit and a call’s CPU limit (a write is charged 1 for each 100 locations).
curl -X PUT https://api.mapjet.net/locations/stores \
-H "X-Substrio-Tenant: mapjet" -H "Authorization: Bearer <key>" -H 'Content-Type: application/json' \
-d '{"locations": [
{"id": "strip", "lat": 36.1147, "lon": -115.1728, "name": "MapJet Strip", "address": "3570 Las Vegas Blvd S"},
{"id": "henderson", "lat": 36.0395, "lon": -114.9817, "name": "MapJet Henderson", "props": {"open_late": true}}
]}'
curl "https://api.mapjet.net/locations/stores/nearest?lat=36.17&lon=-115.14&limit=2" \
-H "X-Substrio-Tenant: mapjet" -H "Authorization: Bearer <key>"
{"set": "stores", "from": {"by": "point", "lat": 36.17, "lon": -115.14},
"results": [{"id": "strip", "lat": 36.1147, "lon": -115.1728, "name": "MapJet Strip", "address": "3570 Las Vegas Blvd S", "distance_km": 6.818},
{"id": "henderson", "lat": 36.0395, "lon": -114.9817, "name": "MapJet Henderson", "props": {"open_late": true}, "distance_km": 20.318}]}
A location is id (1 to 64 of A-Z a-z 0-9 _ . : -, or a whole number), lat, lon, and optionally
name (200 characters), address (300) and props (any JSON object, 4,096 characters): each answer
carries it as you sent it. Distances are along the great circle, the shorter way round (a point by the
antimeridian finds the other side’s). ip= searches from the place of an IP address (from names its
city), and ip=me from the caller’s own (a web page’s visitor: 404 IP_NOT_FOUND when their address has no
place); address= from a geocoded US address (from names the match); either costs one more request.
PUT swaps the set whole: a search during the upload answers from the old set, never from half of it.
within pages: an answer with "more": true carries next; ask again with the same query and after=<next>
for the page after, until "more": false (next is null). Every location within km is on exactly one
page, in order of distance, then id (locations at the same spot come out by id), and each page costs what one
search does. A km past half the earth’s circumference (20,016 km) covers the whole set:
curl "https://api.mapjet.net/locations/stores/within?lat=36.17&lon=-115.14&km=20000&limit=500" \
-H "X-Substrio-Tenant: mapjet" -H "Authorization: Bearer <key>"
{"set": "stores", "from": {...}, "results": [... 500 ...], "more": true, "next": "3f0c1d2e4a5b6c7dstrip-499"}
curl "https://api.mapjet.net/locations/stores/within?lat=36.17&lon=-115.14&km=20000&limit=500&after=3f0c1d2e4a5b6c7dstrip-499" \
-H "X-Substrio-Tenant: mapjet" -H "Authorization: Bearer <key>"
| Status | Code | When |
|---|---|---|
400 |
SET_INVALID, LOCATION_ID_INVALID |
A set name or a location id outside its characters |
400 |
LOCATIONS_INVALID |
A body that is not {"locations": [...]}, an empty list, an id twice, a bad coordinate or field (the message names the location) |
400 |
ORIGIN_REQUIRED, COORDINATES_INVALID, IP_INVALID, IP_PRIVATE, ADDRESS_INVALID |
No lat/lon, ip or address; one that is not one |
400 |
LIMIT_INVALID, KM_INVALID |
limit out of range; km not a number above 0 |
400 |
AFTER_INVALID |
An after that is not a next of a within answer |
404 |
NO_SET, NO_LOCATION, IP_NOT_FOUND, ADDRESS_NOT_FOUND |
No set of yours by the name; no location by the id; an IP or address with no place |
Zones
Upload the areas that matter to you (delivery zones, service regions, store catchments, geofences) as a GeoJSON FeatureCollection, then ask which hold a point, an IP’s place or a typed address, or check a batch of vehicle positions and hear what each entered and left. A set is yours alone, as location sets are; an account holds any number of sets, each of any size a request body carries (a write is charged by its vertices).
curl -X PUT https://api.mapjet.net/zones/delivery \
-H "X-Substrio-Tenant: mapjet" -H "Authorization: Bearer <key>" -H 'Content-Type: application/json' \
-d '{"type": "FeatureCollection", "features": [
{"type": "Feature", "id": "strip", "properties": {"fee": 4.99},
"geometry": {"type": "Polygon", "coordinates": [[[-115.18, 36.08], [-115.15, 36.08], [-115.15, 36.15], [-115.18, 36.15], [-115.18, 36.08]]]}}
]}'
curl -X POST https://api.mapjet.net/zones/delivery/check \
-H "X-Substrio-Tenant: mapjet" -H "Authorization: Bearer <key>" -H 'Content-Type: application/json' \
-d '{"points": [{"device": "van-12", "lat": 36.1147, "lon": -115.1728}]}'
{"set": "delivery", "results": [{"id": null, "zones": ["strip"], "device": "van-12", "entered": ["strip"], "exited": []}]}
A zone is a Feature with an id (1 to 64 of A-Z a-z 0-9 _ . : -, or a whole number), optional
properties (4,096 characters of JSON) and a Polygon or MultiPolygon, holes and all (even-odd: a point in a
hole is outside). Edges are straight in longitude and latitude, as GeoJSON draws them; a zone that crosses
the antimeridian is sent as two parts (RFC 7946). Positions are exact to 0.6 m: a point on an edge has one
answer, the same every time. A device’s last zones are kept by MapJet the moment a check answers, so its
next check, from anywhere, says what changed; PUT keeps them (by zone id) and DELETE of the set ends them.
| Status | Code | When |
|---|---|---|
400 |
SET_INVALID |
A set name outside its characters |
400 |
ZONES_INVALID |
A body that is not a FeatureCollection of Polygons and MultiPolygons, an id twice, a ring under 4 positions, a position out of range (the message names the feature) |
400 |
POINTS_INVALID |
A check that is not {"points": [...]} of positions |
400 |
ORIGIN_REQUIRED, COORDINATES_INVALID, IP_INVALID, IP_PRIVATE, ADDRESS_INVALID |
As for locations |
404 |
NO_ZONE_SET, IP_NOT_FOUND, ADDRESS_NOT_FOUND |
No zone set of yours by the name; an IP or address with no place |
Batch geocoding
Send a spreadsheet of addresses, get it back with each one’s point. A CSV needs a header row and either an
address column or street, city, state and zip columns (also address1, postcode, postal_code
and the like); every column you send comes back as it was, in order.
curl -X POST https://api.mapjet.net/geocode/batch \
-H "X-Substrio-Tenant: mapjet" -H "Authorization: Bearer <key>" -H 'Content-Type: text/csv' \
--data-binary @stores.csv
id,street,city,state,zip,latitude,longitude,matched_address,match_score,match_status
7,3570 Las Vegas Blvd S,Las Vegas,NV,89109,36.1147,-115.1728,"3570 LAS VEGAS BLVD S, LAS VEGAS, NV 89109",1,matched
9,1 Virginia St,Paradise,NV,89501,39.5296,-119.8138,"1 VIRGINIA ST, RENO, NV 89501",0.85,matched
match_score is the share of what you gave that the match agrees with, weighted: house number 0.35,
street 0.35 (any spelling: “Boulevard South” is “BLVD S”), city 0.15, state 0.10, ZIP 0.05. A ZIP settles
a city written as a neighbourhood, and the score shows it. match_status is matched, not_found (no
single place matches) or invalid (no street or no state). JSON in gives JSON out, each result with
differs, the parts that disagree. A refused upload (400 BATCH_INVALID: no address column, no row, or a row of more cells than the header, which a
cell holding a comma makes unless it is quoted) is not charged.
A batch has no row cap: the request body limit and a call’s CPU limit are the only bounds.
Address validation
GET /address/validate?address=1 Virginia St, Paradise, NV 89501 answers whether the address is a real
place and what it is:
{"input": "1 Virginia St, Paradise, NV 89501", "status": "corrected", "valid": false,
"standardized": "1 VIRGINIA ST, RENO, NV 89501",
"components": {"number": "1", "street": "VIRGINIA ST", "city": "RENO", "state": "NV", "zip": "89501"},
"lat": 39.5296, "lon": -119.8138, "score": 0.85, "differs": ["city"], "added": []}
valid when every part you gave agrees (added names what the match filled in, such as a missing ZIP);
corrected when one place matched and a part differs; not_found with suggestions (up to 3 addresses
near what you typed); invalid with no street or no state. The score is the batch geocoder’s.
Tiles
MapJet serves the whole planet’s vector tiles, zoom 0 to 15, from OpenStreetMap (a Protomaps build). Point
MapLibre (or any Mapbox Vector Tile reader) at GET /tiles.json with your key; it names the current release
and the tile URL:
curl https://api.mapjet.net/tiles.json -H "X-Substrio-Tenant: mapjet" -H "Authorization: Bearer <key>"
{"tilejson":"3.0.0","name":"MapJet","version":"planet-20261025","scheme":"xyz",
"tiles":["https://api.mapjet.net/tiles/planet-20261025/{z}/{x}/{y}"],"minzoom":0,"maxzoom":15,...}
curl https://api.mapjet.net/tiles/planet-20261025/15/5900/12854 --compressed -o strip.mvt \
-H "X-Substrio-Tenant: mapjet" -H "Authorization: Bearer <key>"
- A tile is gzip compressed (
Content-Encoding: gzip; browsers andfetchundo it), cached a year and never changed under its URL. A tile the planet does not hold, past zoom 15 or in a release that does not exist, is a204with no body: draw nothing there. - A new import is a new release:
GET /tiles.jsonnames it and a map that loads it asks for new URLs. A map still on the previous release keeps being served it until MapJet drops that release. - A tile answers a plain
200(noContent-Range), so a browser or CDN caches it under its URL. - Each tile is charged 1 on the
tilescounter; a tile off the grid (400 TILE_INVALID) is not.
For the operator: a planet archive (PMTiles v3, gzip tiles) is loaded with
node import/pmtiles-dir.mjs <archive> --release <id> (it streams the archive into the app’s store, sends its
directory once to the import function and then flips the tileset to the release), and a release no map uses
any more is removed with node import/pmtiles-dir.mjs --drop <release> (the live release is refused). An
archive already on the server’s own disk is never streamed (a full read and a full write of the file): it goes
in three steps, in this order:
node import/pmtiles-dir.mjs <archive> --release <id> --adopted <row file>sends its directory from where it lies (no tile byte read) and writes the tileset document to<row file>; the live release is unchanged.- The platform operator’s
file.adopt({"path": "tiles/<id>.pmtiles", "source": "<archive under the server's --adopt-dir>"}, at the appmapjet) makes the file the app’s stored archive with 0 data bytes written. node import/pmtiles-dir.mjs --flip <row file>makes the release live.
All three read SUBSTRIO_URL and the app key’s file SUBSTRIO_KEY_FILE.
AI agents (MCP)
MapJet is an MCP server at https://api.mapjet.net/mcp: Claude, ChatGPT, Cursor or any MCP client calls
MapJet’s routes as tools. Each tool runs the route it names, in the platform, as that route does over HTTP:
the same answer, the same charge (see usage) and the same refusal, whose typed code comes back in the
tool’s result (isError, with code and message) for the agent to correct.
claude mcp add --transport http mapjet https://api.mapjet.net/mcp \
--header "X-Substrio-Tenant: mapjet" --header "Authorization: Bearer ${MAPJET_KEY}"
{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "locations_nearest", "arguments": {"set": "stores", "lat": 36.17, "lon": -115.14, "limit": 2}}}
answers the nearest route’s JSON as the result’s structuredContent (and as its text). Arguments are the
route’s path parameters, then its query (a GET or DELETE) or its JSON body; an argument a tool does not
declare is refused before anything runs. Tiles, glyphs, style documents and a map’s layer files are for
MapLibre and have no tool.
A tool reaches your account’s data only: another account’s key, even one of MapJet’s, gets what the route
gives it over HTTP (NO_SET for your location set, NO_ZONE_SET, NO_MAP), never your data.
| Tool | Route | What it does |
|---|---|---|
ip_lookup |
GET /lookup/:ip |
Look up an IP address |
ip_lookup_batch |
POST /lookup |
Look up many IP addresses |
timezone_at |
GET /timezone/:lat/:lon |
Time zone at a point |
reverse_geocode |
GET /reverse/:lat/:lon |
Nearest US city to a point |
city_geocode |
GET /city/geocode |
Find a US city |
address_autocomplete |
GET /address/autocomplete |
Suggest US addresses |
address_geocode |
GET /address/geocode |
Geocode a US address |
address_validate |
GET /address/validate |
Validate a US address |
geocode_batch |
POST /geocode/batch |
Geocode many US addresses |
usage |
GET /usage |
Your MapJet use |
map_themes |
GET /styles |
List map themes |
maps_list |
GET /maps |
List saved maps |
map_create |
POST /maps |
Save a new map |
map_update |
PUT /maps/:id |
Change a saved map |
map_delete |
DELETE /maps/:id |
Delete a saved map |
location_sets |
GET /locations |
List location sets |
location_set |
GET /locations/:set |
Read a location set |
location_set_replace |
PUT /locations/:set |
Replace a location set |
locations_add |
POST /locations/:set |
Add or update locations |
location_set_delete |
DELETE /locations/:set |
Delete a location set |
location_delete |
DELETE /locations/:set/:id |
Delete a location |
locations_nearest |
GET /locations/:set/nearest |
Nearest locations |
locations_within |
GET /locations/:set/within |
Locations within a distance |
zone_sets |
GET /zones |
List zone sets |
zone_set |
GET /zones/:set |
Read a zone set |
zone_set_replace |
PUT /zones/:set |
Replace a zone set |
zone_set_delete |
DELETE /zones/:set |
Delete a zone set |
zones_at |
GET /zones/:set/at |
Zones holding a point |
zones_check |
POST /zones/:set/check |
Check points against zones |
| Status | Code | When |
|---|---|---|
401 |
No key, or a key that is not one of MapJet’s, on every method, as its HTTP call is refused (the result names how to sign up) | |
| tool result | the route’s own | A refusal of the route the tool runs, isError: true |
JSON-RPC -32602 |
A tool name MapJet does not declare |
Store locator
One script tag puts your saved map beside your locations on your own site: the list nearest first, an
address search with suggestions, “Near me”, directions. It needs a publishable key from your Substrio
account: one made for your site’s origins, safe in a page’s source. It answers only a browser on those
origins, and only the routes a map and a search read (a set’s page, nearest, within, address
suggestions, the map’s style, a theme’s style, data layers, TileJSON, tiles and glyphs; the same reads
Embed’s <mapjet-map> makes). Every route that writes or lists
refuses it (403 KEY_ORIGIN_REFUSED), as does any other site.
<div id="mapjet-locator" style="height:560px"></div>
<script src="https://mapjet.net/lib/[email protected]/mapjet-locator.js"
data-key="<publishable key>" data-map="<saved map id>" data-set="stores"
data-target="#mapjet-locator" defer></script>
The script calls the same routes as your own code, with that key, and your account is charged as for any
call: a tile 1, a search 1 (2 from the visitor’s IP or a typed address), suggestions 1. The first view is
the locations nearest the visitor’s network (ip=me), with no permission prompt; “Near me” asks the
browser for its position. The map is your saved map, day or night by the visitor’s setting, its accent
your map’s. Options on the tag: data-title, data-limit (1 to 50), data-units (mi or km),
data-directions (google, apple, osm); every colour is a CSS variable on .mjl.
Plans
| Plan | Requests | Tiles |
|---|---|---|
free (every account starts here) |
25,000/month | 50,000/month |
starter |
250,000/month | 1,000,000/month |
standard |
1,000,000/month | 5,000,000/month |
scale |
no limit | no limit |
Every Substrio plan includes MapJet at the tier of the same name. Hold MapJet on its own as well and each limit is
the higher of the two, no limit beating any number. Accounts on the retired enterprise and unlimited plans are on
scale. The prices are the platform’s public catalog (GET /v1/_billing/catalog, no key) and mapjet.net/pricing.
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 without an account key (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”).