API Reference

Every route of the MapJet API, its answer and what it counts against your plan.

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: 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.
  • 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; linear runs smoothly from the least value to the greatest. The colours run from a light tint of the layer’s color to 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 without choropleth for 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 and fetch undo 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 a 204 with no body: draw nothing there.
  • A new import is a new release: GET /tiles.json names 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 (no Content-Range), so a browser or CDN caches it under its URL.
  • Each tile is charged 1 on the tiles counter; 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:

  1. 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.
  2. The platform operator’s file.adopt ({"path": "tiles/<id>.pmtiles", "source": "<archive under the server's --adopt-dir>"}, at the app mapjet) makes the file the app’s stored archive with 0 data bytes written.
  3. 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”).