# Wherebox documentation
> Wherebox turns a request into a locator map image. Name an area, add points or a route, and you get a PNG, JPEG or WebP back: the area is framed and highlighted, the surroundings fade out, and points get markers and labels. It is a plain HTTP API, so an `
` tag is a valid client.
This file concatenates every guide and reference page. Each page is also served on its own at the URL in the comment above it.
---
# Wherebox documentation
Wherebox turns a request into a locator map image. Name an area, add points or a route, and you get a PNG, JPEG or WebP back: the area is framed and highlighted, the surroundings fade out, and points get markers and labels. It is a plain HTTP API, so an `
` tag is a valid client.
```http
GET /v1/map.png?area=Czechia&poi=place:Chřiby|marker:triangle&neighbors=flag+code&w=468&h=280
```

Every request example in these docs is parsed by the test suite, and every non-routed example is rendered into the image next to it by `pnpm docs:examples`. If a parameter changes, the docs break loudly instead of silently going stale.
## Guides
Start with Getting started, then pick what you need.
| Guide | What it covers |
|---|---|
| [Getting started](/docs/guides/getting-started.md) | Your first map as an `
` and as a POST, retina, formats |
| [Recipes by use case](/docs/guides/use-cases.md) | Newsroom, booking email, listing, itinerary, AI agent, report, social card, spreadsheet |
| [Locator maps](/docs/guides/locator-maps.md) | Areas, ids and ambiguity, highlight, subdivisions, sub-areas, presets |
| [Points and labels](/docs/guides/points-and-labels.md) | Coordinates vs place names, markers, custom icons, labels, leader lines |
| [Routes](/docs/guides/routes.md) | Given lines, straight and geodesic, routed profiles, via points, styling |
| [Neighboring countries](/docs/guides/neighbors.md) | Names, codes and flags around the area, fit modes, languages |
| [Typography and titles](/docs/guides/typography-and-titles.md) | Fonts, weights, fallback, custom fonts, the title band |
| [Errors and limits](/docs/guides/errors-and-limits.md) | `problem+json`, retries, caching and ETags |
| [Fair use](/docs/guides/fair-use.md) | The free hosted service: monthly allowance, routed renders, rate limits, asking for more |
| [Authentication](/docs/guides/authentication.md) | Hosted service: keys, signed `
` URLs, rate limits and quotas, admin CLI |
| [TypeScript SDK](/docs/guides/sdk-typescript.md) | `@wherebox/sdk`: URLs, signing, rendering, typed errors, edge runtimes |
| [AI assistants (MCP)](/docs/guides/mcp.md) | Connect Claude and other MCP clients: hosted endpoint with OAuth, or `npx @wherebox/mcp` |
| [Python SDK](/docs/guides/sdk-python.md) | `pip install wherebox`: signed URLs for Django and Flask, rendering to a file, errors and retries |
| [Self-hosting](/docs/guides/self-hosting.md) | Install, import data, configure, Docker, routing engine |
## Reference (generated from code)
The guides explain how to do things. They deliberately do not repeat defaults, ranges or lists of values; those live in the reference, which is generated from the same source the API validates against.
- [Query parameters](/docs/reference/parameters.md): every `GET` parameter, with defaults and ranges
- [Request body](/docs/reference/request-body.md): the JSON body of `POST /v1/map`
- [Errors](/docs/errors.md): every `type` the API returns
- [Configuration](/docs/reference/configuration.md): every environment variable
- [Markers](/docs/reference/markers.md): marker presets and role defaults
- [Presets](/docs/reference/presets.md): built-in basemaps and their highlight defaults
The running server also serves an OpenAPI document at `/openapi.json` and an interactive URL builder at `/playground`.
---
# Getting started
A map is one URL. No account, no SDK.
## The first map: GET in an `
`
```http
GET /v1/map.png?area=Czechia&poi=place:Chřiby|marker:triangle&w=468&h=280
```

Put that URL (with your host in front) into an image tag:
```html
```
What happened: `area` was looked up by name and framed, everything outside it faded, inner borders were drawn, and `poi` put a labeled marker on a named place. `w` and `h` are the size in CSS pixels. Commas, pipes and non-ASCII characters can stay as they are in a browser; use `+` or `%20` for spaces and `%23` for `#`.
Every parameter is listed in the [query parameter reference](/docs/reference/parameters.md). The area, `poi` and `w`/`h` parameters used above are [`area`](/docs/reference/parameters.md#param-area), [`poi`](/docs/reference/parameters.md#param-poi), [`w`](/docs/reference/parameters.md#param-w) and [`h`](/docs/reference/parameters.md#param-h).
## The same map as a POST
GET is convenient and cacheable, but URLs are limited in length and cannot carry some things (custom fonts, GeoJSON areas, more than one route, labels containing `|`). For those, send the same description as JSON to `POST /v1/map` with `Content-Type: application/json`:
```json
{
"area": "Czechia",
"pois": [{ "place": "Chřiby", "marker": { "shape": "triangle" } }],
"width": 468,
"height": 280
}
```
The response is the image itself. Add `?response=json` to the POST URL to get `{url, key, meta, …}` instead, where `url` points at the cached image (`/v1/images/{key}`, immutable, safe to store). The body fields are listed in the [request body reference](/docs/reference/request-body.md).
GET and POST describe the same thing; every example in these guides is labeled with the method it uses, and the GET ones can be turned into JSON and back.
## Retina
`scale` multiplies the pixel density without changing the layout: the image is `w × scale` pixels wide, and markers, labels and line widths grow with it.
```http
GET /v1/map.png?area=Czechia&poi=place:Chřiby|marker:triangle&w=360&h=220&scale=2
```

Use it with an `
` that has `width="360"` and let the browser downscale. See [`scale`](/docs/reference/parameters.md#param-scale).
## Formats
Pick the format with the path extension (`.png`, `.jpg`, `.webp`) or with the `format` parameter. PNG is lossless and the default; JPEG and WebP take a `quality`.
```http
GET /v1/map.webp?area=Czechia&w=468&h=280&quality=85
```
See [`format`](/docs/reference/parameters.md#param-format) and [`quality`](/docs/reference/parameters.md#param-quality).
## What to read next
- Frame a country or region: [Locator maps](/docs/guides/locator-maps.md)
- Add points: [Points and labels](/docs/guides/points-and-labels.md)
- Draw a route: [Routes](/docs/guides/routes.md)
- Something returned an error: [Errors and limits](/docs/guides/errors-and-limits.md)
---
# Recipes by use case
Short requests for things people build with Wherebox. Every recipe is a plain `GET`, so it works as an `
` in an email, a page or a spreadsheet. Any country works as an `area`; finer areas such as regions and cities need the OSM import ([Locator maps](/docs/guides/locator-maps.md)).
## Newsroom: where is it happening?
A country with one marked spot and a headline, for a breaking-news story.
```http
GET /v1/map.png?area=Iceland&poi=63.84,-22.43|marker:triangle|label:Grindavík&title=Eruption+near+Grindavík&w=600&h=400
```

## Booking email: the flight at a glance
A great circle between two airports, labelled with their codes. `geodesic` is the default profile, so no routing engine is needed ([Routes](/docs/guides/routes.md)).
```http
GET /v1/map.png?from=38.77,-9.13&to=40.64,-73.78&profile=geodesic&fromlabel=LIS&tolabel=JFK&w=600&h=300
```

## Listing: a hotel and what is near it
A star for the property and pins for the sights guests ask about. With no `area`, the map frames the points.
```http
GET /v1/map.png?poi=41.3917,2.1649|marker:star|label:Hotel&poi=41.378,2.192|label:Barceloneta+beach&poi=41.4036,2.1744|label:Sagrada+Família&preset=light&w=600&h=400
```

## Travel: an itinerary across a country
Highlight the country and draw the legs in order. `via` adds stops, and `straight` connects them without a routing engine.
```http
GET /v1/map.png?area=Japan&bounds=131.5,32.8,141.5,37.6&from=35.68,139.76&via=35.01,135.77&to=34.69,135.50&profile=straight&fromlabel=Tokyo&tolabel=Osaka&poi=35.01,135.77|label:Kyoto&w=600&h=400
```

## AI agent: answering "where is Bhutan?"
An agent can answer a geography question with one image URL. Neighbor labels give the context a bare outline lacks, and `preset=dark` suits dark chat UIs ([Neighboring countries](/docs/guides/neighbors.md)).
```http
GET /v1/map.png?area=Bhutan&bounds=84,22.5,97,31&neighbors=flag+name&lang=en&preset=dark&w=600&h=400
```

## Report: field offices in a country
Several labelled points inside the area, with a title for the page header ([Points and labels](/docs/guides/points-and-labels.md)).
```http
GET /v1/map.png?area=Kenya&poi=-1.286,36.817|label:Nairobi&poi=-4.043,39.668|label:Mombasa&poi=-0.092,34.768|label:Kisumu&title=Field+offices&w=600&h=450
```

## Social card: share image
An Open Graph image is 1200x630. Put the headline in the title band and mark the place. `bounds` frames mainland Portugal; without it the frame would include the Azores and Madeira.
```http
GET /v1/map.png?area=Portugal&bounds=-10.5,36.8,-5.5,42.3&poi=38.72,-9.14|marker:star|label:Lisbon&title=A+weekend+in+Lisbon&preset=dark&w=1200&h=630
```

## Spreadsheet: one template, many countries
Keep the URL in one cell and the country name in another. A formula such as `="https://api.example.com/v1/map.png?area="&A2&"&neighbors=name&w=600&h=400"` fills `area={country}` for each row, and `=IMAGE(...)` in Google Sheets turns it into a picture. Any country name or `iso:` code works.
```http
GET /v1/map.png?area=Canada&bounds=-141,41.5,-52,76&neighbors=name&w=600&h=400
```

---
# Locator maps
A locator map frames an area, highlights it, and fades what is around it. Everything here is about `area` and how it is highlighted.
## Naming an area
`area` takes a name or an id. Names are convenient; ids never change meaning.
| Form | Example |
|---|---|
| Name | `Czechia`, `Zlínský kraj` |
| ISO 3166 code | `iso:CZ`, `iso:CZ-72` |
| OpenStreetMap relation | `osm:r51684` |
| Wikidata | `wikidata:Q213` |
```http
GET /v1/map.png?area=Zlínský+kraj&w=400&h=300
```

```http
GET /v1/map.png?area=iso:CZ-72&w=400&h=300
```
An id returns the same area as its name, but it cannot become ambiguous when the database gains a same-named place. Use ids in anything you store. Look ids up with `GET /v1/areas/search?q=…`, which also returns each match's parent hierarchy.
## Ambiguous names
Many names exist more than once ("Nová Ves" is the name of dozens of villages). The API refuses to guess:
```http
GET /v1/map.png?area=Nová+Ves&w=400&h=300
```
This returns `409` with a `candidates` array. Each candidate has an `id`, an `adminLevel` and a `context` showing where it is, for example `Česko › Kraj Vysočina › okres Žďár nad Sázavou › …`. The [ambiguous error reference](/docs/errors.md#ambiguous) lists the fields. You have two ways out.
**Pick a candidate by id.** Take its `id` from the response and use it as the area:
```http
GET /v1/map.png?area=osm:r442590&w=360&h=260
```

**Narrow the lookup.** `level` restricts the match to one administrative level, `country` to one ISO country code. They only make sense together with a name `area` ([`level`](/docs/reference/parameters.md#param-level), [`country`](/docs/reference/parameters.md#param-country)). If the narrowed lookup is still ambiguous you get another 409, so check the candidates rather than adding filters blindly.
```http
GET /v1/map.png?area=Zlín&level=8&country=CZ&w=360&h=260
```
In a POST body the same thing is an object instead of a string:
```json
{
"area": { "query": "Zlín", "level": 8, "country": "CZ" },
"width": 360,
"height": 260
}
```
Admin levels differ per country. In Czechia 2 is the country, 4 the regions (kraje), 5 the districts (okresy) and 8 the municipalities.
## Highlighting
The highlight is made of a fade of the surroundings, an outline, an optional tint, and inner borders. Defaults depend on the basemap, so a plain request already looks right; override only what you want to change.
```http
GET /v1/map.png?area=Czechia&w=468&h=280&fade=0.7&fadecolor=%23dedede&outline=%23333333&outlinewidth=2
```

```http
GET /v1/map.png?area=iso:CZ-10&w=420&h=300&tint=%23d03030&tintopacity=0.12&outline=%23a01010&outlinewidth=2
```

Parameters: [`fade`](/docs/reference/parameters.md#param-fade), [`fadecolor`](/docs/reference/parameters.md#param-fadecolor), [`outline`](/docs/reference/parameters.md#param-outline), [`outlinewidth`](/docs/reference/parameters.md#param-outlinewidth), [`tint`](/docs/reference/parameters.md#param-tint), [`tintopacity`](/docs/reference/parameters.md#param-tintopacity). Colors are hex; write `#` as `%23` in a URL.
## Subdivisions
Inner borders show how the area splits. By default (`auto`) the next administrative level that splits the area is drawn. Force a level with a number, or turn them off.
```http
GET /v1/map.png?area=Czechia&w=468&h=280&subdivisions=5
```

```http
GET /v1/map.png?area=Czechia&w=468&h=280&subdivisions=false
```
See [`subdivisions`](/docs/reference/parameters.md#param-subdivisions).
## Highlighting a sub-area
`subarea` highlights a second, smaller area inside the framed one, the classic "this region within its country":
```http
GET /v1/map.png?area=Czechia&subarea=Zlínský+kraj&subareacolor=%23c0392b&w=468&h=280
```

It accepts the same names and ids as `area`. Color, opacity and outline are [`subareacolor`](/docs/reference/parameters.md#param-subareacolor), [`subareaopacity`](/docs/reference/parameters.md#param-subareaopacity) and [`subareaoutlinewidth`](/docs/reference/parameters.md#param-subareaoutlinewidth).
## Desaturate and blur the outside
To push the surroundings back further than a fade can, drain their color and soften them. This runs only outside the area; labels and markers stay sharp.
```http
GET /v1/map.png?area=Czechia&w=468&h=280&desaturate=0.8&blur=1.5&fade=0.2
```

See [`desaturate`](/docs/reference/parameters.md#param-desaturate) and [`blur`](/docs/reference/parameters.md#param-blur).
## Your own area
Send a GeoJSON Polygon, MultiPolygon or Feature instead of a name. This is POST only. Turn subdivisions off, since a custom shape has no administrative children:
```json
{
"area": {
"geojson": {
"type": "Polygon",
"coordinates": [[[16.5, 49.1], [16.8, 49.1], [16.8, 49.3], [16.5, 49.3], [16.5, 49.1]]]
}
},
"highlight": { "subdivisions": false },
"width": 360,
"height": 260
}
```

## Presets
A preset is a complete basemap and a matching set of highlight and text defaults. `locator-relief` is the default (shaded relief, like Wikipedia locator maps); `light` and `dark` are flat.
```http
GET /v1/map.png?area=Czechia&poi=place:Chřiby|marker:triangle&w=468&h=280&preset=dark
```

```http
GET /v1/map.png?area=Czechia&poi=place:Chřiby|marker:triangle&w=468&h=280&preset=light
```
Because defaults follow the preset, switching preset also re-themes the fade, outline, labels and start/finish markers. What each preset sets is in the [presets reference](/docs/reference/presets.md); the parameter is [`preset`](/docs/reference/parameters.md#param-preset).
To use your own style, pass a MapLibre `style.json` URL as [`style`](/docs/reference/parameters.md#param-style) together with your provider key. Prefer the `X-Provider-Key` request header over the `key` parameter so the key stays out of URLs and logs. In JSON this is `basemap.styleUrl`, with `{key}` in the URL replaced by the key.
## Disputed borders
By default the basemap draws borders as OpenStreetMap records them, which follows control on the ground. Set `worldview` to draw country borders as a particular country sees them. For example, `in` draws Kashmir the way India does, and `cn` the way China does:
```http
GET /v1/map.png?bounds=70,30,82,38&w=420&h=320&worldview=in
```

```http
GET /v1/map.png?bounds=70,30,82,38&w=420&h=320&worldview=cn
```

With a worldview, three things follow that country's view:
- **Country borders.** The basemap's country, disputed and claimed lines are replaced. International borders are solid. Disputed borders, claim lines and lines of control are dashed. Lines the view does not recognize are not drawn (with `cn`, there is no border between Kosovo and Serbia).
- **A highlighted country.** It takes the view's shape where that view draws it differently, such as India with all of Kashmir under `in`. Countries that every view draws the same keep their OpenStreetMap shape.
- **Neighbor labels.** They come from the view's country list, so Taiwan has no label under `cn`.
Regions, cities and everything below country level stay OpenStreetMap, whatever the worldview.
The worldview data is Natural Earth's [point-of-view editions](https://www.naturalearthdata.com/downloads/10m-cultural-vectors/) (31 countries, public domain), and the attribution adds "Borders: Natural Earth". Natural Earth is drawn at 1:10 million scale. That suits country and regional maps; on a map of a few kilometres a border can sit slightly off the basemap's roads and coastline. The accepted codes are listed under [`worldview`](/docs/reference/parameters.md#param-worldview). A self-hosted server serves only the worldviews it imported (`NE_WORLDVIEWS`; see the [configuration reference](/docs/reference/configuration.md)). Any other code returns `400 bad-request`.
---
# Points and labels
A point is a location with a marker and an optional label. Up to 50 per map. In GET, repeat `poi=` once per point; in POST, use the `pois` array.
## Coordinates or place names
Give `lat,lon` when you know where it is, or `place:Name` to look it up.
```http
GET /v1/map.png?area=Zlínský+kraj&poi=49.13,17.33|label:Chřiby&w=400&h=300
```

```http
GET /v1/map.png?area=Zlínský+kraj&poi=place:Zlín&poi=place:Kroměříž&poi=place:Uherské+Hradiště&w=400&h=300
```

Place lookups cover cities, peaks, mountain ranges and similar names, and prefer a match inside the framed `area`. The label defaults to the place name; set `label:` to override it. Coordinates are used exactly as given, with no lookup. Find place names with `GET /v1/places/search?q=…`.
Names are looked up in the server's imported OpenStreetMap places first. A server can also configure a geocoder (Nominatim or Photon) for names its data does not have, such as places outside the imported region. When the server has none, an unknown name returns `404 not-found` with close matches in `suggestions`. If the geocoder itself fails, the response is `502 upstream-failed` or `504 upstream-timeout`, so retry those rather than treating them as "no such place". Self-hosters: see [Geocoder fallback](/docs/guides/self-hosting.md#geocoder-fallback).
After the location, a point takes `|key:value` options. All of them are in the [`poi` reference](/docs/reference/parameters.md#param-poi). In JSON the same options are fields of each `pois` entry:
```json
{
"area": "Zlínský kraj",
"pois": [
{ "lat": 49.13, "lon": 17.33, "label": "Chřiby", "marker": { "shape": "triangle", "color": "#e02020" } },
{ "place": "Zlín", "marker": { "shape": "circle", "color": "#222222", "size": 9 } }
],
"width": 400,
"height": 300
}
```
## Markers
A plain point is a pin. Pick another preset with `marker:`, and tune `color:`, `size:` and `stroke:`/`strokewidth:`.
```http
GET /v1/map.png?area=Zlínský+kraj&w=480&h=320&poi=49.40,17.60|marker:star|color:%23e0a000|label:star&poi=49.40,17.95|marker:diamond|color:%231565c0|label:diamond&poi=49.20,17.45|marker:square|color:%232e7d32|label:square&poi=49.20,17.80|marker:circle|color:%23222222|label:circle&poi=49.05,17.30|marker:triangle|label:triangle&poi=49.05,17.70|marker:dot|label:dot
```

Which presets exist, their default sizes and which one a point, a route start or a route finish gets by default is in the [markers reference](/docs/reference/markers.md). The role matters: a plain point is a pin, a route start a ring, a route finish a flag (see [Routes](/docs/guides/routes.md)). Setting `marker:` replaces the role default.
## Custom icons
Use `icon` instead of a preset marker for logos or your own pins. It takes an SVG or PNG as a `data:` URI, or an `https` URL on a host the server operator allows. `anchor:bottom` puts the bottom edge of the icon on the location, which is what pin-shaped icons need; the default is the center.
```json
{
"area": "Zlínský kraj",
"pois": [
{
"lat": 49.2266,
"lon": 17.6667,
"label": "Zlín",
"marker": {
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMCAwIDMyIDQwIj48cGF0aCBkPSJNMTYgMEM3IDAgMCA3IDAgMTZjMCAxMiAxNiAyNCAxNiAyNHMxNi0xMiAxNi0yNEMzMiA3IDI1IDAgMTYgMHoiIGZpbGw9IiMxNTY1YzAiLz48Y2lyY2xlIGN4PSIxNiIgY3k9IjE2IiByPSI2IiBmaWxsPSIjZmZmIi8+PC9zdmc+",
"anchor": "bottom",
"size": 30
}
}
],
"width": 400,
"height": 300
}
```

`size` is the icon height in CSS pixels. In a GET URL the icon must be URL-encoded (base64 contains `+`, `/` and `=`), so POST is the practical way to send one.
### Security notes for icons
Fetching a user-supplied URL is how servers get attacked, so the rules are strict. They are the server's, not yours, but they decide what works:
- Remote icons are **off** unless the operator lists hosts in `WHEREBOX_ICON_HOSTS`. Until then only `data:` URIs work. See the [configuration reference](/docs/reference/configuration.md).
- Remote icons must be `https`, are never fetched through redirects, and are capped in size and time.
- The type must really be an image (PNG signature check); SVGs containing a DOCTYPE or entities, external references, or scripts are rejected.
- A rejected icon is an error, not a silent fallback: you get `400 bad-request` (see [Errors and limits](/docs/guides/errors-and-limits.md)).
If you embed untrusted SVG from your own users, sanitize it before it reaches the API as well; the server's checks are a safety net, not a content policy.
## Labels
Set the text with `label:`. A `\n` starts a new line. Labels avoid each other, the markers, the route lines and the image edge.
```http
GET /v1/map.png?area=Moravskoslezský+kraj&w=420&h=300&poi=49.5465,18.4472|label:Lysá hora\n1323 m|weight:700|marker:triangle|color:%23202020
```

In GET, `|` cannot appear inside a label; send a POST if you need one. In JSON write a real newline: `"label": "Lysá hora\n1323 m"`.
### Position, size, weight and color
`pos:` puts the label next to the marker (`right`, `left`, `top`, `bottom`, the four corners, or `auto`, the default). `labelsize:`, `weight:` and `labelcolor:` style it. `weight:` overrides the map-wide `fontweight` for this label only; see [Typography](/docs/guides/typography-and-titles.md).
```http
GET /v1/map.png?area=Zlínský+kraj&w=420&h=300&poi=place:Zlín|pos:bottom|labelsize:18|weight:700&poi=place:Kroměříž|pos:left|labelcolor:%231565c0
```

## Crowded points and leader lines
With `pos:auto`, a label first tries the spots next to its marker. If they are all taken, the label moves further out and a thin leader line connects it back. That keeps nearby points readable without you placing anything.
```http
GET /v1/map.png?bounds=17.636,49.212,17.698,49.241&w=480&h=340&poi=49.2266,17.6693|label:Náměstí+Míru&poi=49.2277,17.6685|label:Zámek&poi=49.2282,17.6667|label:Divadlo&poi=49.2277,17.6649|label:Nádraží&poi=49.2266,17.6641|label:Radnice&poi=49.2255,17.6649|label:Muzeum&poi=49.2250,17.6667|label:Park&poi=49.2255,17.6685|label:Škola
```

Turn this off per point with `leader:false`; the label then sits at the nearest position even if it overlaps something:
```http
GET /v1/map.png?bounds=17.636,49.212,17.698,49.241&w=480&h=340&poi=49.2266,17.6693|label:Náměstí+Míru|leader:false&poi=49.2277,17.6685|label:Zámek|leader:false&poi=49.2282,17.6667|label:Divadlo|leader:false&poi=49.2277,17.6649|label:Nádraží|leader:false&poi=49.2266,17.6641|label:Radnice|leader:false&poi=49.2255,17.6649|label:Muzeum|leader:false&poi=49.2250,17.6667|label:Park|leader:false&poi=49.2255,17.6685|label:Škola|leader:false
```

Options: [`poi`](/docs/reference/parameters.md#param-poi) (`pos`, `labelsize`, `weight`, `labelcolor`, `leader`).
---
# Routes
A route is a line on the map with a marker and label at each end. There are three ways to get the line:
1. **You provide it**: a GeoJSON line or an encoded polyline.
2. **The server draws it locally**: `straight` segments or a `geodesic` great circle between your points.
3. **A routing engine computes it**: `car`, `bike` or `foot` along real roads.
GET describes one route. POST takes up to 10 in `routes`, plus extra lines and polygons in `shapes`. Without an `area`, the map is framed on the routes and points, so a route alone is a complete request.
## A line you already have
If you route elsewhere, send the result. As GeoJSON (POST):
```json
{
"routes": [
{
"geometry": {
"type": "LineString",
"coordinates": [[16.6068, 49.1951], [16.9, 49.24], [17.2, 49.23], [17.6667, 49.2266]]
},
"start": { "label": "Brno" },
"finish": { "label": "Zlín" }
}
],
"width": 480,
"height": 280
}
```

Or as an encoded polyline (Google algorithm). Precision 5 is the default; Valhalla and OSRM use 6, which you set with `polylineprecision`. URL-encode the polyline: it can contain `@`, `{`, `|` and other characters that mean something in a URL.
```http
GET /v1/map.png?polyline=klgkHoojdBswGogx%40n%7D%40_ry%40fT%7BczA&fromlabel=Brno&tolabel=Zlín&w=480&h=280
```

Parameters: [`polyline`](/docs/reference/parameters.md#param-polyline), [`polylineprecision`](/docs/reference/parameters.md#param-polylineprecision).
## Straight and geodesic lines
`profile=straight` connects `from`, the `via` points and `to` with straight segments. `profile=geodesic` follows the great circle, which is what you want for flights. Neither needs a routing engine. `geodesic` is the default, so a route without `profile` always renders; road routes need an explicit `profile=car`, `bike` or `foot`.
`from`, `to` and `via` take `lat,lon` or `place:Name`. Repeat `via` for more waypoints; they are visited in order.
```http
GET /v1/map.png?from=49.1951,16.6068&to=49.2266,17.6667&via=49.24,16.9&profile=straight&fromlabel=Brno&tolabel=Zlín&w=480&h=280
```

```http
GET /v1/map.png?from=50.1,14.26&to=40.64,-73.78&profile=geodesic&fromlabel=Prague&tolabel=New+York&routedash=2,1.5&w=520&h=300
```

With place names, the start and finish labels default to the names, so `from=place:Brno&to=place:Zlín` is already labeled.
```http
GET /v1/map.png?from=place:Brno&to=place:Zlín&profile=straight&w=480&h=280
```
The same in POST, with a title and a point of interest on the way:
```json
{
"routes": [
{
"from": { "lat": 40.4168, "lon": -3.7038 },
"to": { "lat": 37.3891, "lon": -5.9845 },
"via": [{ "lat": 38.98, "lon": -3.93 }, { "lat": 38.25, "lon": -4.75 }],
"profile": "straight",
"start": { "label": "Madrid" },
"finish": { "label": "Seville" }
}
],
"pois": [
{ "lat": 39.42, "lon": -3.78, "label": "Crash Site\n(near Urda)", "labelWeight": 700,
"marker": { "shape": "dot", "size": 16, "strokeColor": "#000000", "strokeWidth": 2 } }
],
"title": { "text": "Madrid – Seville" },
"width": 500,
"height": 440
}
```

## Routed profiles (car, bike, foot)
`car`, `bike` and `foot` ask a routing engine for a real route. This needs one of:
- **A configured server.** The operator points `WHEREBOX_VALHALLA_URL` at a Valhalla instance, or sets a key for openrouteservice or GraphHopper. See the [configuration reference](/docs/reference/configuration.md) and [Self-hosting](/docs/guides/self-hosting.md). With nothing configured, a routed profile returns `400 bad-request`.
- **Your own key.** For openrouteservice and GraphHopper, send your key in the `X-Routing-Key` request header, and choose the provider with `routeprovider` (GET) or `provider` (POST). Like `X-Provider-Key`, the header keeps the secret out of URLs and logs, and is scrubbed from error messages.
If the provider finds no route you get `422 no-route`; if it is down or slow, `502` or `504` ([Errors and limits](/docs/guides/errors-and-limits.md)). Public routing demo servers are rate-limited and not for production use.
This request is parsed but not rendered in the docs build, because it needs a routing engine:
```json
{
"routes": [
{
"from": { "place": "Brno" },
"via": [{ "place": "Jihlava" }],
"to": { "place": "Praha" },
"profile": "car",
"provider": "openrouteservice"
}
],
"width": 480,
"height": 320
}
```
Send it with the key in a header, for example `curl -H 'Content-Type: application/json' -H 'X-Routing-Key: YOUR_KEY' --data @route-car.json https://YOUR-HOST/v1/map -o route.png`, where `route-car.json` holds the body above.
The response's `meta` (with `?response=json`) includes the routed distance and duration per route.
## Via points
`via` forces the route through intermediate points, in order, up to 20. In GET repeat the parameter; in POST use an array of endpoints. The straight example above uses one via point. For routed profiles each via point becomes a waypoint the engine must pass.
## Styling the line
Color, width, dash pattern and opacity of the line, and a casing (an outline under the line that helps it read on busy basemaps):
```http
GET /v1/map.png?from=49.1951,16.6068&to=49.2266,17.6667&profile=straight&routecolor=%231565c0&routewidth=4&routedash=2,1.5&routeopacity=0.9&fromlabel=Brno&tolabel=Zlín&w=480&h=280
```

Parameters: [`routecolor`](/docs/reference/parameters.md#param-routecolor), [`routewidth`](/docs/reference/parameters.md#param-routewidth), [`routedash`](/docs/reference/parameters.md#param-routedash), [`routeopacity`](/docs/reference/parameters.md#param-routeopacity), [`routecasing`](/docs/reference/parameters.md#param-routecasing).
## Start and finish
Each end gets a marker and a label. By default the start is a ring and the finish a flag (see [markers](/docs/reference/markers.md)), labeled with the place name if you gave one. Change the label or marker, or hide an end:
```http
GET /v1/map.png?from=49.1951,16.6068&to=49.2266,17.6667&profile=straight&fromlabel=Start&tolabel=Finish&frommarker=dot&tomarker=pin&w=480&h=280
```

```http
GET /v1/map.png?from=49.1951,16.6068&to=49.2266,17.6667&profile=straight&showfrom=false&tolabel=Zlín&w=480&h=280
```
Parameters: [`fromlabel`](/docs/reference/parameters.md#param-fromlabel), [`tolabel`](/docs/reference/parameters.md#param-tolabel), [`frommarker`](/docs/reference/parameters.md#param-frommarker), [`tomarker`](/docs/reference/parameters.md#param-tomarker), [`showfrom`](/docs/reference/parameters.md#param-showfrom), [`showto`](/docs/reference/parameters.md#param-showto). In POST these are `start` and `finish` objects with the full marker and label options.
## Route plus area
A route on its own frames itself. Add an `area` to keep the locator look and frame the whole area instead:
```json
{
"area": "Zlínský kraj",
"routes": [
{
"from": { "place": "Zlín" },
"to": { "place": "Kroměříž" },
"profile": "straight",
"color": "#d01c1c"
}
],
"width": 420,
"height": 300
}
```

## Forcing the frame with `bounds`
`bounds=west,south,east,north` fixes the frame, overriding automatic framing. Use it to keep a series of maps at the same zoom, or to show context around a short route.
```http
GET /v1/map.png?from=49.1951,16.6068&to=49.2266,17.6667&profile=straight&bounds=15.5,48.6,18.5,49.8&w=480&h=280
```

See [`bounds`](/docs/reference/parameters.md#param-bounds).
## Globe projection
Web Mercator stretches everything away from the equator: Greenland looks the size of Africa, and a flight from Alaska to Europe bends into a long arc. For routes that span continents, `projection=globe` draws an orthographic globe centered on your content instead. Shapes keep their true proportions, and great-circle legs look like the shortest paths they are.
```http
GET /v1/map.png?from=64.5011,-165.4064&to=52.52,13.405&via=47.6062,-122.3321&via=40.7128,-74.0060&profile=geodesic&fromlabel=Nome&tolabel=Berlin&poi=47.6062,-122.3321|label:Seattle|marker:dot&poi=40.7128,-74.0060|label:New+York|marker:dot&projection=globe&w=600&h=420
```

By default the whole globe is shown, centered on your content, so the map reads as a globe at a glance. The same request without `projection=globe` gives the familiar Mercator map, with Greenland inflated and the route curving far north.
### Whole globe or zoomed in
`globeview=fit` zooms in until the content fills the image. Only part of the globe is visible, but the route area gets a larger scale and more detail:
```http
GET /v1/map.png?from=64.5011,-165.4064&to=52.52,13.405&via=47.6062,-122.3321&via=40.7128,-74.0060&profile=geodesic&fromlabel=Nome&tolabel=Berlin&poi=47.6062,-122.3321|label:Seattle|marker:dot&poi=40.7128,-74.0060|label:New+York|marker:dot&projection=globe&globeview=fit&w=600&h=420
```

Which one to use:
- **`full`** (default) when the point is *where on Earth* the route goes: a flight across the Pacific, an expedition, a world tour. Everyone recognizes a globe.
- **`fit`** when the route itself matters more than the planet: its exact path, the labels along it, smaller images where every pixel counts.
In JSON the same option is `"globe": { "view": "fit" }`.
`projection=auto` decides for you: the globe when the content spans a large part of the Earth or would be stretched a lot by Mercator, Mercator otherwise (the thresholds are in the [`projection` reference](/docs/reference/parameters.md#param-projection)). A country-sized map stays Mercator, and when `auto` picks the globe it shows the whole globe unless you pass `globeview=fit`.
Things to know:
- Everything framed must fit on one side of the globe. Content too spread out for that is a `400 bad-request`; split it into several maps.
- Neighbor labels are not available on the globe yet.
- Markers, labels and route lines are drawn directly on the globe, so they stay sharp. The basemap is rendered as a flat map and wrapped onto the sphere, so its finest lines get slightly softer toward the globe's edge and near the poles.
---
# Neighboring countries
Neighbor labels name the countries around your area, placed inside each country's visible part. They need an `area`.
## What to show
`neighbors` picks what each label shows: the country name, its international vehicle registration code (D, A, SK, PL), its flag, or a flag combined with the code or the name.
```http
GET /v1/map.png?area=Czechia&poi=place:Chřiby|marker:triangle&w=468&h=280&neighbors=flag+code
```

```http
GET /v1/map.png?area=Czechia&w=468&h=280&neighbors=name
```

Both `flag+code` and `flag code` work; a literal `+` in a query string is read as a space, and the server treats the two the same. `neighbors=false` turns labels off. Values: [`neighbors`](/docs/reference/parameters.md#param-neighbors).
Which countries get a label is `neighborwhich`: `touching` (the default) labels countries that border the area, `visible` labels every other country in the frame too. Size, color and uppercase names are `neighborsize`, `neighborcolor`, `neighborupper`.
```http
GET /v1/map.png?area=iso:CZ-72&w=420&h=320&padding=0.3&neighbors=name&neighborwhich=visible&neighborupper=true
```

## When a label does not fit
A label has to fit inside the visible part of its country. When a frame is tight, a neighbor such as Austria may show only a sliver. `neighborfit` decides what happens:
| Fit | Behavior |
|---|---|
| `extend` (default) | grows the image on the side nearest the country until the label fits |
| `shrink` | reduces the label text |
| `drop` | omits the label |
`extend` is the one to understand: the result can be **larger than the `w` and `h` you asked for.** The real size is in the response headers `X-Image-Width` and `X-Image-Height`, in pixels (already multiplied by `scale`). Read them if your layout depends on the dimensions, or use `shrink` or `drop` for a guaranteed size.
```http
GET /v1/map.png?area=Czechia&w=468&h=270&padding=0&neighbors=flag+name
```

With `padding=0` the country touches the edges, so the neighbors' labels have nowhere to go and the canvas grows. The same request that must keep its size:
```http
GET /v1/map.png?area=Czechia&w=468&h=270&padding=0&neighbors=flag+name&neighborfit=drop
```

In a browser, give the `
` the size you requested and let the headers tell you when you need to adapt. With `?response=json` on POST, the `meta.neighbors` object lists `labeled`, `dropped` and how many pixels each side was `extended`.
Parameters: [`neighborfit`](/docs/reference/parameters.md#param-neighborfit), [`neighborwhich`](/docs/reference/parameters.md#param-neighborwhich).
## Language
Names are local by default: Deutschland, Polska, Slovensko, the way Wikipedia locator maps do it. `lang` switches the label language (a two- or three-letter code):
```http
GET /v1/map.png?area=iso:CZ-72&w=420&h=320&neighbors=name&lang=en&neighborupper=true&preset=dark
```

See [`lang`](/docs/reference/parameters.md#param-lang).
The same in POST, using the nested `neighbors` object:
```json
{
"area": "Czechia",
"neighbors": { "show": "flag+code", "which": "touching", "fit": "shrink", "size": 12 },
"lang": "en",
"width": 468,
"height": 280
}
```
---
# Typography and titles
Everything the API draws (point labels, route labels, neighbor labels, the title) uses one font setting. The basemap keeps its own fonts.
## Fonts and weights
Three families are bundled: `noto-sans` (the default), `noto-serif` and `noto-sans-mono`. `fontweight` sets the weight for all labels; the nearest bundled weight is used, so asking for 650 gives you a real weight rather than a faux-bold.
```http
GET /v1/map.png?area=Zlínský+kraj&poi=place:Zlín&poi=place:Kroměříž&font=noto-serif&fontweight=700&w=400&h=300
```

Parameters: [`font`](/docs/reference/parameters.md#param-font), [`fontweight`](/docs/reference/parameters.md#param-fontweight). A single label can override the weight, see [Points and labels](/docs/guides/points-and-labels.md).
## Fallback
Text is drawn as vector paths from the font files, so output does not depend on fonts installed on the server. If the chosen font lacks a character, that character falls back to Noto Sans, then to Noto Sans Mono (which, for example, has arrows such as "→" that the serif lacks). Latin scripts with diacritics are covered. The bundled fonts also cover several non-Latin scripts (see below); CJK is not covered, so supply your own font for it.
```http
GET /v1/map.png?from=50.1,14.26&to=40.64,-73.78&profile=geodesic&fromlabel=PRG&tolabel=JFK&font=noto-serif&title=PRG+→+JFK&titlepos=bottom&titlealign=center&w=520&h=300
```

## Non-Latin scripts
Labels and titles in Arabic, Hebrew, Devanagari, Thai, Georgian and Armenian work with any font family. Text is shaped with HarfBuzz, so Arabic letters join, Devanagari conjuncts form and Thai marks stack. Mixed right-to-left and left-to-right text (for example "Cairo القاهرة 2026") is reordered for display. For each script the server falls back automatically to a bundled Noto font, at the same weight as your chosen font. CJK is not supported yet; supply your own font for it.
```http
GET /v1/map.png?poi=30.0444,31.2357|label:القاهرة&poi=31.7683,35.2137|label:ירושלים&poi=28.6139,77.209|label:नई+दिल्ली&poi=13.7563,100.5018|label:กรุงเทพมหานคร&poi=41.7151,44.8271|label:თბილისი&w=520&h=320
```

## Custom fonts
Send your own TTF, OTF or WOFF in `font.family` as a `data:` URI, or an `https` URL on a host the operator allows (`WHEREBOX_FONT_HOSTS`, falling back to the icon hosts; see the [configuration reference](/docs/reference/configuration.md)). This is POST only.
This example is parsed but not rendered in the docs build, because it needs a font host that you configure:
```json
{
"area": "Czechia",
"font": { "family": "https://fonts.example.com/brand-regular.woff2", "weight": 500 },
"pois": [{ "place": "Chřiby" }],
"width": 468,
"height": 280
}
```
Missing glyphs still fall back to the bundled fonts. Size and fetch time are capped by the server, and a rejected font is a `400 bad-request`.
## The title band
`title` adds a band above or below the map. The band is **inside** your `w × h`: the map gets the remaining height, so the image size does not change.
```http
GET /v1/map.png?area=Czechia&title=Czechia+and+its+neighbors&titlepos=bottom&titlealign=center&neighbors=name&w=468&h=300
```

Long titles shrink and then get an ellipsis, so they never wrap. Parameters: [`title`](/docs/reference/parameters.md#param-title), [`titlepos`](/docs/reference/parameters.md#param-titlepos), [`titlealign`](/docs/reference/parameters.md#param-titlealign), [`titlesize`](/docs/reference/parameters.md#param-titlesize), [`titleweight`](/docs/reference/parameters.md#param-titleweight), [`titlecolor`](/docs/reference/parameters.md#param-titlecolor), [`titlebg`](/docs/reference/parameters.md#param-titlebg). Title options without `title` are an error.
Colors follow the preset: the dark preset gets a dark band with light text. In POST there are a few more options (divider, padding):
```json
{
"area": "Zlínský kraj",
"title": {
"text": "Zlín Region",
"position": "top",
"align": "left",
"size": 20,
"weight": 700,
"background": "#f3efe6",
"dividerColor": "#c9c2b0",
"padding": 12
},
"width": 420,
"height": 320
}
```

---
# Errors and limits
Every failure is an [RFC 7807](https://www.rfc-editor.org/rfc/rfc7807) `application/problem+json` response. Images and errors never mix: if you asked for a PNG and the status is not 200 (or 304), the body is JSON.
## The shape
```text
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://wherebox.dev/docs/errors#invalid-spec",
"title": "Invalid map spec",
"status": 400,
"detail": "The request does not match the map spec",
"issues": [{ "path": "fade", "message": "…" }]
}
```
`type` is a stable code, `title` a short human label, `status` repeats the HTTP status, and `detail` says what happened. Some errors add fields: `issues` on 400, `suggestions` on 404, `candidates` on 409. The trailing part of `type` (after `#`) is the code, and every code has an entry in the [error reference](/docs/errors.md), which is generated from the same table the server uses. The part before `#` is the address of that entry in the published docs; a self-hosted server can point it at its own copy with `WHEREBOX_DOCS_URL`, so match on the code, not on the whole URL.
## Branch on `type`, not on text
`title` and `detail` are for people and may be reworded. `type` and `status` are the contract. A client usually needs three branches:
```js
const res = await fetch(url);
if (res.ok) return res; // 200 image, or 304 with If-None-Match
const problem = await res.json();
const code = problem.type.split('#')[1]; // "ambiguous", "busy", "invalid-spec", …
switch (code) {
case 'ambiguous': // 409: let the user pick problem.candidates[i].id, retry with area=
case 'not-found': // 404: offer problem.suggestions
case 'invalid-spec': // 400: a bug in your request; log problem.issues
case 'busy': // 503: retry, see below
default: // upstream-failed/-timeout (502/504): retry later or fall back
}
```
Which code means what, in one line each:
| Status | Code | What to do |
|---|---|---|
| 400 | [`invalid-spec`](/docs/errors.md#invalid-spec) | Fix the request. `issues[]` lists each bad parameter by path; unknown parameters come with a "did you mean" hint. Not retryable. |
| 400 | [`bad-request`](/docs/errors.md#bad-request) | Something else is unacceptable as sent: a rejected icon or font, an unsupported extension, routing not configured. Not retryable. |
| 404 | [`not-found`](/docs/errors.md#not-found) | No such area or place. Use `suggestions`, or look it up with `/v1/areas/search`. |
| 409 | [`ambiguous`](/docs/errors.md#ambiguous) | Several areas match; see [Locator maps](/docs/guides/locator-maps.md#ambiguous-names). |
| 422 | [`no-route`](/docs/errors.md#no-route) | The routing engine found no route for that profile. |
| 503 | [`busy`](/docs/errors.md#busy) | The render queue is full. Retry. |
| 502 | [`upstream-failed`](/docs/errors.md#upstream-failed) | A basemap, tile or routing server failed. Retry later. |
| 504 | [`upstream-timeout`](/docs/errors.md#upstream-timeout) | A routing or asset server was too slow. Retry later. |
### Ambiguous names in practice
A name that matches several areas is the one error you should expect in normal use. This request is parsed in the tests, but it is `409` when sent:
```http
GET /v1/map.png?area=Nová+Ves&w=400&h=300
```
The response carries a `candidates` array; each entry has an `id`, an `adminLevel` and a `context`. Show the contexts to a person, then repeat the request with `area=`. Or narrow it up front with `level` and `country`.
### Invalid parameters
Unknown parameters are rejected rather than ignored, so a typo cannot silently produce a different map. `GET /v1/map.png?aera=Czechia` returns `400 invalid-spec` with an issue like `unknown parameter "aera" (did you mean "area"?)`. Out-of-range numbers and malformed colors are reported the same way, each with its `path`.
## Retries on 503
When more renders are requested than the server can run and queue, it answers `503` with `busy` and a `Retry-After` header (in seconds). Retry after that long, with a little jitter, and give up after a few attempts. Retry `502` and `504` the same way. Do not retry `400`, `404`, `409` or `422`: the answer will not change.
Identical concurrent requests share a single render, and a repeated request is served from the cache without taking a render slot, so retry storms on one URL are cheap.
```js
async function fetchMap(url, tries = 4) {
for (let i = 0; i < tries; i++) {
const res = await fetch(url);
if (res.status !== 503 && res.status !== 502 && res.status !== 504) return res;
const wait = Number(res.headers.get('retry-after') ?? 2) * 1000 * (i + 1);
await new Promise((r) => setTimeout(r, wait + Math.random() * 250));
}
throw new Error('map service unavailable');
}
```
The queue size and concurrency are operator settings; see the [configuration reference](/docs/reference/configuration.md).
## Caching and ETags
Maps are content-addressed: the cache key is a hash of the whole request (after defaults are applied) plus the renderer and data versions. The same request always gives the same image until the server's data or renderer changes.
- Image responses carry `ETag` (the hash), `Cache-Control: public, max-age=86400` and `X-Cache: HIT|MISS`. Send `If-None-Match` with the ETag to get an empty `304` when nothing changed.
- GET responses can sit behind a CDN or the browser cache as they are. Because the key covers every parameter, a changed parameter is a new URL and a new image; there is nothing to purge.
- `POST /v1/map?response=json` returns `{url, key, meta}`. The `url` (`/v1/images/{key}`) is immutable (`max-age=31536000, immutable`), so store it or put it in a page. If the cached image has expired, that URL returns `404 not-found`; re-send the POST to render it again.
- Two headers describe the result: `X-Image-Width` and `X-Image-Height` (the real pixel size, which can exceed `w × scale` with [neighbor `extend`](/docs/guides/neighbors.md#when-a-label-does-not-fit)) and `X-Render-Ms` on a fresh render.
Parameters beginning with an underscore (`&_v=2`) are ignored by the parser. Use one if you need a different URL for the same map, for example to get past a stale browser or CDN entry; the server still returns the same cached image.
---
# Fair use
The hosted Wherebox service is free for everyone. To keep it that way, each account gets a monthly allowance and a few limits that protect the service for all users. Most sites and apps never come close: a locator map is rendered once and then served from the cache, and cached maps never count.
## The limits
| | Every account | Raised on request |
|---|---|---|
| Renders per month (UTC) | 10,000 | 100,000 |
| of which routed (car, bike, foot) | 1,000 | 10,000 |
| Requests per minute | 60 | 300 |
| Largest image (width × height × scale²) | 4.2 MP (e.g. 2048 × 2048 px) | 16.8 MP (e.g. 4096 × 4096 px) |
| Road routing | included | included |
Limits apply to the **account**: all of its API keys and connected AI assistants share them. Creating more keys does not raise them.
## What counts
- **A render** is a map that had to be drawn because no identical image was in the cache. Requesting the same map again (same parameters) is a cache hit and is free, also after you reach the limit.
- **A routed render** is a render with a road route (`profile` `car`, `bike` or `foot`). Routing takes much more server time, so it has its own, smaller allowance inside the monthly one. Straight and geodesic lines (`profile=straight`, `profile=geodesic`) are not counted separately.
- **Requests per minute** count every authenticated request, including searches and cache hits.
The [Usage page](/docs/guides/authentication.md#rate-limits-and-quotas) in your account shows where you stand. Every rendered response also carries [`X-Quota-Remaining`](/docs/reference/http.md#header-x-quota-remaining).
## When you reach a limit
- **Monthly allowance:** new renders are refused with `429` [`quota-exceeded`](/docs/errors.md#quota-exceeded) until the 1st of next month (UTC). Maps you have already rendered keep working, because they are served from the cache. If only the routed allowance is used up, maps with straight or geodesic lines still render.
- **Requests per minute:** `429` [`rate-limited`](/docs/errors.md#rate-limited) with `Retry-After`. Wait and retry.
- **Image size:** `403` [`plan-limit`](/docs/errors.md#plan-limit). Ask for a smaller image, or a lower `scale`.
The error's `detail` says which limit was hit and, on the hosted service, where to ask for more.
## Need more?
Higher allowances are free too; they are granted by hand for legitimate projects. Email **[hello@wherebox.dev](mailto:hello@wherebox.dev?subject=Higher%20Wherebox%20limit)** with the subject "Higher Wherebox limit" and tell us:
1. the email address of your Wherebox account,
2. what you are building, with a link if it is public,
3. roughly how many new maps you expect per month, and how many of them with road routes (`car`, `bike`, `foot`),
4. whether you need larger images or more requests per minute.
The **Ask for a higher limit** link on your account's Usage page opens this email with your account details already filled in. We usually answer within a few working days. Meanwhile, keep your URLs stable so repeated views are cache hits, which never count (see [caching](/docs/guides/errors-and-limits.md#caching-and-etags)).
## Please don't
- open several accounts to get around the limits (one account per person or organization; a team shares one),
- download or scrape the search endpoints in bulk, or use the service as a bulk tile or image proxy,
- send load that degrades the service for others.
Accounts that break these rules may be throttled, suspended or closed. Usually you hear from us first, with a chance to fix things; we act without notice only to stop harm to the service or to others. If you think we got it wrong, reply to that email or write to [hello@wherebox.dev](mailto:hello@wherebox.dev) and a person will look at it again. The limits may change over time; this page always shows the current numbers.
## Report abuse
Seen Wherebox used for spam, scraping or anything harmful? Email [hello@wherebox.dev](mailto:hello@wherebox.dev?subject=Abuse%20report) with the subject "Abuse report" and include the URLs or request IDs (the `requestId` in error responses, or the `X-Request-Id` header) you saw.
---
# Authentication (hosted service)
This guide applies to servers that run with `WHEREBOX_AUTH=keys`, such as the hosted service. A self-hosted server with the default `WHEREBOX_AUTH=none` is open and ignores everything below; the `apikey`, `sig` and `exp` parameters are accepted and skipped, so the same URLs work against both.
Every map request needs a key. There are two kinds, and which one you use depends on where the request is made.
## Keys
- A **secret key** (`sk_` followed by 32 characters) goes in the `Authorization: Bearer sk_…` header. Use it from your server.
- A **publishable key** (`pk_` followed by 32 characters) goes in the `apikey` parameter of a signed URL. Use it for `
` tags in pages and emails.
Treat a **secret key** like a password. It authorizes any request for your account, so it must never appear in a URL, a web page, a mobile app or a log. If you put one in a URL, the server refuses the request with [`forbidden`](/docs/errors.md#forbidden), and you should rotate the key, because URLs end up in server logs, browser history and `Referer` headers.
A **publishable key** is designed to be visible. It only works together with a signature, and the signature is what protects you. Each publishable key comes with a **signing secret**, which stays on your server and signs the URLs you embed in pages. Operators get both when they create the key (see [admin commands](#admin-commands)). The token is shown once, so store it right away.
Publishable and secret keys are not interchangeable. A `pk_` key in the `Authorization` header and a signature-less `pk_` key in a URL are both refused with `forbidden`. Sending both a header and `apikey` is refused with [`unauthorized`](/docs/errors.md#unauthorized), because it is unclear which one you meant.
## Server-to-server calls
Send the secret key in the `Authorization` header. Everything else is the same as without authentication, including `POST /v1/map` and the lookup endpoints.
```http
GET /v1/map.png?area=Czechia&poi=place:Chřiby|marker:triangle&neighbors=flag+code&w=468&h=280
```
```bash
curl -H "Authorization: Bearer $WHEREBOX_SECRET_KEY" \
"https://maps.example.com/v1/map.png?area=Czechia&w=468&h=280" -o czechia.png
```
The same header works for `/v1/areas/search`, `/v1/places/search` and `/v1/areas/{id}`. Images fetched by content hash (`/v1/images/{key}`), `/openapi.json`, `/healthz` and `/playground` are public.
## Signed URLs for `
` embeds
A browser cannot keep a secret, so an `
` cannot carry an `sk_` key. Use a publishable key and sign the URL on your server instead:
```html
```
### Why signatures are needed
Anything in a web page is public. Without a signature, anyone could read your `pk_` key from the page source and use it to render any map they like, and every render would count against your quota. With a signature, the `pk_` key alone is useless. The signature is an HMAC over the whole request: the path and every parameter. It can only be produced by whoever holds the signing secret, and changing even one character of the URL invalidates it. A leaked URL shows the same map it always showed. It cannot be turned into a different area, a larger image or another endpoint (the [`sig` parameter](/docs/reference/http.md#param-sig) covers the path and all other parameters, including `exp`).
The signature does not make a URL secret. Anyone who has the URL can request that exact map until it expires, so use [`exp`](#expiring-urls) and [allowed origins](#allowed-origins) when that matters.
### How to sign
Take the URL without `apikey` and `sig`, then:
1. Add `apikey=`, and `exp=` if the URL should expire.
2. Build the **canonical request**: the path, `?`, then every parameter except `sig`, sorted by name and then by value (UTF-16 code unit order), each written as `encodeURIComponent(name)=encodeURIComponent(value)` and joined with `&`. Repeated parameters stay repeated.
3. Compute `HMAC-SHA256` of the canonical request, using the signing secret (hex-decoded to bytes) as the key.
4. Encode the digest as base64url without padding, keep the first 32 characters, and add it as `sig`.
Two details matter. First, sign the *decoded* values. In a query string `+` and `%20` both mean a space, and your canonical request must encode a space as `%20` either way. A literal plus sign (`%2B`) stays `%2B`. Second, the order of parameters in the final URL does not matter, but nothing may be added, removed or changed after signing.
The reference lists the parameters you add: [`apikey`](/docs/reference/http.md#param-apikey), [`sig`](/docs/reference/http.md#param-sig) and [`exp`](/docs/reference/http.md#param-exp).
#### Node.js with `@wherebox/accounts`
Inside this repository (or any service that depends on the workspace package), use the helper the server itself uses. It accepts relative and absolute URLs and replaces an existing `sig`.
```js
import { signUrl } from '@wherebox/accounts';
const url = signUrl(
'https://maps.example.com/v1/map.png?area=Zlínský kraj&w=468&h=280',
process.env.WHEREBOX_PUBLISHABLE_KEY,
process.env.WHEREBOX_SIGNING_SECRET,
{ expiresAt: new Date(Date.now() + 3600 * 1000) },
);
```
#### Node.js without dependencies
This is the same algorithm with only `node:crypto`. It is CommonJS; in an ES module use `import { createHmac } from 'node:crypto'` instead of the first line. The test suite runs this exact snippet against the server's verifier.
```js
const { createHmac } = require('node:crypto');
function canonicalRequest(pathname, searchParams) {
const pairs = [...searchParams.entries()].filter(([name]) => name !== 'sig');
pairs.sort(([an, av], [bn, bv]) => (an < bn ? -1 : an > bn ? 1 : av < bv ? -1 : av > bv ? 1 : 0));
const query = pairs.map(([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`).join('&');
return `${pathname}?${query}`;
}
/** url: absolute URL; signingSecret: the hex signing secret; expiresAt: optional Date. */
function signUrl(url, publishableKey, signingSecret, expiresAt) {
const u = new URL(url);
u.searchParams.delete('sig');
u.searchParams.set('apikey', publishableKey);
if (expiresAt) u.searchParams.set('exp', String(Math.floor(expiresAt.getTime() / 1000)));
const sig = createHmac('sha256', Buffer.from(signingSecret, 'hex'))
.update(canonicalRequest(u.pathname, u.searchParams))
.digest('base64url')
.slice(0, 32);
u.searchParams.set('sig', sig);
return u.toString();
}
```
#### Python
```python
import base64
import hashlib
import hmac
import time
from urllib.parse import parse_qsl, quote, urlsplit, urlunsplit
# Characters that JavaScript's encodeURIComponent leaves alone.
SAFE = "-_.!~*'()"
def _enc(text):
return quote(text, safe=SAFE)
def _utf16_key(pair):
# JavaScript compares strings by UTF-16 code units; Python compares code points.
return (pair[0].encode("utf-16-be"), pair[1].encode("utf-16-be"))
def canonical_request(path, pairs):
kept = sorted(((k, v) for k, v in pairs if k != "sig"), key=_utf16_key)
return path + "?" + "&".join(_enc(k) + "=" + _enc(v) for k, v in kept)
def sign_url(url, publishable_key, signing_secret, expires_at=None):
"""url: absolute URL; signing_secret: the hex signing secret; expires_at: optional Unix seconds."""
parts = urlsplit(url)
pairs = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) if k not in ("sig", "apikey")]
pairs.append(("apikey", publishable_key))
if expires_at is not None:
pairs = [(k, v) for k, v in pairs if k != "exp"] + [("exp", str(int(expires_at)))]
digest = hmac.new(
bytes.fromhex(signing_secret),
canonical_request(parts.path, pairs).encode("utf-8"),
hashlib.sha256,
).digest()
sig = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")[:32]
pairs.append(("sig", sig))
query = "&".join(_enc(k) + "=" + _enc(v) for k, v in pairs)
return urlunsplit((parts.scheme, parts.netloc, parts.path, query, ""))
# signed = sign_url("https://maps.example.com/v1/map.png?area=Czechia&w=468&h=280",
# PUBLISHABLE_KEY, SIGNING_SECRET, expires_at=time.time() + 3600)
```
#### Checking a signed URL
Signed URLs look like this. The signature below is only an illustration, so this request does not authenticate against a real server, but it parses like any other:
```http
GET /v1/map.png?area=Czechia&poi=place:Chřiby&w=468&h=280&apikey=pk_8fK2aQ1mZr9TbL0x4WcYvN3sHd7uJe6p&sig=Jx3w0b2kQnVt9yRmZsLc4u8hAe1pDfGo
```
Names with spaces and accents sign the same way. This one also expires:
```http
GET /v1/map.png?area=Zlínský+kraj&w=468&h=280&apikey=pk_8fK2aQ1mZr9TbL0x4WcYvN3sHd7uJe6p&exp=1798761600&sig=Jx3w0b2kQnVt9yRmZsLc4u8hAe1pDfGo
```
If a signed URL is refused with `invalid URL signature`, the usual causes are a changed parameter, a different path (`.png` vs `.jpg` is a different path), signing with the wrong key's signing secret, or an encoding mismatch (`+` signed as `+` instead of `%20`).
## Expiring URLs
Add `exp` (Unix seconds) before signing and the URL stops working at that moment, with a [`forbidden`](/docs/errors.md#forbidden) response (`signed URL has expired`). Because `exp` is part of the signature, nobody can extend it by editing the URL; you must sign a new one.
Use short lifetimes for maps in emails or one-off pages. For maps that stay on a page for a long time, remember that a new `exp` makes a new URL, so the browser cannot reuse its cached copy of the old one. Rounding `exp` to the next hour or day keeps the URL stable for long enough to be cached.
## Allowed origins
A publishable key can be limited to the sites that may embed it. The server compares the host of the `Origin` header (or the `Referer` header when there is no `Origin`) with the key's list. A pattern is either an exact host (`example.com`) or a subdomain wildcard (`*.example.com`, which matches `www.example.com` and `a.b.example.com` but not `example.com` itself). Look-alike hosts such as `evil-example.com` do not match.
Keep two things in mind. Browsers send these headers on their own, but a script or a `curl` command can send anything, so origins limit casual reuse on other websites and are not a replacement for signatures or expiry. And when a key has allowed origins, a request without either header is refused. If your page uses `referrerpolicy="no-referrer"` or a strict `Referrer-Policy`, the `
` request has no `Referer` and the map is refused; use `strict-origin-when-cross-origin` (the browser default) or leave the key without origins.
## Rate limits and quotas
Each account has a **rate limit**, in requests per minute, set by its plan and shared by all of its keys and connected apps. You can burst up to one minute's worth of requests; the allowance refills continuously. Every authenticated response carries the state:
- [`X-RateLimit-Limit`](/docs/reference/http.md#header-x-ratelimit-limit): requests per minute for the account
- [`X-RateLimit-Remaining`](/docs/reference/http.md#header-x-ratelimit-remaining): requests left right now
- [`X-RateLimit-Reset`](/docs/reference/http.md#header-x-ratelimit-reset): when the allowance is full again (Unix seconds)
When it runs out you get `429` [`rate-limited`](/docs/errors.md#rate-limited) with a [`Retry-After`](/docs/reference/http.md#header-retry-after) header (seconds). Wait that long. Limits are per account, so creating more keys does not raise them.
The **quota** is the number of fresh renders per calendar month (UTC) for the whole account. A plan can also have no quota at all. **Cache hits are free**: a request whose image is already in the cache does not count, and it still works after the quota is used up. The same goes for a request that arrives while an identical render is already running. A response that rendered a new image carries [`X-Quota-Remaining`](/docs/reference/http.md#header-x-quota-remaining), the renders left this month. A cache hit has no such header because nothing was rendered. Renders with a road route (`car`, `bike`, `foot`) can also have their own, smaller monthly allowance. When the quota is used up, a request that would need a new render gets `429` [`quota-exceeded`](/docs/errors.md#quota-exceeded), while images that are already cached keep working. To make good use of the quota, keep your URLs stable (see [caching](/docs/guides/errors-and-limits.md#caching-and-etags)), so repeated views of a map are hits.
Rate limits apply to every request, including lookups. Only map renders are metered against the quota.
Plans also limit the output size and whether server-side routing is included. A request over the plan's pixel limit, or one that asks for a routed `profile` (`car`, `bike`, `foot`) on a plan without routing, is refused with `403` [`plan-limit`](/docs/errors.md#plan-limit) before any rendering starts. Straight and geodesic lines are always allowed. The plan definitions are operator settings (`WHEREBOX_PLANS` in the [configuration reference](/docs/reference/configuration.md)). On the hosted service, which is free, the current numbers are on the [fair use](/docs/guides/fair-use.md) page.
## Errors
All failures are `application/problem+json`, as described in [Errors and limits](/docs/guides/errors-and-limits.md). Branch on `type`:
- `401` [`unauthorized`](/docs/errors.md#unauthorized): no key, a malformed or unknown key, or both a header and `apikey`. Fix the request.
- `403` [`forbidden`](/docs/errors.md#forbidden): the key or account is revoked or disabled, the signature is missing, wrong or expired, the origin is not allowed, a secret key was used in a URL, or a publishable key was used in the header. Retrying the same request will not help.
- `403` [`plan-limit`](/docs/errors.md#plan-limit): the image is larger than the plan allows, or routing is not included.
- `429` [`rate-limited`](/docs/errors.md#rate-limited): wait for `Retry-After`.
- `429` [`quota-exceeded`](/docs/errors.md#quota-exceeded): the monthly quota is used up. Cached maps still work.
```js
const res = await fetch(url, { headers: { authorization: `Bearer ${process.env.WHEREBOX_SECRET_KEY}` } });
if (res.status === 429) {
const code = (await res.json()).type.split('#')[1]; // "rate-limited" or "quota-exceeded"
if (code === 'rate-limited') {
await new Promise((r) => setTimeout(r, Number(res.headers.get('retry-after') ?? 1) * 1000));
// then retry
}
}
```
## Admin commands
Operators manage accounts and keys with the CLI. It needs `DATABASE_URL` and the same `WHEREBOX_AUTH_SECRET` as the API (changing that secret invalidates every key and signing secret). Add `--json` to print JSON.
```bash
pnpm mm account create --email dev@example.com --name "Acme"
pnpm mm account list
pnpm mm account plan plus # e.g. a higher allowance granted on request
pnpm mm account disable # and: enable
pnpm mm key create --kind secret --name backend
pnpm mm key create --kind publishable --name website --origin example.com --origin "*.example.com"
pnpm mm key list
pnpm mm key revoke
pnpm mm usage --from 2026-11-01 --to 2026-11-30
pnpm mm sign "https://maps.example.com/v1/map.png?area=Czechia" --key pk_… --secret --expires 3600
```
Creating a key prints the token once, and for a publishable key also its signing secret. Only a peppered hash of the token is stored, so a lost token cannot be recovered; create a new key and revoke the old one. Plan changes, `disable` and `revoke` reach a running API within about a minute, because it caches key lookups. `usage` lists renders, cache hits and bytes per key and day. `sign` is handy for checking your own signing code: the URL it prints must be identical in meaning to the one your code produces.
---
# TypeScript SDK
`@wherebox/sdk` is a small typed client for the HTTP API. It builds `
` URLs, signs them for the hosted service, renders images, and turns every error into one class you can branch on by `code`. The map spec types and validation are built in, its only dependency is `zod`, and it uses only `fetch` and `globalThis.crypto.subtle`, so the same code runs in browsers, Deno, Bun, Cloudflare Workers and Node 20 or newer. It ships as ESM and CommonJS with type declarations.
The SDK adds no parameters of its own. The map you describe is the [`MapSpec`](/docs/reference/request-body.md) JSON body, and the SDK turns it into query parameters when you ask for a URL.
## Install
```bash
npm install @wherebox/sdk
```
`pnpm add @wherebox/sdk`, `yarn add @wherebox/sdk` and `bun add @wherebox/sdk` work the same way. Inside this repository's workspace, use `pnpm add "@wherebox/sdk@workspace:*"` instead.
## Quick start
Create one client per server and reuse it. `render()` sends `POST /v1/map` and returns the image bytes with the final size, whether it came from the cache, and its `ETag`.
```ts
import { createClient } from '@wherebox/sdk';
const wherebox = createClient({
baseUrl: 'https://maps.example.com',
secretKey: process.env.WHEREBOX_SECRET_KEY, // only on servers; omit for an open self-hosted API
});
const { image, contentType, width, height, cached } = await wherebox.render({
area: 'Czechia',
pois: [{ place: 'Chřiby', marker: { shape: 'triangle' } }],
width: 468,
height: 280,
});
console.log(`${contentType} ${width}x${height}, ${image.byteLength} bytes, cached: ${cached}`);
```
That request, as the JSON body the SDK sends:
```json
{
"area": "Czechia",
"pois": [{ "place": "Chřiby", "marker": { "shape": "triangle" } }],
"width": 468,
"height": 280
}
```
`createClient` takes `baseUrl` and, depending on where the code runs, `secretKey`, or `publishableKey` with `signingSecret`. See [Authentication](/docs/guides/authentication.md) for what each key is. An optional `fetch` replaces the global one, which is handy for tests and instrumentation.
Besides `render()` the client has `renderJson()` (the `?response=json` body: a URL of the cached image plus metadata), `searchAreas()`, `searchPlaces()`, `getArea()` and `url()`.
## Embedding in an `
`
`url()` returns an absolute GET URL, with the format taken from the path extension. It is asynchronous because it may sign the URL.
```ts
import { createClient } from '@wherebox/sdk';
const wherebox = createClient({ baseUrl: 'https://maps.example.com' });
const src = await wherebox.url(
{ area: 'Czechia', pois: [{ place: 'Chřiby', marker: { shape: 'triangle' } }], width: 468, height: 280 },
{ format: 'webp' },
);
const html = `
`;
console.log(html);
```
The same map as a GET request, which is what ends up in `src`:
```http
GET /v1/map.webp?area=Czechia&poi=place:Chřiby|marker:triangle&w=468&h=280
```
The spec is the same as in the quick start, so the same fields exist; the registry that parses GET parameters also writes them, which is why the round trip is exact. Some specs cannot be written as a query string: custom fonts, GeoJSON areas, more than one route, labels containing `|`. For those, `url()` throws `PostOnlyError` listing the offending fields instead of dropping them silently. Use `render()` or `renderJson()` for such maps.
## Signing on a server
The hosted service refuses unsigned URLs from browsers, because anything in a page is public. A **publishable key** (`pk_…`) goes in the URL, and a signature made with its **signing secret** proves the URL came from you (see [How to sign](/docs/guides/authentication.md#how-to-sign)). Give the client both and `url()` signs for you, optionally with an expiry:
```ts
import { createClient, signUrl } from '@wherebox/sdk';
const wherebox = createClient({
baseUrl: 'https://maps.example.com',
publishableKey: process.env.WHEREBOX_PUBLISHABLE_KEY,
signingSecret: process.env.WHEREBOX_SIGNING_SECRET,
});
// Adds apikey, exp and sig. The URL stops working after an hour.
const src = await wherebox.url({ area: 'Czechia', width: 468, height: 280 }, { expiresIn: 3600 });
// Or sign a URL you already have; the output is identical to the server's own signUrl.
const signed = await signUrl(
'https://maps.example.com/v1/map.png?area=Czechia&w=468&h=280',
process.env.WHEREBOX_PUBLISHABLE_KEY ?? '',
process.env.WHEREBOX_SIGNING_SECRET ?? '',
{ expiresAt: new Date(Date.now() + 3600 * 1000) },
);
console.log(src, signed);
```
**Never ship `signingSecret` or `secretKey` to a browser.** Signing with `url()` is for servers and edge functions. In the browser, create the client without keys (or do not use it for signed maps at all) and use URLs that your backend signed. A secret key in a page is a leaked key.
### A Next.js route handler
A route handler that returns a signed URL for a name the page asks about. It uses the standard `Request` and `Response`, so it works in the App Router and, with the same two lines, in any framework built on web standards (an Express route does the same with `req.query` and `res.json`).
```ts
import { createClient, WhereboxError, PostOnlyError } from '@wherebox/sdk';
const wherebox = createClient({
baseUrl: process.env.WHEREBOX_URL ?? 'https://maps.example.com',
publishableKey: process.env.WHEREBOX_PUBLISHABLE_KEY,
signingSecret: process.env.WHEREBOX_SIGNING_SECRET,
});
// app/api/map-url/route.ts
export async function GET(request: Request): Promise {
const area = new URL(request.url).searchParams.get('area');
if (!area) return Response.json({ error: 'area is required' }, { status: 400 });
try {
// Expires in an hour, so a leaked URL stops working.
const src = await wherebox.url({ area, width: 468, height: 280 }, { expiresIn: 3600 });
return Response.json({ src });
} catch (err) {
if (err instanceof PostOnlyError || err instanceof WhereboxError) return Response.json({ error: err.message }, { status: 400 });
throw err;
}
}
```
The page then calls `/api/map-url?area=Czechia` and puts the returned `src` into an `
`.
## Rendering to a file
`render()` gives you a `Uint8Array`. In Node, Deno and Bun you can write it straight to disk. The `format` option sets the image type, and `width` and `height` are the final pixel size, which can exceed `width × scale` when `neighborfit=extend` grew the image.
```ts
import { writeFile } from 'node:fs/promises';
import { createClient } from '@wherebox/sdk';
const wherebox = createClient({ baseUrl: 'http://localhost:3000' });
const map = await wherebox.render(
{ area: 'Czechia', neighbors: { show: 'flag+code' }, width: 468, height: 280, scale: 2 },
{ format: 'webp' },
);
await writeFile('czechia.webp', map.image);
console.log(`saved ${map.width}x${map.height} (${map.etag})`);
```
```json
{
"area": "Czechia",
"neighbors": { "show": "flag+code" },
"width": 468,
"height": 280,
"scale": 2,
"format": "webp"
}
```
Add `routingKey` or `providerKey` to the call options to send the `X-Routing-Key` or `X-Provider-Key` header for routed maps and commercial basemaps; the keys stay out of URLs and logs. `renderJson()` is the better choice when you want to store a link instead of bytes: its `url` points at the cached image and is immutable.
## Looking things up
`searchAreas()` and `searchPlaces()` return the matches directly, and `getArea()` returns one area by id. Their result types (`AreaSummary`, `PlaceSummary`) are exported.
```ts
import { createClient } from '@wherebox/sdk';
const wherebox = createClient({ baseUrl: 'https://maps.example.com', secretKey: process.env.WHEREBOX_SECRET_KEY });
const matches = await wherebox.searchAreas('Nová Ves', { limit: 5 });
for (const area of matches) console.log(area.id, area.adminLevel, area.context);
const first = matches[0];
if (first) {
// Ids from the lookup endpoints are used as `osm:` in a spec.
const map = await wherebox.renderJson({ area: first.ref, width: 400, height: 300 });
console.log(map.url);
}
```
## Error handling
Every non-2xx answer throws a `WhereboxError` built from the [problem+json body](/docs/guides/errors-and-limits.md#the-shape). Branch on `code`, the part of `type` after `#`; it is typed as the error catalog's codes, so a typo in a comparison does not compile. The other fields are `status`, `type`, `title`, `detail`, and, where the server sends them, `issues`, `candidates`, `suggestions` and `retryAfter` (seconds, from the `Retry-After` header). A response that is not a problem document (a proxy's error page, say) has the code `'unknown'`. Network failures are not wrapped; they surface as the `TypeError` that `fetch` throws.
```ts
import { createClient, WhereboxError, type MapSpec } from '@wherebox/sdk';
const wherebox = createClient({ baseUrl: 'https://maps.example.com', secretKey: process.env.WHEREBOX_SECRET_KEY });
async function renderWithRetry(spec: MapSpec, tries = 3) {
for (let attempt = 1; ; attempt++) {
try {
return await wherebox.render(spec);
} catch (err) {
if (!(err instanceof WhereboxError)) throw err;
if (err.code === 'ambiguous') {
// Show err.candidates[i].context to a person, then retry with area: err.candidates[i].ref.
console.log(err.candidates?.map((c) => `${c.id}: ${c.context}`));
} else if (err.code === 'invalid-spec') {
console.error(err.issues); // a bug in the request: [{ path, message }]
} else if (err.code === 'not-found') {
console.log(err.suggestions?.map((s) => s.name));
} else if ((err.code === 'busy' || err.code === 'rate-limited') && attempt < tries) {
await new Promise((resolve) => setTimeout(resolve, (err.retryAfter ?? 1) * 1000));
continue;
}
throw err;
}
}
}
await renderWithRetry({ area: { query: 'Nová Ves', level: 8, country: 'CZ' }, width: 400, height: 300 });
```
```json
{
"area": { "query": "Nová Ves", "level": 8, "country": "CZ" },
"width": 400,
"height": 300
}
```
Which codes exist and what they mean is in the [error reference](/docs/errors.md); for authentication failures (`unauthorized`, `forbidden`, `plan-limit`, `rate-limited`, `quota-exceeded`) see [Authentication](/docs/guides/authentication.md#errors).
## Edge runtimes
Nothing in the SDK needs Node, so a Cloudflare Worker, a Deno Deploy handler or a Bun server can sign URLs with the same code. Keep the keys in the platform's secrets, not in the source, and reuse the client between requests.
```ts
import { createClient } from '@wherebox/sdk';
interface Env {
WHEREBOX_URL: string;
WHEREBOX_PUBLISHABLE_KEY: string;
WHEREBOX_SIGNING_SECRET: string;
}
export default {
// Redirects /map?area=… to a freshly signed image URL.
async fetch(request: Request, env: Env): Promise {
const wherebox = createClient({
baseUrl: env.WHEREBOX_URL,
publishableKey: env.WHEREBOX_PUBLISHABLE_KEY,
signingSecret: env.WHEREBOX_SIGNING_SECRET,
});
const area = new URL(request.url).searchParams.get('area') ?? 'Czechia';
return Response.redirect(await wherebox.url({ area, width: 468, height: 280 }, { expiresIn: 86400 }), 302);
},
};
```
The signature is computed with WebCrypto (HMAC-SHA256) and matches the server's byte for byte; a test signs dozens of awkward URLs (accents, spaces, `+`, repeated parameters, an existing `sig`) with both implementations and checks that the server accepts the SDK's output. A runtime without `crypto.subtle` gets a clear error from `url()` when signing, and unsigned URLs still work.
---
# AI assistants (MCP)
Wherebox has a [Model Context Protocol](https://modelcontextprotocol.io) server, so an AI assistant such as Claude can make maps for you: it renders a map, looks at the image, refines the spec until the map is right, and then gives you a permanent image URL to embed. It can also look up ambiguous names before it renders.
There are two ways to connect:
| | Hosted endpoint | Local server |
|---|---|---|
| Runs | on the Wherebox server, at `/mcp` | on your machine: `npx @wherebox/mcp` |
| Transport | Streamable HTTP | stdio |
| Sign-in | OAuth: you approve the assistant in your browser | a secret key (`sk_…`) in `WHEREBOX_API_KEY` |
| Good for | Claude on the web, desktop and mobile, any client with remote servers | clients that only run local servers, scripts, your own server |
Both offer the same tools, resources and prompts (the [MCP reference](/docs/reference/mcp.md) lists them), and both count like the API: each newly rendered map counts toward your account's monthly allowance, while identical requests are served from the cache for free (see [fair use](/docs/guides/fair-use.md)).
## Hosted endpoint
The endpoint is `https://api.wherebox.dev/mcp`. Add it to your client as a remote (HTTP) MCP server; the first time, the client opens a browser window where you sign in to Wherebox and approve it.
- **Claude (web and desktop):** Settings → Connectors → *Add custom connector*, then paste the URL.
- **Claude Code:**
```bash
claude mcp add --transport http wherebox https://api.wherebox.dev/mcp
```
Without a token, the endpoint answers `401` with a `WWW-Authenticate` header that points the client at the [OAuth metadata](/docs/reference/http.md#header-www-authenticate); MCP clients follow it on their own. Tokens are issued for one resource, so a token approved for the MCP endpoint does not work on the REST API (`/v1`) and the other way round. A secret key (`Authorization: Bearer sk_…`) also works, for clients that let you set headers.
To cut an assistant off, open **Connected apps** in your account and disconnect it: its tokens stop working at once, and it has to ask for your approval again.
Renders made through a connected assistant are metered per app, separately from your API keys.
## Local server
The `@wherebox/mcp` package runs the same server over stdio. It needs Node.js 20 or newer and a secret key from the console:
```bash
claude mcp add wherebox --env WHEREBOX_API_KEY=sk_your_secret_key -- npx -y @wherebox/mcp
```
Clients configured with a JSON file (Claude Desktop, Cursor, …) take the same command:
```text
{
"mcpServers": {
"wherebox": {
"command": "npx",
"args": ["-y", "@wherebox/mcp"],
"env": { "WHEREBOX_API_KEY": "sk_your_secret_key" }
}
}
}
```
The key stays in your client's configuration and is only sent to the API, in the `Authorization` header. `WHEREBOX_API_URL` points the server at another Wherebox API, such as your own (see [Configuration](/docs/reference/configuration.md)). Over plain `http` the key is accepted only for `localhost`.
## Self-hosted servers
A self-hosted server has the same `/mcp` endpoint. With `WHEREBOX_AUTH=none` (the default) it is open like the rest of the API, so connect your client to `http://your-server:8080/mcp`, or run the local server with `WHEREBOX_API_URL=http://your-server:8080` and no key. With `WHEREBOX_AUTH=keys`, use a secret key, or OAuth when `WHEREBOX_PUBLIC_URL` is set (see [Self-hosting](/docs/guides/self-hosting.md)).
## What the assistant sends
`render_map` and `get_map_url` take a map spec: the same JSON as the body of `POST /v1/map`, described in the [request body reference](/docs/reference/request-body.md). For example, an assistant asked "where are the Chřiby hills?" might send:
```json
{
"area": "Czechia",
"pois": [{ "place": "Chřiby", "marker": { "shape": "triangle" } }],
"neighbors": { "show": "name" },
"lang": "en",
"width": 468,
"height": 280
}
```
It gets the image back to check, plus the image's permanent URL (`https://api.wherebox.dev/v1/images/.png`). The URL is content-addressed: the same spec always gives the same URL, it needs no key, and anyone with the link can view the image. That is why the tools return this URL rather than a signed `/v1/map` URL: a signed URL needs your publishable key's signing secret, which the server never stores.
When a name is ambiguous or unknown, the tool returns an error with the same `candidates` or `suggestions` as the API's [`ambiguous`](/docs/errors.md#ambiguous) and [`not-found`](/docs/errors.md#not-found) errors, and the assistant picks one (or asks you) and tries again. Running out of quota, hitting a plan limit or the rate limit also comes back as an error the assistant can explain.
The guides and the reference are available to the assistant as MCP resources (`wherebox://docs/…`), and two prompts, `locator-map` and `route-map`, start a map from a short description.
---
# Python SDK
`wherebox` is a small Python client for the API: it builds and signs map URLs for `
` tags, renders images to bytes or files, and looks up areas and places. It uses only the standard library and works on Python 3.9 or newer.
```bash
pip install wherebox
```
From a checkout of this repository (to try unreleased changes):
```bash
pip install ./sdk/python
```
## Quick start
```python
from wherebox import Client
client = Client(
"https://maps.example.com",
secret_key="sk_…", # server-side calls (render, lookups)
publishable_key="pk_…", # embeds
signing_secret="",
)
spec = {
"area": "Czechia",
"pois": [{"place": "Chřiby", "label": "Chřiby", "marker": {"shape": "triangle"}}],
"neighbors": {"show": "flag+code"},
"width": 468,
"height": 280,
}
img_src = client.url(spec) # a signed GET URL for
result = client.render(spec) # PNG bytes, rendered by POST /v1/map
```
A spec is the JSON body of `POST /v1/map` as a Python dict: see the [request body reference](/docs/reference/request-body.md). The same spec works for `url()` and `render()`. Names, defaults and ranges are in the [reference](/docs/reference/parameters.md); the guides explain what each part does ([locator maps](/docs/guides/locator-maps.md), [points and labels](/docs/guides/points-and-labels.md), [routes](/docs/guides/routes.md)).
Every key is optional. A self-hosted server without `WHEREBOX_AUTH=keys` needs none, and `url()` then returns an unsigned URL.
## Signed URLs for embeds
A page or email can only show a map through a URL, and a [publishable key](/docs/guides/authentication.md) is only accepted with a signature. `Client.url()` builds the URL from the spec and signs it. Do this on your server: the signing secret must never reach a browser.
```python
url = client.url(spec, format="webp", expires_in=3600) # valid for an hour
```
`expires_in` is optional (seconds); the expiry is covered by the signature. Without it the URL works until you rotate the key. `url()` raises `PostOnlyError` when the spec uses something a URL cannot carry (`shapes`, a second route, a GeoJSON area, a custom font, a `|` in a label); its `paths` attribute lists the offending fields. Render those with `client.render()`, which uses `POST`.
To sign a URL you built yourself, use `sign_url(url, publishable_key, signing_secret, expires_at=None)`. The output is identical to the server's own `signUrl` (a cross-language test checks it) and to the snippet in the [authentication guide](/docs/guides/authentication.md#python).
### Django
```python
from django.shortcuts import render
from wherebox import Client
maps = Client("https://maps.example.com", publishable_key=settings.WHEREBOX_PK, signing_secret=settings.WHEREBOX_SIGNING_SECRET)
def office(request):
src = maps.url({"area": "Czechia", "pois": [{"place": "Brno", "label": "Our office"}], "width": 468, "height": 280}, expires_in=3600)
return render(request, "office.html", {"map_src": src}) #
```
### Flask
```python
from flask import Flask, redirect
from wherebox import Client
app = Flask(__name__)
maps = Client("https://maps.example.com", publishable_key=PK, signing_secret=SIGNING_SECRET)
@app.get("/maps/.png")
def city_map(city):
# Sign on demand and redirect: the browser fetches the image from Wherebox, not through your server.
return redirect(maps.url({"area": "Czechia", "pois": [{"place": city}], "width": 468, "height": 280}, expires_in=300))
```
## Rendering to a file
```python
from wherebox import Client
client = Client("https://maps.example.com", secret_key="sk_…")
result = client.render(spec, format="png")
with open("czechia.png", "wb") as f:
f.write(result.image)
print(result.content_type, result.width, result.height, result.cached)
```
`RenderResult` has `image` (bytes), `content_type`, `width` and `height` (the final size from `X-Image-Width` / `X-Image-Height`, which can exceed `width × scale` when neighbor labels extend the canvas), `cached` (the server answered from its cache) and `etag`. `client.render_json(spec)` returns the JSON form instead: a durable `url` for the cached image, its `key` and size.
Keys for a commercial basemap or a routing provider go in headers, not in the spec, so they stay out of logs: `client.render(spec, provider_key="…", routing_key="…")`. See [HTTP: headers](/docs/reference/http.md).
Lookups share the client:
```python
client.search_areas("Zlín", limit=5) # list of dicts with id, name, adminLevel, context
client.search_places("Chřiby")
client.get_area("osm:r51684")
```
## Errors
Every failed request raises `WhereboxError`, built from the [problem+json](/docs/guides/errors-and-limits.md) body:
| Attribute | Meaning |
|---|---|
| `status` | HTTP status (`0` when the server could not be reached; `code` is then `network`) |
| `code` | The catalog code, e.g. `"ambiguous"`; see the [error reference](/docs/errors.md) |
| `type`, `title`, `detail` | The RFC 7807 fields; `detail` says what happened |
| `issues` | `invalid-spec`: `[{path, message}]` per problem |
| `candidates` | `ambiguous`: matching areas with `id`, `adminLevel` and `context` |
| `suggestions` | `not-found`: close matches |
| `retry_after` | Seconds from `Retry-After` (`busy`, `rate-limited`), else `None` |
| `retryable` | `True` for `busy` and `rate-limited` |
Branch on `code`:
```python
from wherebox import WhereboxError
try:
result = client.render({"area": "Nová Ves"})
except WhereboxError as e:
if e.code == "ambiguous":
for c in e.candidates: # pick one, then retry with area=c["id"]
print(c["id"], c["context"])
elif e.code == "not-found":
print("did you mean:", [s["name"] for s in e.suggestions])
elif e.code == "invalid-spec":
for issue in e.issues:
print(issue["path"], issue["message"])
elif e.code in ("unauthorized", "forbidden"):
raise RuntimeError("check the API key") from e
else:
raise
```
## Retries on 503 and 429
`busy` (503) and `rate-limited` (429) are transient: wait `retry_after` seconds and try again. `quota-exceeded` is also a 429, but it lasts until next month or until the limit is raised, so `retryable` is `False` for it.
```python
import time
from wherebox import WhereboxError
def render_with_retries(client, spec, attempts=4):
for attempt in range(attempts):
try:
return client.render(spec)
except WhereboxError as e:
if not e.retryable or attempt == attempts - 1:
raise
time.sleep(e.retry_after if e.retry_after is not None else 2 ** attempt)
```
Cached images never hit the render queue, so identical requests are cheap to repeat. See [errors and limits](/docs/guides/errors-and-limits.md) for caching and ETags.
---
# Self-hosting
Wherebox is one Node 22 API process plus a PostGIS database. Rendering uses MapLibre Native, so the machine needs OpenGL (software GL is fine) and the process is CPU-bound.
## Install
You need Node 22 (MapLibre Native ships prebuilt binaries only for specific Node versions; `.nvmrc` pins it), pnpm, PostgreSQL with PostGIS, and `osmium-tool` for the import.
```bash
git clone && cd wherebox
pnpm install
```
Create a PostGIS database and point `DATABASE_URL` at it (the default is `postgres:///wherebox`).
## Import data
Areas and places come from an OpenStreetMap extract; the neighbor labels come from Natural Earth.
```bash
# Czechia: boundaries and places (about a minute)
mkdir -p data/raw
curl -LO --output-dir data/raw https://download.geofabrik.de/europe/czech-republic-latest.osm.pbf
pnpm import:cz
# World countries for neighbor labels (about 30 seconds)
pnpm import:countries
```
The importer rebuilds the tables and swaps them in atomically, so re-importing a fresh extract does not interrupt a running server. Country-level areas (`area=Japan`, `iso:IT`, `wikidata:Q17`) fall back to Natural Earth when the OSM import does not cover them, so `import:countries` makes every country available as an `area`. Regions, cities and other finer areas outside the extract return `404 not-found`, which is why the examples in these docs stick to Czech places. Without `import:countries`, maps work but `neighbors` has no countries to label.
## Start the API
```bash
pnpm --filter @wherebox/api start # http://localhost:8080/playground
```
Check it with a first map:
```http
GET /v1/map.png?area=Czechia&w=468&h=280
```
`/healthz` says the process is up; `/readyz` also checks the database and reports the data version and the render queue. `/openapi.json` and `/playground` are served by the API itself, and `/` redirects to the playground. The API does not serve these docs: they are published at https://wherebox.dev/docs, and the Markdown sources are in the `docs/` folder of the repository. Problem `type` URIs link to the published error reference; set `WHEREBOX_DOCS_URL` to point them at your own copy.
## Configuration
Everything is configured with environment variables: the database, the port, the image cache (disk, S3-compatible or none), render concurrency and queue, routing, and the allow-lists for remote icons and fonts. The authoritative list, with defaults and which values are secrets, is the [configuration reference](/docs/reference/configuration.md); this guide does not repeat it.
Three decisions you will actually make:
- **Cache.** The disk cache is capped and evicts the least-recently-used images. Use an S3-compatible store if you run more than one API process, so they share it.
- **Remote icons and fonts.** Both are off until you list trusted hosts. Leave them off unless you need them.
- **Public URL.** Set `WHEREBOX_PUBLIC_URL` when the API sits behind a proxy, so `?response=json` returns correct image URLs.
## Docker and Compose
> **Unverified until the first deployment.** The Dockerfile and compose file were written without a Docker host available. Treat the first `docker compose up --build` as the real test and expect to fix something.
The repository's `docker-compose.yml` runs `db` (PostGIS), `api` and `caddy` (TLS in front of the API), plus an `importer` service under the `tools` profile. The image is based on Ubuntu 24.04 because the MapLibre Native binaries target it, and it starts Xvfb for headless GL.
```bash
# .env needs at least WHEREBOX_DOMAIN and POSTGRES_PASSWORD (see deploy/README.md)
docker compose up -d --build
deploy/import.sh https://download.geofabrik.de/europe/czech-republic-latest.osm.pbf
```
The step-by-step runbook (sizing, firewall, DNS, backups, troubleshooting) is deploy/README.md.
## Routing engine
`car`, `bike` and `foot` routes need a routing engine; `straight`, `geodesic` and given lines do not. Options:
- **Valhalla** (the default provider): self-host it and set `WHEREBOX_VALHALLA_URL`. This is the recommended production setup.
- **openrouteservice** or **GraphHopper**: set `WHEREBOX_ROUTING_PROVIDER` and a server-side key, or let callers send their own `X-Routing-Key`.
The public Valhalla demo instance run for OpenStreetMap is for development only (about one request per second, not for third-party production use). Do not point production at it. Without a configured engine, routed profiles return `400 bad-request`; see [Routes](/docs/guides/routes.md) for the request side.
## Geocoder fallback
`place:` points and route ends are looked up in the imported OSM places. For names outside your import, set `WHEREBOX_GEOCODER_URL` to a geocoder. The database is always asked first, and the geocoder only for names it does not know. A server without a database can use the geocoder alone.
- **Nominatim** (the default `WHEREBOX_GEOCODER_PROVIDER`), self-hosted or any Nominatim-compatible service. Put a key in the URL if the service needs one (`https://…/?key=…`); it is never shown in errors.
- **Photon**: set `WHEREBOX_GEOCODER_PROVIDER=photon`.
Results are cached in the process (found places for a week, misses for an hour), identical lookups share one request, and requests are spaced to `WHEREBOX_GEOCODER_RPS` per second. The public `nominatim.openstreetmap.org` and `photon.komoot.io` instances are for development only: their usage policies allow about one request per second, and Nominatim requires a `WHEREBOX_GEOCODER_USER_AGENT` that identifies your application. Watch `wherebox_geocoder_total` in [metrics](/docs/reference/metrics.md). All variables are in the [configuration reference](/docs/reference/configuration.md#geocoding).
---
# API reference
Generated from the code: edit the registries in `packages/spec`, not these pages. See CONTRIBUTING-DOCS.
| Page | Contents |
|---|---|
| [Query parameters](/docs/reference/parameters.md) | 65 GET parameters, 13 POI options and the POST-only fields |
| [Request body](/docs/reference/request-body.md) | The JSON body of `POST /v1/map` as a field tree, plus the JSON Schema |
| [HTTP: auth and headers](/docs/reference/http.md) | 3 auth parameters and 19 request/response headers |
| [Metrics](/docs/reference/metrics.md) | 13 Prometheus metrics at /metrics |
| [MCP server](/docs/reference/mcp.md) | 4 tools, the docs as resources, 2 prompts |
| [Errors](/docs/errors.md) | 13 problem+json error codes with sample bodies |
| [Configuration](/docs/reference/configuration.md) | 59 environment variables |
| [Markers](/docs/reference/markers.md) | 9 marker presets and the role defaults |
| [Basemap presets](/docs/reference/presets.md) | 3 presets with their highlight and text defaults |
| [openapi.json](/docs/openapi.json) | OpenAPI 3.1 document |
---
# Query parameters
Parameters for `GET /v1/map` and `GET /v1/map.{ext}`. The same options exist in the JSON body of `POST /v1/map` (see [Request body](/docs/reference/request-body.md)).
## Area and framing
What the map is about and how it is framed. All of these are optional, but a map needs an `area`, `bounds`, a route or at least one point.
| Parameter | Value | Default | Description | Example |
|---|---|---|---|---|
| `area` | string \| object | — | The area to frame and highlight. Ambiguous names return 409 with candidates. Name, `iso:CZ`, `iso:CZ-72`, `osm:r51684` or `wikidata:Q213`. Country-level areas fall back to Natural Earth when the OSM import does not cover them (`area=Japan`, `iso:IT`); regions and cities need the OSM import. | `area=Czechia` |
| `level` | integer, 1–11 | — | Only match areas at this OSM admin level (2 = country; regions and municipalities vary by country). Narrows a name lookup (turns `area` into `{query, level}`). | `level=8` |
| `country` | string | — | Only match areas inside this ISO 3166-1 alpha-2 country. Narrows a name lookup. | `country=CZ` |
| `bounds` | string | — | Explicit frame `[west, south, east, north]`; overrides automatic framing. `west,south,east,north`. | `bounds=12.09,48.55,18.86,51.06` |
| `padding` | number, 0–0.45 | `0.04` | Space around the framed content as a fraction of the image size. | `padding=0.04` |
| `projection` | `mercator` \| `globe` \| `auto` | `mercator` | Map projection. `mercator` is the usual flat web map. `globe` draws an orthographic globe, which keeps continent-spanning routes and high latitudes true to shape; the content must fit on one hemisphere (within 85° of the view center), and neighbor labels are not available. `auto` picks the globe when the content spans more than 50° or Mercator would stretch it more than 2.5× between its lowest and highest latitude, else Mercator. `mercator` (default), `globe` or `auto`. | `projection=globe` |
| `globeview` | `full` \| `fit` | `full` | How much of the globe to show. `full` (default): the whole globe, centered on your content, with space around it; recognizable at a glance as a globe. `fit`: zoomed in until the content fills the image; a larger scale with more detail along the route, but only part of the globe is visible. `full` (default) or `fit`. | `globeview=fit` |
## Points
Labeled markers. Repeat `poi` for each point; options go after the location, separated by `|`.
| Parameter | Value | Default | Description | Example |
|---|---|---|---|---|
| `poi` | string (repeatable) | — | Points with markers and labels (up to 50). `lat,lon` or `place:Name`, followed by `\|option:value` pairs (see POI options). Repeat for more points. | `poi=49.13,17.33\|label:Chřiby\|marker:triangle` |
## Route
One route per GET request (use POST for several). Give `from` and `to`, or a `polyline`.
| Parameter | Value | Default | Description | Example |
|---|---|---|---|---|
| `from` | string | — | Start point when the server should compute the line. `lat,lon` or `place:Name`. A route needs `from`+`to` or `polyline`. | `from=40.4168,-3.7038` |
| `to` | string | — | Finish point when the server should compute the line. | `to=place:Sevilla` |
| `via` | string (repeatable) | — | Points the route must pass through, in order. | `via=38.98,-3.93` |
| `profile` | `car` \| `bike` \| `foot` \| `straight` \| `geodesic` | `geodesic` | How endpoints are connected: routed by `car`/`bike`/`foot` (needs a routing engine on the server or your `X-Routing-Key`), or locally as `straight` segments or `geodesic` great-circle arcs. The default `geodesic` always works (no routing engine, key or plan feature needed) and is the true shortest path, so it suits flights and locator maps; ask for `car`/`bike`/`foot` explicitly for roads. | `profile=straight` |
| `polyline` | string | — | The line as an encoded polyline (Google algorithm). URL-encode it (polylines can contain `\|`, `?` and `\`). | ``polyline=_p~iF~ps\|U_ulLnnqC_mqNvxq`@`` |
| `polylineprecision` | `5` \| `6` | `5` | Polyline precision: 5 (Google) or 6 (Valhalla/OSRM). | `polylineprecision=6` |
| `routeprovider` | `valhalla` \| `openrouteservice` \| `graphhopper` | — | Routing provider (default: the server's). openrouteservice and GraphHopper need a key (server-side or `X-Routing-Key`). | `routeprovider=valhalla` |
| `routecolor` | string | `#d01c1c` | Color of the route line. | `routecolor=#d01c1c` |
| `routewidth` | number, 0.5–20 | `3` | Line width in px. | `routewidth=3` |
| `routecasing` | string | `#ffffff` | Outline drawn under the line so it reads on busy basemaps. | `routecasing=#ffffff` |
| `routedash` | string | — | Dash pattern in line widths, e.g. `[2, 1.5]`. Comma-separated. | `routedash=2,1.5` |
| `routeopacity` | number, 0–1 | `1` | Line opacity. | `routeopacity=0.9` |
| `fromlabel` | string | — | Label at this end (default: the place name, if the end was given as a place). | `fromlabel=Madrid` |
| `tolabel` | string | — | Label at this end (default: the place name, if the end was given as a place). | `tolabel=Sevilla` |
| `frommarker` | `pin` \| `dot` \| `ring` \| `flag` \| `triangle` \| `circle` \| `square` \| `star` \| `diamond` | — | Marker preset. Defaults depend on the role: plain point → `pin`, route start → `ring`, route finish → `flag`. | `frommarker=dot` |
| `tomarker` | `pin` \| `dot` \| `ring` \| `flag` \| `triangle` \| `circle` \| `square` \| `star` \| `diamond` | — | Marker preset. Defaults depend on the role: plain point → `pin`, route start → `ring`, route finish → `flag`. | `tomarker=pin` |
| `showfrom` | boolean | `true` | `false` hides the marker and label for this end. | `showfrom=false` |
| `showto` | boolean | `true` | `false` hides the marker and label for this end. | `showto=false` |
## Image
Size, format and attribution of the output image.
| Parameter | Value | Default | Description | Example |
|---|---|---|---|---|
| `w`
alias `width` | integer, 64–2048 | `600` | Image width in CSS px (the output is width × `scale` pixels). | `w=468` |
| `h`
alias `height` | integer, 64–2048 | `400` | Image height in CSS px, including the title band if any. | `h=280` |
| `scale` | integer, 1–3 | `1` | Pixel ratio for high-DPI output (2 = retina). | `scale=2` |
| `format` | `png` \| `jpeg` \| `webp` | `png` | Image format (GET can also use the path extension: /v1/map.png, .jpg, .webp). Or use the path extension: `/v1/map.webp`. | `format=webp` |
| `quality` | integer, 1–100 | `90` | JPEG/WebP quality. | `quality=85` |
| `attribution` | `bottom-right` \| `bottom-left` \| `top-right` \| `top-left` | `bottom-right` | Corner for the data attribution (always drawn; required by the data licenses). | `attribution=bottom-left` |
## Basemap
The map underneath: a built-in preset or your own MapLibre style.
| Parameter | Value | Default | Description | Example |
|---|---|---|---|---|
| `preset` | `locator-relief` \| `light` \| `dark` | `locator-relief` | Built-in basemap: shaded relief (Wikipedia locator style), light, or dark. No key needed. | `preset=dark` |
| `style` | string | — | Any MapLibre style.json URL; overrides `preset`. `{key}` in the URL is replaced with `providerKey`. URL-encode it. | `style=https://api.maptiler.com/maps/streets-v2/style.json?key={key}` |
| `key` | string | — | Key for a commercial style (prefer the `X-Provider-Key` header so it stays out of URLs). Prefer the `X-Provider-Key` header so the key stays out of URLs and logs. | `key=your-provider-key` |
## Highlight
How the area stands out. Unset values use the defaults of the chosen basemap preset.
| Parameter | Value | Default | Description | Example |
|---|---|---|---|---|
| `fade` | number, 0–1 | `0.45` (varies by preset) | How much the surroundings are washed out: 0 = not at all, 1 = fully covered. | `fade=0.6` |
| `fadecolor` | string | `#ffffff` (varies by preset) | Color the surroundings fade towards. URL-encode `#` as `%23`. | `fadecolor=#ffffff` |
| `outline` | string | `#555555` (varies by preset) | Area outline color. | `outline=#555555` |
| `outlinewidth` | number, 0–10 | `1.1` (varies by preset) | Area outline width in px; 0 = none. | `outlinewidth=2` |
| `tint` | string | — | Optional color tint drawn over the area. | `tint=#d03030` |
| `tintopacity` | number, 0–1 | `0.15` (varies by preset) | Opacity of `tint`. | `tintopacity=0.12` |
| `subdivisions` | boolean \| string \| integer, `auto`, 3–11 | `auto` (varies by preset) | Inner admin borders: `auto` draws the next level that splits the area, a number forces that admin level, `false` hides them. `auto`, `false`, or an admin level number. | `subdivisions=auto` |
| `desaturate` | number, 0–1 | `0` (varies by preset) | Outside the area: 0 = full color, 1 = grayscale (applied on top of `fade`). | `desaturate=0.8` |
| `blur` | number, 0–20 | `0` (varies by preset) | Outside the area: blur radius in CSS px; 0 = sharp. | `blur=1.5` |
| `subarea` | string \| object | — | A second, smaller area highlighted inside the framed one (e.g. a region within its country). | `subarea=Zlínský kraj` |
| `subareacolor` | string | `#d03030` (varies by preset) | Fill and outline color of `subArea`. | `subareacolor=#c0392b` |
| `subareaopacity` | number, 0–1 | `0.35` (varies by preset) | Fill opacity of `subArea`. | `subareaopacity=0.35` |
| `subareaoutlinewidth` | number, 0–10 | `1.2` (varies by preset) | Outline width of `subArea` in px; 0 = none. | `subareaoutlinewidth=1.2` |
| `worldview` | `ar` \| `bd` \| `br` \| `cn` \| `de` \| `eg` \| `es` \| `fr` \| `gb` \| `gr` \| `id` \| `il` \| `in` \| `it` \| `jp` \| `kr` \| `ma` \| `nl` \| `np` \| `pk` \| `pl` \| `ps` \| `pt` \| `ru` \| `sa` \| `se` \| `tr` \| `tw` \| `ua` \| `us` \| `vn` | — | Draw country borders as this country sees them (ISO 3166-1 alpha-2, e.g. `in`, `cn`, `us`, `ma`). Replaces the basemap's country and disputed borders with Natural Earth's borders for that worldview, and uses its country shapes for a highlighted country and for neighbor labels. Regions, cities and everything below country level stay OpenStreetMap. Default: the basemap's borders (OpenStreetMap, on-the-ground control). One of the 31 Natural Earth worldviews; the Locator maps guide explains what changes ("Disputed borders"). | `worldview=in` |
## Neighboring countries
Labels for the countries around the area. Requires `neighbors`; the other options refine it.
| Parameter | Value | Default | Description | Example |
|---|---|---|---|---|
| `neighbors` | `name` \| `code` \| `flag` \| `flag+code` \| `flag+name` | `name` | What to show for each neighboring country: its name, its international vehicle registration code (D, A, SK…), its flag, or a flag with code/name. `false`/`none` turns them off. A literal `+` may arrive as a space; both work. | `neighbors=flag+code` |
| `neighborfit` | `extend` \| `shrink` \| `drop` | `extend` | When a label does not fit in the visible part of its country: `extend` grows the image on that side (the output becomes larger than width×height; see `X-Image-Width`), `shrink` reduces the label to 70 %, `drop` omits it. | `neighborfit=extend` |
| `neighborwhich` | `touching` \| `visible` | `touching` | `touching` = countries bordering the area; `visible` = every other country in the frame. | `neighborwhich=visible` |
| `neighborsize` | number, 6–32 | `12` | Text size in CSS px; flags are sized to match. | `neighborsize=12` |
| `neighborcolor` | string | — | Text color (default: the theme's muted label color). | `neighborcolor=#666666` |
| `neighborupper` | boolean | `false` | Uppercase country names. | `neighborupper=true` |
## Labels and typography
Language and font of everything drawn on top of the basemap.
| Parameter | Value | Default | Description | Example |
|---|---|---|---|---|
| `lang` | string | — | Label language (ISO 639-1). Default: local names (Deutschland, Polska), as on Wikipedia locator maps. | `lang=en` |
| `font` | `noto-sans` \| `noto-serif` \| `noto-sans-mono` | `noto-sans` | A bundled family, or your own TTF/OTF/WOFF as a `data:` URI or an https URL on an allowed host (`WHEREBOX_FONT_HOSTS`). Missing glyphs fall back to Noto Sans, then Noto Sans Mono. Bundled families only in GET; custom fonts via POST. | `font=noto-serif` |
| `fontweight` | integer, 100–900 | `400` | Weight for all labels (100–900; the nearest bundled weight is used). | `fontweight=600` |
## Title band
An optional title band inside the requested width and height. Requires `title`; the other options refine it.
| Parameter | Value | Default | Description | Example |
|---|---|---|---|---|
| `title` | string | — | Title text (one line; long titles shrink, then get an ellipsis). | `title=Madrid – Seville` |
| `titlepos` | `top` \| `bottom` | `top` | Band above or below the map. | `titlepos=bottom` |
| `titlealign` | `left` \| `center` \| `right` | `left` | Text alignment in the band. | `titlealign=center` |
| `titlesize` | number, 8–64 | `18` | Text size in CSS px. | `titlesize=20` |
| `titleweight` | integer, 100–900 | `600` | Font weight. | `titleweight=700` |
| `titlecolor` | string | — | Text color (default: the theme label color). | `titlecolor=#1a1a1a` |
| `titlebg` | string | — | Band background (default: white, or near-black on the dark preset). | `titlebg=#ffffff` |
## POI options (inside `poi=`)
A `poi` value is a location followed by `|key:value` options: `lat,lon|label:Chřiby|marker:triangle`. The location is `lat,lon` or `place:Name`.
| Option | Value | Default | Description | Example |
|---|---|---|---|---|
| `label` | string | — | Label text. A newline (`\n` in GET) starts a new line. `\n` (backslash-n) starts a new line. `\|` cannot appear in a label in GET; use POST. | `label:Chřiby` |
| `pos` | `auto` \| `right` \| `left` \| `top` \| `bottom` \| `top-right` \| `top-left` \| `bottom-right` \| `bottom-left` | `auto` | Where the label goes relative to the marker. `auto` picks the first free spot and falls back to a leader line. | `pos:bottom` |
| `labelsize` | number, 6–48 | `16` | Label text size in CSS px. | `labelsize:16` |
| `weight` | integer, 100–900 | — | Font weight for this label (overrides `font.weight`). | `weight:700` |
| `labelcolor` | string | — | Label color (default: the basemap theme's label color). | `labelcolor:#1a1a1a` |
| `leader` | boolean | `true` | When no free spot is next to the marker, place the label further out with a thin leader line. | `leader:false` |
| `marker` | `pin` \| `dot` \| `ring` \| `flag` \| `triangle` \| `circle` \| `square` \| `star` \| `diamond` | — | Marker preset. Defaults depend on the role: plain point → `pin`, route start → `ring`, route finish → `flag`. | `marker:triangle` |
| `color` | string | — | Marker fill color. | `color:#e02020` |
| `size` | number, 4–64 | — | Marker height in CSS pixels (multiplied by `scale`). Default depends on the shape. | `size:14` |
| `stroke` | string | — | Outline color (default white where the shape has an outline). | `stroke:#ffffff` |
| `strokewidth` | number, 0–8 | — | Outline width in px; 0 = none. `dot` and `pin` have a thin default outline. | `strokewidth:1.5` |
| `icon` | string | — | Custom icon instead of the shape: an SVG/PNG `data:` URI, or an https URL on a host the server allows (`WHEREBOX_ICON_HOSTS`). SVGs with external references or scripts are rejected. URL-encode the value (base64 contains `+`, `/` and `=`). | `icon:data:image/svg+xml;base64,PHN2Zy8+` |
| `anchor` | `center` \| `bottom` | — | Which point of a custom icon sits on the location: its center, or its bottom edge (pin-shaped icons). | `anchor:bottom` |
## POST-only
These MapSpec fields cannot be set from a query string; send them in the JSON body of `POST /v1/map`.
- `shapes`
- `routes.1+`
- `font.family (custom fonts)`
- `area (GeoJSON)`
- `pois.*.marker.icon (long data URIs work but POST is recommended)`
## Unknown parameters and cache busters
An unknown parameter is rejected with `400` ([`invalid-spec`](/docs/errors.md#invalid-spec)) and, when one is close, a "did you mean" hint. Parameters starting with `_` (for example `_v=3`) are ignored by the parser, so they can be used to bust caches.
---
# Request body
The JSON body of `POST /v1/map`. Every field of the map spec, as a tree: `routes[]` means "each element of `routes`". Fields without a default are optional unless a description says otherwise.
| Path | Type / values | Default | Description |
|---|---|---|---|
| `area.query` | string | — | Name or id, as in the string form. |
| `area.level` | integer, 1–11 | — | Only match areas at this OSM admin level (2 = country; regions and municipalities vary by country). |
| `area.country` | string | — | Only match areas inside this ISO 3166-1 alpha-2 country. |
| `area.geojson.type` | `Polygon` \| `MultiPolygon` \| `Feature` | — | GeoJSON type. |
| `area.geojson` | object | — | A GeoJSON Polygon, MultiPolygon, or Feature with one of those geometries. |
| `area` | string \| object | — | The area to frame and highlight. Ambiguous names return 409 with candidates. |
| `bounds` | array | — | Explicit frame `[west, south, east, north]`; overrides automatic framing. |
| `routes[].geometry.type` | `LineString` \| `MultiLineString` \| `Feature` | — | GeoJSON type. |
| `routes[].geometry` | object | — | A line you already have (e.g. from your own routing provider). |
| `routes[].polyline` | string | — | The line as an encoded polyline (Google algorithm). |
| `routes[].polylinePrecision` | `5` \| `6` | `5` | Polyline precision: 5 (Google) or 6 (Valhalla/OSRM). |
| `routes[].from.lat` | number, -90–90 | — | Latitude (WGS84). |
| `routes[].from.lon` | number, -180–180 | — | Longitude (WGS84). |
| `routes[].from.place` | string | — | Place name to look up (imported OSM places first, then the server's geocoder if configured); its name becomes the default label. |
| `routes[].from` | object | — | Start point when the server should compute the line. |
| `routes[].to.lat` | number, -90–90 | — | Latitude (WGS84). |
| `routes[].to.lon` | number, -180–180 | — | Longitude (WGS84). |
| `routes[].to.place` | string | — | Place name to look up (imported OSM places first, then the server's geocoder if configured); its name becomes the default label. |
| `routes[].to` | object | — | Finish point when the server should compute the line. |
| `routes[].via[].lat` | number, -90–90 | — | Latitude (WGS84). |
| `routes[].via[].lon` | number, -180–180 | — | Longitude (WGS84). |
| `routes[].via[].place` | string | — | Place name to look up (imported OSM places first, then the server's geocoder if configured); its name becomes the default label. |
| `routes[].via[]` | object | — | A route end or via point: `{lat, lon}` or `{place}`. |
| `routes[].via` | array | `[]` | Points the route must pass through, in order. |
| `routes[].profile` | `car` \| `bike` \| `foot` \| `straight` \| `geodesic` | `geodesic` | How endpoints are connected: routed by `car`/`bike`/`foot` (needs a routing engine on the server or your `X-Routing-Key`), or locally as `straight` segments or `geodesic` great-circle arcs. The default `geodesic` always works (no routing engine, key or plan feature needed) and is the true shortest path, so it suits flights and locator maps; ask for `car`/`bike`/`foot` explicitly for roads. |
| `routes[].provider` | `valhalla` \| `openrouteservice` \| `graphhopper` | — | Routing provider (default: the server's). openrouteservice and GraphHopper need a key (server-side or `X-Routing-Key`). |
| `routes[].color` | string | `#d01c1c` | Color of the route line. |
| `routes[].width` | number, 0.5–20 | `3` | Line width in px. |
| `routes[].casingColor` | string | `#ffffff` | Outline drawn under the line so it reads on busy basemaps. |
| `routes[].casingWidth` | number, 0–10 | `1.5` | Outline width on each side in px; 0 = none. |
| `routes[].dash` | array | — | Dash pattern in line widths, e.g. `[2, 1.5]`. |
| `routes[].opacity` | number, 0–1 | `1` | Line opacity. |
| `routes[].start.label` | string | — | Label at this end (default: the place name, if the end was given as a place). |
| `routes[].start.labelPosition` | `auto` \| `right` \| `left` \| `top` \| `bottom` \| `top-right` \| `top-left` \| `bottom-right` \| `bottom-left` | `auto` | Where the label goes relative to the marker. `auto` picks the first free spot and falls back to a leader line. |
| `routes[].start.labelSize` | number, 6–48 | `16` | Label text size in CSS px. |
| `routes[].start.labelWeight` | integer, 100–900 | — | Font weight for this label (overrides `font.weight`). |
| `routes[].start.labelColor` | string | — | Label color (default: the basemap theme's label color). |
| `routes[].start.marker.shape` | `pin` \| `dot` \| `ring` \| `flag` \| `triangle` \| `circle` \| `square` \| `star` \| `diamond` | — | Marker preset. Defaults depend on the role: plain point → `pin`, route start → `ring`, route finish → `flag`. |
| `routes[].start.marker.color` | string | — | Marker fill color. |
| `routes[].start.marker.size` | number, 4–64 | — | Marker height in CSS pixels (multiplied by `scale`). Default depends on the shape. |
| `routes[].start.marker.strokeColor` | string | — | Outline color (default white where the shape has an outline). |
| `routes[].start.marker.strokeWidth` | number, 0–8 | — | Outline width in px; 0 = none. `dot` and `pin` have a thin default outline. |
| `routes[].start.marker.icon` | string | — | Custom icon instead of the shape: an SVG/PNG `data:` URI, or an https URL on a host the server allows (`WHEREBOX_ICON_HOSTS`). SVGs with external references or scripts are rejected. |
| `routes[].start.marker.anchor` | `center` \| `bottom` | — | Which point of a custom icon sits on the location: its center, or its bottom edge (pin-shaped icons). |
| `routes[].start.marker` | object | — | Marker style; every field is optional and falls back to the role default. |
| `routes[].start.show` | boolean | `true` | `false` hides the marker and label for this end. |
| `routes[].start` | object | `{}` | Marker and label for one end of the route. |
| `routes[].finish.label` | string | — | Label at this end (default: the place name, if the end was given as a place). |
| `routes[].finish.labelPosition` | `auto` \| `right` \| `left` \| `top` \| `bottom` \| `top-right` \| `top-left` \| `bottom-right` \| `bottom-left` | `auto` | Where the label goes relative to the marker. `auto` picks the first free spot and falls back to a leader line. |
| `routes[].finish.labelSize` | number, 6–48 | `16` | Label text size in CSS px. |
| `routes[].finish.labelWeight` | integer, 100–900 | — | Font weight for this label (overrides `font.weight`). |
| `routes[].finish.labelColor` | string | — | Label color (default: the basemap theme's label color). |
| `routes[].finish.marker.shape` | `pin` \| `dot` \| `ring` \| `flag` \| `triangle` \| `circle` \| `square` \| `star` \| `diamond` | — | Marker preset. Defaults depend on the role: plain point → `pin`, route start → `ring`, route finish → `flag`. |
| `routes[].finish.marker.color` | string | — | Marker fill color. |
| `routes[].finish.marker.size` | number, 4–64 | — | Marker height in CSS pixels (multiplied by `scale`). Default depends on the shape. |
| `routes[].finish.marker.strokeColor` | string | — | Outline color (default white where the shape has an outline). |
| `routes[].finish.marker.strokeWidth` | number, 0–8 | — | Outline width in px; 0 = none. `dot` and `pin` have a thin default outline. |
| `routes[].finish.marker.icon` | string | — | Custom icon instead of the shape: an SVG/PNG `data:` URI, or an https URL on a host the server allows (`WHEREBOX_ICON_HOSTS`). SVGs with external references or scripts are rejected. |
| `routes[].finish.marker.anchor` | `center` \| `bottom` | — | Which point of a custom icon sits on the location: its center, or its bottom edge (pin-shaped icons). |
| `routes[].finish.marker` | object | — | Marker style; every field is optional and falls back to the role default. |
| `routes[].finish.show` | boolean | `true` | `false` hides the marker and label for this end. |
| `routes[].finish` | object | `{}` | Marker and label for one end of the route. |
| `routes[]` | object | — | A line between points: given as geometry/polyline, routed between `from` and `to`, or straight/geodesic. |
| `routes` | array | `[]` | Routes to draw (GET supports one; POST up to 10). |
| `shapes[].geojson.type` | string | — | GeoJSON type (LineString, Polygon, Feature, FeatureCollection, …). |
| `shapes[].geojson` | object | — | Any GeoJSON geometry, Feature or FeatureCollection. |
| `shapes[].color` | string | `#555555` | Line color of the shape. |
| `shapes[].width` | number, 0–20 | `2` | Line width in px; 0 = no line. |
| `shapes[].dash` | array | — | Dash pattern in line widths. |
| `shapes[].opacity` | number, 0–1 | `1` | Line opacity. |
| `shapes[].fill` | string | — | Fill color for polygons (no fill if unset). |
| `shapes[].fillOpacity` | number, 0–1 | `0.25` | Fill opacity for polygons. |
| `shapes[]` | object | — | An extra line or polygon drawn on the map (e.g. a boundary arc or a buffer zone). |
| `shapes` | array | `[]` | Extra lines and polygons (POST only). |
| `font.family` | `noto-sans` \| `noto-serif` \| `noto-sans-mono` | `noto-sans` | A bundled family, or your own TTF/OTF/WOFF as a `data:` URI or an https URL on an allowed host (`WHEREBOX_FONT_HOSTS`). Missing glyphs fall back to Noto Sans, then Noto Sans Mono. |
| `font.weight` | integer, 100–900 | `400` | Weight for all labels (100–900; the nearest bundled weight is used). |
| `font` | object | `{}` | Font for everything we draw (labels, title); the basemap keeps its own fonts. |
| `title.text` | string | — | Title text (one line; long titles shrink, then get an ellipsis). |
| `title.position` | `top` \| `bottom` | `top` | Band above or below the map. |
| `title.align` | `left` \| `center` \| `right` | `left` | Text alignment in the band. |
| `title.size` | number, 8–64 | `18` | Text size in CSS px. |
| `title.weight` | integer, 100–900 | `600` | Font weight. |
| `title.color` | string | — | Text color (default: the theme label color). |
| `title.background` | string | — | Band background (default: white, or near-black on the dark preset). |
| `title.dividerColor` | string | — | Line between the band and the map. |
| `title.dividerWidth` | number, 0–6 | `1` | Divider width in px; 0 = none. |
| `title.padding` | number, 0–40 | `10` | Space around the text in px. |
| `title` | object | — | A title band, drawn inside the requested width×height (the map gets the rest). |
| `pois[].lat` | number, -90–90 | — | Latitude (WGS84). Any coordinate works; no lookup is done. |
| `pois[].lon` | number, -180–180 | — | Longitude (WGS84). |
| `pois[].place` | string | — | A place name to look up instead of coordinates (cities, peaks, mountain ranges…). Looked up in the imported OSM places first, then in the server's geocoder if one is configured. The label defaults to the place name. |
| `pois[].label` | string | — | Label text. A newline (`\n` in GET) starts a new line. |
| `pois[].labelPosition` | `auto` \| `right` \| `left` \| `top` \| `bottom` \| `top-right` \| `top-left` \| `bottom-right` \| `bottom-left` | `auto` | Where the label goes relative to the marker. `auto` picks the first free spot and falls back to a leader line. |
| `pois[].labelSize` | number, 6–48 | `16` | Label text size in CSS px. |
| `pois[].labelWeight` | integer, 100–900 | — | Font weight for this label (overrides `font.weight`). |
| `pois[].labelColor` | string | — | Label color (default: the basemap theme's label color). |
| `pois[].marker.shape` | `pin` \| `dot` \| `ring` \| `flag` \| `triangle` \| `circle` \| `square` \| `star` \| `diamond` | — | Marker preset. Defaults depend on the role: plain point → `pin`, route start → `ring`, route finish → `flag`. |
| `pois[].marker.color` | string | — | Marker fill color. |
| `pois[].marker.size` | number, 4–64 | — | Marker height in CSS pixels (multiplied by `scale`). Default depends on the shape. |
| `pois[].marker.strokeColor` | string | — | Outline color (default white where the shape has an outline). |
| `pois[].marker.strokeWidth` | number, 0–8 | — | Outline width in px; 0 = none. `dot` and `pin` have a thin default outline. |
| `pois[].marker.icon` | string | — | Custom icon instead of the shape: an SVG/PNG `data:` URI, or an https URL on a host the server allows (`WHEREBOX_ICON_HOSTS`). SVGs with external references or scripts are rejected. |
| `pois[].marker.anchor` | `center` \| `bottom` | — | Which point of a custom icon sits on the location: its center, or its bottom edge (pin-shaped icons). |
| `pois[].marker` | object | — | Marker style; every field is optional and falls back to the role default. |
| `pois[].leader` | boolean | `true` | When no free spot is next to the marker, place the label further out with a thin leader line. |
| `pois[]` | object | — | A point on the map: coordinates or a place name, with an optional label and marker. |
| `pois` | array | `[]` | Points with markers and labels (up to 50). |
| `width` | integer, 64–2048 | `600` | Image width in CSS px (the output is width × `scale` pixels). |
| `height` | integer, 64–2048 | `400` | Image height in CSS px, including the title band if any. |
| `scale` | integer, 1–3 | `1` | Pixel ratio for high-DPI output (2 = retina). |
| `format` | `png` \| `jpeg` \| `webp` | `png` | Image format (GET can also use the path extension: /v1/map.png, .jpg, .webp). |
| `quality` | integer, 1–100 | `90` | JPEG/WebP quality. |
| `padding` | number, 0–0.45 | `0.04` | Space around the framed content as a fraction of the image size. |
| `projection` | `mercator` \| `globe` \| `auto` | `mercator` | Map projection. `mercator` is the usual flat web map. `globe` draws an orthographic globe, which keeps continent-spanning routes and high latitudes true to shape; the content must fit on one hemisphere (within 85° of the view center), and neighbor labels are not available. `auto` picks the globe when the content spans more than 50° or Mercator would stretch it more than 2.5× between its lowest and highest latitude, else Mercator. |
| `globe.view` | `full` \| `fit` | `full` | How much of the globe to show. `full` (default): the whole globe, centered on your content, with space around it; recognizable at a glance as a globe. `fit`: zoomed in until the content fills the image; a larger scale with more detail along the route, but only part of the globe is visible. |
| `globe` | object | `{}` | Globe options (with `projection` `globe`, or `auto` when it picks the globe). |
| `basemap.preset` | `locator-relief` \| `light` \| `dark` | `locator-relief` | Built-in basemap: shaded relief (Wikipedia locator style), light, or dark. No key needed. |
| `basemap.styleUrl` | string | — | Any MapLibre style.json URL; overrides `preset`. `{key}` in the URL is replaced with `providerKey`. |
| `basemap.providerKey` | string | — | Key for a commercial style (prefer the `X-Provider-Key` header so it stays out of URLs). |
| `basemap` | object | `{}` | The map underneath: a built-in preset or your own style. |
| `highlight.fade` | number, 0–1 | `0.45` (varies by preset) | How much the surroundings are washed out: 0 = not at all, 1 = fully covered. |
| `highlight.fadeColor` | string | `#ffffff` (varies by preset) | Color the surroundings fade towards. |
| `highlight.outlineColor` | string | `#555555` (varies by preset) | Area outline color. |
| `highlight.outlineWidth` | number, 0–10 | `1.1` (varies by preset) | Area outline width in px; 0 = none. |
| `highlight.tint` | string | — | Optional color tint drawn over the area. |
| `highlight.tintOpacity` | number, 0–1 | `0.15` (varies by preset) | Opacity of `tint`. |
| `highlight.subdivisions` | boolean \| string \| integer, `auto`, 3–11 | `auto` (varies by preset) | Inner admin borders: `auto` draws the next level that splits the area, a number forces that admin level, `false` hides them. |
| `highlight.subdivisionColor` | string | `#8a8a8a` (varies by preset) | Color of the inner admin borders. |
| `highlight.subdivisionWidth` | number, 0–6 | `0.6` (varies by preset) | Width of the inner admin borders in px. |
| `highlight.desaturate` | number, 0–1 | `0` (varies by preset) | Outside the area: 0 = full color, 1 = grayscale (applied on top of `fade`). |
| `highlight.blur` | number, 0–20 | `0` (varies by preset) | Outside the area: blur radius in CSS px; 0 = sharp. |
| `highlight.subArea.query` | string | — | Name or id, as in the string form. |
| `highlight.subArea.level` | integer, 1–11 | — | Only match areas at this OSM admin level (2 = country; regions and municipalities vary by country). |
| `highlight.subArea.country` | string | — | Only match areas inside this ISO 3166-1 alpha-2 country. |
| `highlight.subArea.geojson.type` | `Polygon` \| `MultiPolygon` \| `Feature` | — | GeoJSON type. |
| `highlight.subArea.geojson` | object | — | A GeoJSON Polygon, MultiPolygon, or Feature with one of those geometries. |
| `highlight.subArea` | string \| object | — | A second, smaller area highlighted inside the framed one (e.g. a region within its country). |
| `highlight.subAreaColor` | string | `#d03030` (varies by preset) | Fill and outline color of `subArea`. |
| `highlight.subAreaOpacity` | number, 0–1 | `0.35` (varies by preset) | Fill opacity of `subArea`. |
| `highlight.subAreaOutlineWidth` | number, 0–10 | `1.2` (varies by preset) | Outline width of `subArea` in px; 0 = none. |
| `highlight` | object | `{}` | How the area stands out. Unset fields use the basemap preset's defaults (see the presets reference). |
| `attribution` | `bottom-right` \| `bottom-left` \| `top-right` \| `top-left` | `bottom-right` | Corner for the data attribution (always drawn; required by the data licenses). |
| `neighbors.show` | `name` \| `code` \| `flag` \| `flag+code` \| `flag+name` | `name` | What to show for each neighboring country: its name, its international vehicle registration code (D, A, SK…), its flag, or a flag with code/name. |
| `neighbors.which` | `touching` \| `visible` | `touching` | `touching` = countries bordering the area; `visible` = every other country in the frame. |
| `neighbors.fit` | `extend` \| `shrink` \| `drop` | `extend` | When a label does not fit in the visible part of its country: `extend` grows the image on that side (the output becomes larger than width×height; see `X-Image-Width`), `shrink` reduces the label to 70 %, `drop` omits it. |
| `neighbors.size` | number, 6–32 | `12` | Text size in CSS px; flags are sized to match. |
| `neighbors.color` | string | — | Text color (default: the theme's muted label color). |
| `neighbors.uppercase` | boolean | `false` | Uppercase country names. |
| `neighbors.max` | integer, 1–30 | `12` | At most this many countries are labeled (largest visible first). |
| `neighbors` | object | — | Label the countries around the area (needs an `area`). |
| `worldview` | `ar` \| `bd` \| `br` \| `cn` \| `de` \| `eg` \| `es` \| `fr` \| `gb` \| `gr` \| `id` \| `il` \| `in` \| `it` \| `jp` \| `kr` \| `ma` \| `nl` \| `np` \| `pk` \| `pl` \| `ps` \| `pt` \| `ru` \| `sa` \| `se` \| `tr` \| `tw` \| `ua` \| `us` \| `vn` | — | Draw country borders as this country sees them (ISO 3166-1 alpha-2, e.g. `in`, `cn`, `us`, `ma`). Replaces the basemap's country and disputed borders with Natural Earth's borders for that worldview, and uses its country shapes for a highlighted country and for neighbor labels. Regions, cities and everything below country level stay OpenStreetMap. Default: the basemap's borders (OpenStreetMap, on-the-ground control). |
| `lang` | string | — | Label language (ISO 639-1). Default: local names (Deutschland, Polska), as on Wikipedia locator maps. |
The same schema is part of [openapi.json](/docs/openapi.json) (`components.schemas.MapSpec`).
Full JSON Schema
```json
{
"type": "object",
"properties": {
"area": {
"anyOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "A name (\"Czechia\", \"Zlínský kraj\"), or an id: `iso:CZ`, `iso:CZ-72`, `osm:r51684`, `wikidata:Q213`.",
"examples": [
"Czechia"
]
},
{
"type": "object",
"properties": {
"query": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "Name or id, as in the string form.",
"examples": [
"Nová Ves"
]
},
"level": {
"description": "Only match areas at this OSM admin level (2 = country; regions and municipalities vary by country).",
"examples": [
8
],
"type": "integer",
"minimum": 1,
"maximum": 11
},
"country": {
"description": "Only match areas inside this ISO 3166-1 alpha-2 country.",
"examples": [
"CZ"
],
"type": "string",
"minLength": 2,
"maxLength": 2
}
},
"required": [
"query"
],
"description": "A name lookup narrowed by admin level and/or country, for ambiguous names."
},
{
"type": "object",
"properties": {
"geojson": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"Polygon",
"MultiPolygon",
"Feature"
],
"description": "GeoJSON type.",
"examples": [
"Polygon"
]
}
},
"required": [
"type"
],
"additionalProperties": {},
"description": "A GeoJSON Polygon, MultiPolygon, or Feature with one of those geometries."
}
},
"required": [
"geojson"
],
"description": "Your own area geometry instead of a lookup."
}
],
"description": "The area to frame and highlight. Ambiguous names return 409 with candidates.",
"examples": [
"Czechia"
]
},
"bounds": {
"description": "Explicit frame `[west, south, east, north]`; overrides automatic framing.",
"examples": [
[
12.09,
48.55,
18.86,
51.06
]
],
"type": "array",
"prefixItems": [
{
"type": "number",
"minimum": -180,
"maximum": 180
},
{
"type": "number",
"minimum": -85.06,
"maximum": 85.06
},
{
"type": "number",
"minimum": -180,
"maximum": 180
},
{
"type": "number",
"minimum": -85.06,
"maximum": 85.06
}
],
"items": false,
"minItems": 4,
"maxItems": 4
},
"routes": {
"default": [],
"description": "Routes to draw (GET supports one; POST up to 10).",
"maxItems": 10,
"type": "array",
"items": {
"type": "object",
"properties": {
"geometry": {
"description": "A line you already have (e.g. from your own routing provider).",
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"LineString",
"MultiLineString",
"Feature"
],
"description": "GeoJSON type.",
"examples": [
"LineString"
]
}
},
"required": [
"type"
],
"additionalProperties": {}
},
"polyline": {
"description": "The line as an encoded polyline (Google algorithm).",
"examples": [
"_p~iF~ps|U_ulLnnqC_mqNvxq`@"
],
"type": "string",
"maxLength": 200000
},
"polylinePrecision": {
"default": 5,
"description": "Polyline precision: 5 (Google) or 6 (Valhalla/OSRM).",
"examples": [
5
],
"anyOf": [
{
"type": "number",
"const": 5
},
{
"type": "number",
"const": 6
}
]
},
"from": {
"description": "Start point when the server should compute the line.",
"anyOf": [
{
"type": "object",
"properties": {
"lat": {
"type": "number",
"minimum": -90,
"maximum": 90,
"description": "Latitude (WGS84).",
"examples": [
40.4168
]
},
"lon": {
"type": "number",
"minimum": -180,
"maximum": 180,
"description": "Longitude (WGS84).",
"examples": [
-3.7038
]
}
},
"required": [
"lat",
"lon"
],
"description": "Coordinates."
},
{
"type": "object",
"properties": {
"place": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "Place name to look up (imported OSM places first, then the server's geocoder if configured); its name becomes the default label.",
"examples": [
"Sevilla"
]
}
},
"required": [
"place"
],
"description": "A place name to look up."
}
],
"examples": [
{
"lat": 40.4168,
"lon": -3.7038
}
]
},
"to": {
"description": "Finish point when the server should compute the line.",
"anyOf": [
{
"type": "object",
"properties": {
"lat": {
"type": "number",
"minimum": -90,
"maximum": 90,
"description": "Latitude (WGS84).",
"examples": [
40.4168
]
},
"lon": {
"type": "number",
"minimum": -180,
"maximum": 180,
"description": "Longitude (WGS84).",
"examples": [
-3.7038
]
}
},
"required": [
"lat",
"lon"
],
"description": "Coordinates."
},
{
"type": "object",
"properties": {
"place": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "Place name to look up (imported OSM places first, then the server's geocoder if configured); its name becomes the default label.",
"examples": [
"Sevilla"
]
}
},
"required": [
"place"
],
"description": "A place name to look up."
}
],
"examples": [
{
"lat": 40.4168,
"lon": -3.7038
}
]
},
"via": {
"default": [],
"description": "Points the route must pass through, in order.",
"maxItems": 20,
"type": "array",
"items": {
"anyOf": [
{
"type": "object",
"properties": {
"lat": {
"type": "number",
"minimum": -90,
"maximum": 90,
"description": "Latitude (WGS84).",
"examples": [
40.4168
]
},
"lon": {
"type": "number",
"minimum": -180,
"maximum": 180,
"description": "Longitude (WGS84).",
"examples": [
-3.7038
]
}
},
"required": [
"lat",
"lon"
],
"description": "Coordinates."
},
{
"type": "object",
"properties": {
"place": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "Place name to look up (imported OSM places first, then the server's geocoder if configured); its name becomes the default label.",
"examples": [
"Sevilla"
]
}
},
"required": [
"place"
],
"description": "A place name to look up."
}
],
"description": "A route end or via point: `{lat, lon}` or `{place}`.",
"examples": [
{
"lat": 40.4168,
"lon": -3.7038
}
]
}
},
"profile": {
"default": "geodesic",
"type": "string",
"enum": [
"car",
"bike",
"foot",
"straight",
"geodesic"
],
"description": "How endpoints are connected: routed by `car`/`bike`/`foot` (needs a routing engine on the server or your `X-Routing-Key`), or locally as `straight` segments or `geodesic` great-circle arcs. The default `geodesic` always works (no routing engine, key or plan feature needed) and is the true shortest path, so it suits flights and locator maps; ask for `car`/`bike`/`foot` explicitly for roads.",
"examples": [
"geodesic"
]
},
"provider": {
"description": "Routing provider (default: the server's). openrouteservice and GraphHopper need a key (server-side or `X-Routing-Key`).",
"examples": [
"valhalla"
],
"type": "string",
"enum": [
"valhalla",
"openrouteservice",
"graphhopper"
]
},
"color": {
"default": "#d01c1c",
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Color of the route line.",
"examples": [
"#d01c1c"
]
},
"width": {
"default": 3,
"description": "Line width in px.",
"examples": [
3
],
"type": "number",
"minimum": 0.5,
"maximum": 20
},
"casingColor": {
"default": "#ffffff",
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Outline drawn under the line so it reads on busy basemaps.",
"examples": [
"#ffffff"
]
},
"casingWidth": {
"default": 1.5,
"description": "Outline width on each side in px; 0 = none.",
"examples": [
1.5
],
"type": "number",
"minimum": 0,
"maximum": 10
},
"dash": {
"description": "Dash pattern in line widths, e.g. `[2, 1.5]`.",
"examples": [
[
2,
1.5
]
],
"maxItems": 8,
"type": "array",
"items": {
"type": "number",
"minimum": 0,
"maximum": 50
}
},
"opacity": {
"default": 1,
"description": "Line opacity.",
"examples": [
1
],
"type": "number",
"minimum": 0,
"maximum": 1
},
"start": {
"default": {},
"type": "object",
"properties": {
"label": {
"description": "Label at this end (default: the place name, if the end was given as a place).",
"examples": [
"Madrid"
],
"type": "string",
"maxLength": 160
},
"labelPosition": {
"default": "auto",
"type": "string",
"enum": [
"auto",
"right",
"left",
"top",
"bottom",
"top-right",
"top-left",
"bottom-right",
"bottom-left"
],
"description": "Where the label goes relative to the marker. `auto` picks the first free spot and falls back to a leader line.",
"examples": [
"auto"
]
},
"labelSize": {
"default": 16,
"description": "Label text size in CSS px.",
"examples": [
16
],
"type": "number",
"minimum": 6,
"maximum": 48
},
"labelWeight": {
"description": "Font weight for this label (overrides `font.weight`).",
"examples": [
700
],
"type": "integer",
"minimum": 100,
"maximum": 900
},
"labelColor": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Label color (default: the basemap theme's label color).",
"examples": [
"#1a1a1a"
]
},
"marker": {
"type": "object",
"properties": {
"shape": {
"type": "string",
"enum": [
"pin",
"dot",
"ring",
"flag",
"triangle",
"circle",
"square",
"star",
"diamond"
],
"description": "Marker preset. Defaults depend on the role: plain point → `pin`, route start → `ring`, route finish → `flag`.",
"examples": [
"pin"
]
},
"color": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Marker fill color.",
"examples": [
"#e02020"
]
},
"size": {
"type": "number",
"minimum": 4,
"maximum": 64,
"description": "Marker height in CSS pixels (multiplied by `scale`). Default depends on the shape.",
"examples": [
20
]
},
"strokeColor": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Outline color (default white where the shape has an outline).",
"examples": [
"#ffffff"
]
},
"strokeWidth": {
"type": "number",
"minimum": 0,
"maximum": 8,
"description": "Outline width in px; 0 = none. `dot` and `pin` have a thin default outline.",
"examples": [
1.5
]
},
"icon": {
"type": "string",
"maxLength": 200000,
"description": "Custom icon instead of the shape: an SVG/PNG `data:` URI, or an https URL on a host the server allows (`WHEREBOX_ICON_HOSTS`). SVGs with external references or scripts are rejected.",
"examples": [
"data:image/svg+xml;base64,PHN2Zy8+"
]
},
"anchor": {
"type": "string",
"enum": [
"center",
"bottom"
],
"description": "Which point of a custom icon sits on the location: its center, or its bottom edge (pin-shaped icons).",
"examples": [
"bottom"
]
}
},
"description": "Marker style; every field is optional and falls back to the role default."
},
"show": {
"default": true,
"description": "`false` hides the marker and label for this end.",
"examples": [
true
],
"type": "boolean"
}
},
"description": "Marker and label for one end of the route."
},
"finish": {
"default": {},
"type": "object",
"properties": {
"label": {
"description": "Label at this end (default: the place name, if the end was given as a place).",
"examples": [
"Madrid"
],
"type": "string",
"maxLength": 160
},
"labelPosition": {
"default": "auto",
"type": "string",
"enum": [
"auto",
"right",
"left",
"top",
"bottom",
"top-right",
"top-left",
"bottom-right",
"bottom-left"
],
"description": "Where the label goes relative to the marker. `auto` picks the first free spot and falls back to a leader line.",
"examples": [
"auto"
]
},
"labelSize": {
"default": 16,
"description": "Label text size in CSS px.",
"examples": [
16
],
"type": "number",
"minimum": 6,
"maximum": 48
},
"labelWeight": {
"description": "Font weight for this label (overrides `font.weight`).",
"examples": [
700
],
"type": "integer",
"minimum": 100,
"maximum": 900
},
"labelColor": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Label color (default: the basemap theme's label color).",
"examples": [
"#1a1a1a"
]
},
"marker": {
"type": "object",
"properties": {
"shape": {
"type": "string",
"enum": [
"pin",
"dot",
"ring",
"flag",
"triangle",
"circle",
"square",
"star",
"diamond"
],
"description": "Marker preset. Defaults depend on the role: plain point → `pin`, route start → `ring`, route finish → `flag`.",
"examples": [
"pin"
]
},
"color": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Marker fill color.",
"examples": [
"#e02020"
]
},
"size": {
"type": "number",
"minimum": 4,
"maximum": 64,
"description": "Marker height in CSS pixels (multiplied by `scale`). Default depends on the shape.",
"examples": [
20
]
},
"strokeColor": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Outline color (default white where the shape has an outline).",
"examples": [
"#ffffff"
]
},
"strokeWidth": {
"type": "number",
"minimum": 0,
"maximum": 8,
"description": "Outline width in px; 0 = none. `dot` and `pin` have a thin default outline.",
"examples": [
1.5
]
},
"icon": {
"type": "string",
"maxLength": 200000,
"description": "Custom icon instead of the shape: an SVG/PNG `data:` URI, or an https URL on a host the server allows (`WHEREBOX_ICON_HOSTS`). SVGs with external references or scripts are rejected.",
"examples": [
"data:image/svg+xml;base64,PHN2Zy8+"
]
},
"anchor": {
"type": "string",
"enum": [
"center",
"bottom"
],
"description": "Which point of a custom icon sits on the location: its center, or its bottom edge (pin-shaped icons).",
"examples": [
"bottom"
]
}
},
"description": "Marker style; every field is optional and falls back to the role default."
},
"show": {
"default": true,
"description": "`false` hides the marker and label for this end.",
"examples": [
true
],
"type": "boolean"
}
},
"description": "Marker and label for one end of the route."
}
},
"description": "A line between points: given as geometry/polyline, routed between `from` and `to`, or straight/geodesic."
}
},
"shapes": {
"default": [],
"description": "Extra lines and polygons (POST only).",
"maxItems": 50,
"type": "array",
"items": {
"type": "object",
"properties": {
"geojson": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "GeoJSON type (LineString, Polygon, Feature, FeatureCollection, …).",
"examples": [
"LineString"
]
}
},
"required": [
"type"
],
"additionalProperties": {},
"description": "Any GeoJSON geometry, Feature or FeatureCollection."
},
"color": {
"default": "#555555",
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Line color of the shape.",
"examples": [
"#555555"
]
},
"width": {
"default": 2,
"description": "Line width in px; 0 = no line.",
"examples": [
2
],
"type": "number",
"minimum": 0,
"maximum": 20
},
"dash": {
"description": "Dash pattern in line widths.",
"examples": [
[
3,
2
]
],
"maxItems": 8,
"type": "array",
"items": {
"type": "number",
"minimum": 0,
"maximum": 50
}
},
"opacity": {
"default": 1,
"description": "Line opacity.",
"examples": [
1
],
"type": "number",
"minimum": 0,
"maximum": 1
},
"fill": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Fill color for polygons (no fill if unset).",
"examples": [
"#ffcc00"
]
},
"fillOpacity": {
"default": 0.25,
"description": "Fill opacity for polygons.",
"examples": [
0.25
],
"type": "number",
"minimum": 0,
"maximum": 1
}
},
"required": [
"geojson"
],
"description": "An extra line or polygon drawn on the map (e.g. a boundary arc or a buffer zone)."
}
},
"font": {
"default": {},
"type": "object",
"properties": {
"family": {
"default": "noto-sans",
"description": "A bundled family, or your own TTF/OTF/WOFF as a `data:` URI or an https URL on an allowed host (`WHEREBOX_FONT_HOSTS`). Missing glyphs fall back to Noto Sans, then Noto Sans Mono.",
"examples": [
"noto-serif"
],
"anyOf": [
{
"type": "string",
"enum": [
"noto-sans",
"noto-serif",
"noto-sans-mono"
],
"description": "Bundled font family.",
"examples": [
"noto-sans"
]
},
{
"type": "string",
"maxLength": 2000000
}
]
},
"weight": {
"default": 400,
"description": "Weight for all labels (100–900; the nearest bundled weight is used).",
"examples": [
600
],
"type": "integer",
"minimum": 100,
"maximum": 900
}
},
"description": "Font for everything we draw (labels, title); the basemap keeps its own fonts."
},
"title": {
"type": "object",
"properties": {
"text": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "Title text (one line; long titles shrink, then get an ellipsis).",
"examples": [
"Madrid – Seville"
]
},
"position": {
"default": "top",
"description": "Band above or below the map.",
"examples": [
"top"
],
"type": "string",
"enum": [
"top",
"bottom"
]
},
"align": {
"default": "left",
"description": "Text alignment in the band.",
"examples": [
"left"
],
"type": "string",
"enum": [
"left",
"center",
"right"
]
},
"size": {
"default": 18,
"description": "Text size in CSS px.",
"examples": [
18
],
"type": "number",
"minimum": 8,
"maximum": 64
},
"weight": {
"default": 600,
"description": "Font weight.",
"examples": [
600
],
"type": "integer",
"minimum": 100,
"maximum": 900
},
"color": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Text color (default: the theme label color).",
"examples": [
"#1a1a1a"
]
},
"background": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Band background (default: white, or near-black on the dark preset).",
"examples": [
"#ffffff"
]
},
"dividerColor": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Line between the band and the map.",
"examples": [
"#cfcfcf"
]
},
"dividerWidth": {
"default": 1,
"description": "Divider width in px; 0 = none.",
"examples": [
1
],
"type": "number",
"minimum": 0,
"maximum": 6
},
"padding": {
"default": 10,
"description": "Space around the text in px.",
"examples": [
10
],
"type": "number",
"minimum": 0,
"maximum": 40
}
},
"required": [
"text"
],
"description": "A title band, drawn inside the requested width×height (the map gets the rest)."
},
"pois": {
"default": [],
"description": "Points with markers and labels (up to 50).",
"maxItems": 50,
"type": "array",
"items": {
"type": "object",
"properties": {
"lat": {
"description": "Latitude (WGS84). Any coordinate works; no lookup is done.",
"examples": [
49.13
],
"type": "number",
"minimum": -90,
"maximum": 90
},
"lon": {
"description": "Longitude (WGS84).",
"examples": [
17.33
],
"type": "number",
"minimum": -180,
"maximum": 180
},
"place": {
"description": "A place name to look up instead of coordinates (cities, peaks, mountain ranges…). Looked up in the imported OSM places first, then in the server's geocoder if one is configured. The label defaults to the place name.",
"examples": [
"Chřiby"
],
"type": "string",
"minLength": 1,
"maxLength": 200
},
"label": {
"description": "Label text. A newline (`\\n` in GET) starts a new line.",
"examples": [
"Crash Site\n(near Urda)"
],
"type": "string",
"maxLength": 160
},
"labelPosition": {
"default": "auto",
"type": "string",
"enum": [
"auto",
"right",
"left",
"top",
"bottom",
"top-right",
"top-left",
"bottom-right",
"bottom-left"
],
"description": "Where the label goes relative to the marker. `auto` picks the first free spot and falls back to a leader line.",
"examples": [
"auto"
]
},
"labelSize": {
"default": 16,
"description": "Label text size in CSS px.",
"examples": [
16
],
"type": "number",
"minimum": 6,
"maximum": 48
},
"labelWeight": {
"description": "Font weight for this label (overrides `font.weight`).",
"examples": [
700
],
"type": "integer",
"minimum": 100,
"maximum": 900
},
"labelColor": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Label color (default: the basemap theme's label color).",
"examples": [
"#1a1a1a"
]
},
"marker": {
"type": "object",
"properties": {
"shape": {
"type": "string",
"enum": [
"pin",
"dot",
"ring",
"flag",
"triangle",
"circle",
"square",
"star",
"diamond"
],
"description": "Marker preset. Defaults depend on the role: plain point → `pin`, route start → `ring`, route finish → `flag`.",
"examples": [
"pin"
]
},
"color": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Marker fill color.",
"examples": [
"#e02020"
]
},
"size": {
"type": "number",
"minimum": 4,
"maximum": 64,
"description": "Marker height in CSS pixels (multiplied by `scale`). Default depends on the shape.",
"examples": [
20
]
},
"strokeColor": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Outline color (default white where the shape has an outline).",
"examples": [
"#ffffff"
]
},
"strokeWidth": {
"type": "number",
"minimum": 0,
"maximum": 8,
"description": "Outline width in px; 0 = none. `dot` and `pin` have a thin default outline.",
"examples": [
1.5
]
},
"icon": {
"type": "string",
"maxLength": 200000,
"description": "Custom icon instead of the shape: an SVG/PNG `data:` URI, or an https URL on a host the server allows (`WHEREBOX_ICON_HOSTS`). SVGs with external references or scripts are rejected.",
"examples": [
"data:image/svg+xml;base64,PHN2Zy8+"
]
},
"anchor": {
"type": "string",
"enum": [
"center",
"bottom"
],
"description": "Which point of a custom icon sits on the location: its center, or its bottom edge (pin-shaped icons).",
"examples": [
"bottom"
]
}
},
"description": "Marker style; every field is optional and falls back to the role default."
},
"leader": {
"default": true,
"description": "When no free spot is next to the marker, place the label further out with a thin leader line.",
"examples": [
true
],
"type": "boolean"
}
},
"description": "A point on the map: coordinates or a place name, with an optional label and marker."
}
},
"width": {
"default": 600,
"description": "Image width in CSS px (the output is width × `scale` pixels).",
"examples": [
468
],
"type": "integer",
"minimum": 64,
"maximum": 2048
},
"height": {
"default": 400,
"description": "Image height in CSS px, including the title band if any.",
"examples": [
280
],
"type": "integer",
"minimum": 64,
"maximum": 2048
},
"scale": {
"default": 1,
"description": "Pixel ratio for high-DPI output (2 = retina).",
"examples": [
2
],
"type": "integer",
"minimum": 1,
"maximum": 3
},
"format": {
"default": "png",
"type": "string",
"enum": [
"png",
"jpeg",
"webp"
],
"description": "Image format (GET can also use the path extension: /v1/map.png, .jpg, .webp).",
"examples": [
"png"
]
},
"quality": {
"default": 90,
"description": "JPEG/WebP quality.",
"examples": [
90
],
"type": "integer",
"minimum": 1,
"maximum": 100
},
"padding": {
"default": 0.04,
"description": "Space around the framed content as a fraction of the image size.",
"examples": [
0.04
],
"type": "number",
"minimum": 0,
"maximum": 0.45
},
"projection": {
"default": "mercator",
"type": "string",
"enum": [
"mercator",
"globe",
"auto"
],
"description": "Map projection. `mercator` is the usual flat web map. `globe` draws an orthographic globe, which keeps continent-spanning routes and high latitudes true to shape; the content must fit on one hemisphere (within 85° of the view center), and neighbor labels are not available. `auto` picks the globe when the content spans more than 50° or Mercator would stretch it more than 2.5× between its lowest and highest latitude, else Mercator.",
"examples": [
"globe"
]
},
"globe": {
"default": {},
"type": "object",
"properties": {
"view": {
"default": "full",
"description": "How much of the globe to show. `full` (default): the whole globe, centered on your content, with space around it; recognizable at a glance as a globe. `fit`: zoomed in until the content fills the image; a larger scale with more detail along the route, but only part of the globe is visible.",
"examples": [
"full"
],
"type": "string",
"enum": [
"full",
"fit"
]
}
},
"description": "Globe options (with `projection` `globe`, or `auto` when it picks the globe)."
},
"basemap": {
"default": {},
"type": "object",
"properties": {
"preset": {
"default": "locator-relief",
"type": "string",
"enum": [
"locator-relief",
"light",
"dark"
],
"description": "Built-in basemap: shaded relief (Wikipedia locator style), light, or dark. No key needed.",
"examples": [
"locator-relief"
]
},
"styleUrl": {
"description": "Any MapLibre style.json URL; overrides `preset`. `{key}` in the URL is replaced with `providerKey`.",
"examples": [
"https://api.maptiler.com/maps/streets-v2/style.json?key={key}"
],
"type": "string",
"format": "uri"
},
"providerKey": {
"description": "Key for a commercial style (prefer the `X-Provider-Key` header so it stays out of URLs).",
"examples": [
"your-provider-key"
],
"type": "string",
"maxLength": 200
}
},
"description": "The map underneath: a built-in preset or your own style."
},
"highlight": {
"default": {},
"type": "object",
"properties": {
"fade": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "How much the surroundings are washed out: 0 = not at all, 1 = fully covered.",
"examples": [
0.45
]
},
"fadeColor": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Color the surroundings fade towards.",
"examples": [
"#ffffff"
]
},
"outlineColor": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Area outline color.",
"examples": [
"#555555"
]
},
"outlineWidth": {
"type": "number",
"minimum": 0,
"maximum": 10,
"description": "Area outline width in px; 0 = none.",
"examples": [
1.1
]
},
"tint": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Optional color tint drawn over the area.",
"examples": [
"#d03030"
]
},
"tintOpacity": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Opacity of `tint`.",
"examples": [
0.15
]
},
"subdivisions": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "string",
"const": "auto"
},
{
"type": "integer",
"minimum": 3,
"maximum": 11
}
],
"description": "Inner admin borders: `auto` draws the next level that splits the area, a number forces that admin level, `false` hides them.",
"examples": [
"auto"
]
},
"subdivisionColor": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Color of the inner admin borders.",
"examples": [
"#8a8a8a"
]
},
"subdivisionWidth": {
"type": "number",
"minimum": 0,
"maximum": 6,
"description": "Width of the inner admin borders in px.",
"examples": [
0.6
]
},
"desaturate": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Outside the area: 0 = full color, 1 = grayscale (applied on top of `fade`).",
"examples": [
0.8
]
},
"blur": {
"type": "number",
"minimum": 0,
"maximum": 20,
"description": "Outside the area: blur radius in CSS px; 0 = sharp.",
"examples": [
1.5
]
},
"subArea": {
"anyOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "A name (\"Czechia\", \"Zlínský kraj\"), or an id: `iso:CZ`, `iso:CZ-72`, `osm:r51684`, `wikidata:Q213`.",
"examples": [
"Czechia"
]
},
{
"type": "object",
"properties": {
"query": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "Name or id, as in the string form.",
"examples": [
"Nová Ves"
]
},
"level": {
"description": "Only match areas at this OSM admin level (2 = country; regions and municipalities vary by country).",
"examples": [
8
],
"type": "integer",
"minimum": 1,
"maximum": 11
},
"country": {
"description": "Only match areas inside this ISO 3166-1 alpha-2 country.",
"examples": [
"CZ"
],
"type": "string",
"minLength": 2,
"maxLength": 2
}
},
"required": [
"query"
],
"description": "A name lookup narrowed by admin level and/or country, for ambiguous names."
},
{
"type": "object",
"properties": {
"geojson": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"Polygon",
"MultiPolygon",
"Feature"
],
"description": "GeoJSON type.",
"examples": [
"Polygon"
]
}
},
"required": [
"type"
],
"additionalProperties": {},
"description": "A GeoJSON Polygon, MultiPolygon, or Feature with one of those geometries."
}
},
"required": [
"geojson"
],
"description": "Your own area geometry instead of a lookup."
}
],
"description": "A second, smaller area highlighted inside the framed one (e.g. a region within its country).",
"examples": [
"Zlínský kraj"
]
},
"subAreaColor": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Fill and outline color of `subArea`.",
"examples": [
"#d03030"
]
},
"subAreaOpacity": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Fill opacity of `subArea`.",
"examples": [
0.35
]
},
"subAreaOutlineWidth": {
"type": "number",
"minimum": 0,
"maximum": 10,
"description": "Outline width of `subArea` in px; 0 = none.",
"examples": [
1.2
]
}
},
"description": "How the area stands out. Unset fields use the basemap preset's defaults (see the presets reference)."
},
"attribution": {
"default": "bottom-right",
"description": "Corner for the data attribution (always drawn; required by the data licenses).",
"examples": [
"bottom-right"
],
"type": "string",
"enum": [
"bottom-right",
"bottom-left",
"top-right",
"top-left"
]
},
"neighbors": {
"type": "object",
"properties": {
"show": {
"default": "name",
"type": "string",
"enum": [
"name",
"code",
"flag",
"flag+code",
"flag+name"
],
"description": "What to show for each neighboring country: its name, its international vehicle registration code (D, A, SK…), its flag, or a flag with code/name.",
"examples": [
"flag+code"
]
},
"which": {
"default": "touching",
"description": "`touching` = countries bordering the area; `visible` = every other country in the frame.",
"examples": [
"touching"
],
"type": "string",
"enum": [
"touching",
"visible"
]
},
"fit": {
"default": "extend",
"description": "When a label does not fit in the visible part of its country: `extend` grows the image on that side (the output becomes larger than width×height; see `X-Image-Width`), `shrink` reduces the label to 70 %, `drop` omits it.",
"examples": [
"extend"
],
"type": "string",
"enum": [
"extend",
"shrink",
"drop"
]
},
"size": {
"default": 12,
"description": "Text size in CSS px; flags are sized to match.",
"examples": [
12
],
"type": "number",
"minimum": 6,
"maximum": 32
},
"color": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
"description": "Text color (default: the theme's muted label color).",
"examples": [
"#666666"
]
},
"uppercase": {
"default": false,
"description": "Uppercase country names.",
"examples": [
true
],
"type": "boolean"
},
"max": {
"default": 12,
"description": "At most this many countries are labeled (largest visible first).",
"examples": [
12
],
"type": "integer",
"minimum": 1,
"maximum": 30
}
},
"description": "Label the countries around the area (needs an `area`)."
},
"worldview": {
"description": "Draw country borders as this country sees them (ISO 3166-1 alpha-2, e.g. `in`, `cn`, `us`, `ma`). Replaces the basemap's country and disputed borders with Natural Earth's borders for that worldview, and uses its country shapes for a highlighted country and for neighbor labels. Regions, cities and everything below country level stay OpenStreetMap. Default: the basemap's borders (OpenStreetMap, on-the-ground control).",
"examples": [
"in"
],
"type": "string",
"enum": [
"ar",
"bd",
"br",
"cn",
"de",
"eg",
"es",
"fr",
"gb",
"gr",
"id",
"il",
"in",
"it",
"jp",
"kr",
"ma",
"nl",
"np",
"pk",
"pl",
"ps",
"pt",
"ru",
"sa",
"se",
"tr",
"tw",
"ua",
"us",
"vn"
]
},
"lang": {
"description": "Label language (ISO 639-1). Default: local names (Deutschland, Polska), as on Wikipedia locator maps.",
"examples": [
"en"
],
"type": "string",
"pattern": "^[a-z]{2,3}$"
}
},
"description": "Everything about one map image. Without an `area`, the map is framed on routes, shapes and points (or `bounds`)."
}
```
---
# HTTP: auth parameters and headers
Request parameters and headers outside the map description: authentication, URL signatures, caching and limits. Entries marked *keys* apply only when the server runs with API keys (`WHEREBOX_AUTH=keys`, the hosted service).
## Query parameters
Accepted on every `/v1/map` request in addition to the [map parameters](/docs/reference/parameters.md).
| Parameter | Description | Example |
|---|---|---|
| `apikey` | Publishable key (`pk_…`) for `
` embeds. Only valid together with `sig`. Secret keys (`sk_…`) are rejected in URLs. | `apikey=pk_8fK2aQ1mZr9TbL0x4WcYvN3sHd7uJe6p` |
| `sig` | URL signature: HMAC-SHA256 of the canonical request with the key's signing secret (see the signing guide). Proves the URL was created by the key owner, so a leaked `pk_` cannot be reused for other maps. | `sig=Jx3w0b2kQnVt9yRmZsLc4u8hAe1pDfGo` |
| `exp` | Optional expiry of a signed URL (Unix seconds). Covered by the signature. | `exp=1798761600` |
## Request headers
| Header | Description | Example |
|---|---|---|
| `Authorization` *keys* | Secret key for server-to-server calls (`Bearer sk_…`), or an OAuth access token from a connected app (`Bearer wba_…`). | `Bearer sk_…` |
| `X-Provider-Key` | Key for a commercial basemap style (`style=`); kept out of URLs and logs. | `your-maptiler-key` |
| `X-Routing-Key` | Key for openrouteservice / GraphHopper routing; kept out of URLs and logs. | `your-ors-key` |
| `X-Request-Id` | Optional id for this request (letters, digits, `._:-`, ≤ 64 chars), e.g. from your proxy; otherwise the server generates one. | `3f2c9a1e-…` |
| `If-None-Match` | Send a previous `ETag` to get `304 Not Modified` when the image is unchanged. | `"e1b4…png"` |
## Response headers
| Header | Description | Example |
|---|---|---|
| `ETag` | Content hash of the image (also its cache key). | `"e1b4d02…7b6.png"` |
| `Cache-Control` | Rendered maps may be cached for a day; `/v1/images/{key}` is immutable. | `public, max-age=86400` |
| `X-Cache` | `HIT` when served from the image cache, `MISS` when rendered for this request. | `HIT` |
| `X-Render-Ms` | Render time in milliseconds (on a `MISS`). | `212` |
| `X-Image-Width` | Final pixel width. Can exceed `w × scale` when `neighborfit=extend` grew the image. | `556` |
| `X-Image-Height` | Final pixel height. | `296` |
| `Retry-After` | Seconds to wait after a `503 busy` or `429` response. | `2` |
| `X-RateLimit-Limit` *keys* | Requests per minute allowed for this key's plan. | `120` |
| `X-RateLimit-Remaining` *keys* | Requests left in the current window. | `117` |
| `X-RateLimit-Reset` *keys* | Unix seconds when the window is full again. | `1798761660` |
| `WWW-Authenticate` *keys* | On `401` when OAuth is enabled: where OAuth clients find the authorization server (RFC 9728), with `error="invalid_token"` when a presented access token was rejected (expired, revoked, or issued for another resource). | `Bearer resource_metadata="https://app.wherebox.dev/.well-known/oauth-protected-resource/v1"` |
| `X-Quota-Remaining` *keys* | Renders left this calendar month (UTC). Cache hits do not count. | `24120` |
| `X-Request-Id` | Id of this request; also in every error body as `requestId` and in the server logs. Quote it in support requests. | `3f2c9a1e-7b1d-4c0e-9a55-0d2f6e8b1c44` |
| `Access-Control-Allow-Origin` | CORS: images and JSON can be fetched from any origin. | `*` |
---
# Metrics
Prometheus metrics at `GET /metrics` (text format). The endpoint is disabled unless `WHEREBOX_METRICS_TOKEN` is set; scrape it with `Authorization: Bearer `. Route labels are route *patterns* (e.g. `/v1/map.:ext`), so cardinality stays bounded.
| Metric | Type | Labels | Description |
|---|---|---|---|
| `wherebox_build_info` | gauge | `render_version`, `data_version` | Always 1; labels carry the render and data versions of this process. |
| `wherebox_http_requests_total` | counter | `route`, `method`, `status` | HTTP requests by route pattern, method and status code. |
| `wherebox_http_request_duration_seconds` | histogram | `route` | HTTP request latency by route pattern. Buckets (s): 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10. |
| `wherebox_renders_total` | counter | `result` | Map image requests by outcome: `rendered` (new render), `cache_hit` (image cache), `joined` (shared an identical in-flight render). |
| `wherebox_render_stage_seconds` | histogram | `stage` | Time per render stage (resolve, geometry, style, neighbors, raster, encode, total) for new renders. Buckets (s): 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10. |
| `wherebox_render_queue_active` | gauge | — | Renders running now. |
| `wherebox_render_queue_waiting` | gauge | — | Renders waiting for a slot (beyond `WHEREBOX_RENDER_CONCURRENCY`). |
| `wherebox_problems_total` | counter | `code` | Error responses by catalog code (see the errors reference). |
| `wherebox_auth_total` | counter | `outcome` | Key-auth outcomes (`ok` or an error code) when `WHEREBOX_AUTH=keys`. |
| `wherebox_map_pool_total` | counter | `event` | MapLibre map pool events: `created`, `reused`, `discarded`. |
| `wherebox_geometry_cache_total` | counter | `result` | Area/subdivision geometry cache lookups: `hit` or `miss`. |
| `wherebox_geocoder_total` | counter | `result` | Place-name geocoder fallback lookups: `cache` (answered from the in-process cache), `fetched` (found), `miss` (provider knows no such place), `error`. |
| `wherebox_upstream_total` | counter | `result` | Basemap/tile/terrain fetches through the HTTP cache: `cache` (served from disk), `fetched`, `revalidated`, `error`. |
## Example scrape config
```yaml
scrape_configs:
- job_name: wherebox
metrics_path: /metrics
authorization:
credentials:
static_configs:
- targets: ["api:8080"]
```
---
# MCP server
Tools, resources and prompts of the Wherebox MCP server (`packages/mcp`). The hosted endpoint `/mcp` (Streamable HTTP, OAuth) and the local server `npx @wherebox/mcp` (stdio) are built from the same definitions. See the [MCP guide](/docs/guides/mcp.md) for connecting a client.
## Tools
| Tool | Annotations | Description |
|---|---|---|
| `render_map` | readOnlyHint: true, idempotentHint: true, openWorldHint: false | Render a static map image and return it, so you can see it, together with its permanent public URL. Use this to design a map: look at the image and call again with a refined spec until it is right. Arguments are a Wherebox MapSpec: give at least one of `area` (a name or an id such as `iso:CZ-72`, `osm:r51684`), `pois`, `routes` or `bounds`. Defaults: 600×400 CSS px, PNG, the shaded-relief basemap, labels in local language (`lang` for others). Points take `lat`+`lon` or a `place` name; routes take `from`/`to` and default to a geodesic line (routed `car`/`bike`/`foot` profiles need a routing engine and plan). Each new map counts as one render toward the monthly quota; repeating an identical spec is served from cache for free. Ambiguous or unknown names come back as an error listing candidates or close matches. |
| `get_map_url` | readOnlyHint: true, idempotentHint: true, openWorldHint: false | Render a map (or reuse the cached one) and return only its permanent, public image URL (`/v1/images/.`) for embedding in Markdown, HTML or documents, without the image bytes. The URL is content-addressed: it never changes for the same spec, needs no key, and anyone with it can view the image. Arguments are a Wherebox MapSpec: give at least one of `area` (a name or an id such as `iso:CZ-72`, `osm:r51684`), `pois`, `routes` or `bounds`. Defaults: 600×400 CSS px, PNG, the shaded-relief basemap, labels in local language (`lang` for others). Points take `lat`+`lon` or a `place` name; routes take `from`/`to` and default to a geodesic line (routed `car`/`bike`/`foot` profiles need a routing engine and plan). Each new map counts as one render toward the monthly quota; repeating an identical spec is served from cache for free. |
| `search_areas` | readOnlyHint: true, idempotentHint: true, openWorldHint: false | Find admin areas (countries, regions, districts, municipalities) by name. Each result has a `ref` (e.g. "osm:r439539") to pass as `area` or `highlight.subArea`, its admin level, and `context`: its parent areas ("Česko › Liberecký kraj › …"), which tells same-named areas apart. Use it before rendering when a name may be ambiguous. |
| `search_places` | readOnlyHint: true, idempotentHint: true, openWorldHint: false | Find named places (cities, villages, peaks, mountain ranges…) by name. Each result has coordinates and `context`: the areas containing it, which tells same-named places apart. Pass the chosen place as `{"lat": …, "lon": …}` in `pois` or route `from`/`to` (exact), or by `place` name when the name is unique. |
`render_map` and `get_map_url` take a MapSpec, the same JSON as the [request body](/docs/reference/request-body.md) of `POST /v1/map`. The advertised input schema (11706 bytes, budget 12000) is generated from it without examples, with repeated parts in `$defs`, and without these less common fields, which are still accepted: `shapes`, `font`, `quality`, `attribution`, `globe`, `basemap.styleUrl`, `basemap.providerKey`, `routes[].geometry`, `routes[].polyline`, `routes[].polylinePrecision`, `routes[].provider`, `routes[].casingColor`, `routes[].casingWidth`, `routes[].opacity`, `routes[].start.labelWeight`, `routes[].start.labelSize`, `routes[].finish.labelWeight`, `routes[].finish.labelSize`, `pois[].labelWeight`, `pois[].leader`, `marker.strokeColor`, `marker.strokeWidth`, `marker.icon`, `marker.anchor`, `title.position`, `title.align`, `title.size`, `title.weight`, `title.color`, `title.background`, `title.dividerColor`, `title.dividerWidth`, `title.padding`, `highlight.subdivisionColor`, `highlight.subdivisionWidth`, `highlight.subAreaColor`, `highlight.subAreaOpacity`, `highlight.subAreaOutlineWidth`, `highlight.outlineWidth`, `neighbors.size`, `neighbors.color`, `neighbors.uppercase`, `neighbors.max`.
`render_map` returns the image (inline up to 1 MiB) and a text block with its permanent URL; `get_map_url` returns only the URL. Errors are tool results with `isError: true` whose text carries the [error](/docs/errors.md) code, `detail`, and the `candidates`, `suggestions` or `issues`.
Input schema of `search_areas` and `search_places`:
```text
{"type":"object","properties":{"q":{"type":"string","minLength":1,"maxLength":200,"description":"Name to look up, in any language or spelling (accents optional), e.g. \"Zlínský kraj\", \"Chriby\"."},"limit":{"default":10,"description":"Most results to return.","type":"integer","minimum":1,"maximum":50}},"required":["q"]}
```
## Resources
The guides and this reference, as `text/markdown`. Links between pages point at these URIs.
| URI | Title |
|---|---|
| `wherebox://docs/README.md` | Wherebox documentation |
| `wherebox://docs/guides/authentication.md` | Authentication (hosted service) |
| `wherebox://docs/guides/errors-and-limits.md` | Errors and limits |
| `wherebox://docs/guides/fair-use.md` | Fair use |
| `wherebox://docs/guides/getting-started.md` | Getting started |
| `wherebox://docs/guides/locator-maps.md` | Locator maps |
| `wherebox://docs/guides/mcp.md` | AI assistants (MCP) |
| `wherebox://docs/guides/neighbors.md` | Neighboring countries |
| `wherebox://docs/guides/points-and-labels.md` | Points and labels |
| `wherebox://docs/guides/routes.md` | Routes |
| `wherebox://docs/guides/sdk-python.md` | Python SDK |
| `wherebox://docs/guides/sdk-typescript.md` | TypeScript SDK |
| `wherebox://docs/guides/self-hosting.md` | Self-hosting |
| `wherebox://docs/guides/typography-and-titles.md` | Typography and titles |
| `wherebox://docs/guides/use-cases.md` | Recipes by use case |
| `wherebox://docs/reference/README.md` | API reference |
| `wherebox://docs/reference/configuration.md` | Configuration |
| `wherebox://docs/reference/errors.md` | Errors |
| `wherebox://docs/reference/http.md` | HTTP: auth parameters and headers |
| `wherebox://docs/reference/markers.md` | Markers |
| `wherebox://docs/reference/mcp.md` | MCP server |
| `wherebox://docs/reference/metrics.md` | Metrics |
| `wherebox://docs/reference/parameters.md` | Query parameters |
| `wherebox://docs/reference/presets.md` | Basemap presets |
| `wherebox://docs/reference/request-body.md` | Request body |
## Prompts
| Prompt | Arguments | Description |
|---|---|---|
| `locator-map` | `subject`, `notes` | Make a "where is it" map: a place or area shown inside its country or region, Wikipedia locator style. |
| `route-map` | `from`, `to`, `notes` | Draw a route or flight line between two places, with labeled ends. |
## Server instructions
Sent to the client in the `initialize` result:
> Wherebox renders static map images: locator maps (an area highlighted, its surroundings faded), labeled points, route and flight lines, and labels for neighboring countries. A map is described by one JSON MapSpec.
>
> Workflow: call render_map, look at the returned image, and refine the spec (size, labels, highlight, framing) until it is right; then use the permanent image URL it returns, or call get_map_url, to embed the map (Markdown, HTML <img>, documents).
>
> Names can be ambiguous ("Nová Ves") or unknown: the tool error lists candidates with a `ref` to pass as `area`, or close matches. Use search_areas / search_places to look names up first when unsure, and prefer the returned `ref` ids and coordinates over names.
>
> The guides and the full reference are available as resources (wherebox://docs/…), including fields the tool schema leaves out (shapes, fonts, title styling, routes from your own geometry).
---
# Errors
Errors are [RFC 7807](https://www.rfc-editor.org/rfc/rfc7807) `application/problem+json` responses. The `type` field is a URL of the code's entry below (`https://wherebox.dev/docs/errors#`, or under the server's `WHEREBOX_DOCS_URL`), so clients can branch on the code after `#`.
Every problem also carries:
- `requestId`: Id of the request (same as the `X-Request-Id` header). Quote it when reporting a problem.
## `invalid-spec`
**400** · Invalid map spec
A parameter or JSON field is missing, malformed or out of range — including unknown query parameters (with a "did you mean" hint).
Extra fields:
- `issues`: `[{path, message}]` — one entry per problem.
```json
{
"type": "https://wherebox.dev/docs/errors#invalid-spec",
"title": "Invalid map spec",
"status": 400,
"detail": "The request does not match the map spec",
"issues": [
{
"path": "width",
"message": "Too big: expected number to be <=2048"
},
{
"path": "fadecolour",
"message": "unknown parameter \"fadecolour\" (did you mean \"fadecolor\"?)"
}
],
"requestId": "3f2c9a1e-7b1d-4c0e-9a55-0d2f6e8b1c44"
}
```
## `bad-request`
**400** · Bad request
The request cannot be processed as sent: an unsupported image extension, an invalid image key, a missing search query, a rejected icon or font, a provider/routing key that is too long, routing that is not configured on this server, or a `worldview` whose data is not imported on this server.
```json
{
"type": "https://wherebox.dev/docs/errors#bad-request",
"title": "Bad request",
"status": 400,
"detail": "routing is not configured on this server",
"requestId": "3f2c9a1e-7b1d-4c0e-9a55-0d2f6e8b1c44"
}
```
## `not-found`
**404** · Not found
No area or place matches the name, or a cached image expired.
Extra fields:
- `suggestions`: Close matches, when there are any.
```json
{
"type": "https://wherebox.dev/docs/errors#not-found",
"title": "Not found",
"status": 404,
"detail": "no area named \"Prgaue\"",
"suggestions": [
{
"id": "r435514",
"ref": "osm:r435514",
"name": "Praha",
"adminLevel": 4,
"context": "Česko"
}
],
"requestId": "3f2c9a1e-7b1d-4c0e-9a55-0d2f6e8b1c44"
}
```
## `ambiguous`
**409** · Ambiguous name
Several areas match the name. Pick one by id (`osm:r…`) or narrow with `level`/`country`.
Extra fields:
- `candidates`: Matching areas with `ref` (pass it as `area`, e.g. `osm:r439539`), `adminLevel` and `context` (e.g. "Česko › Liberecký kraj").
```json
{
"type": "https://wherebox.dev/docs/errors#ambiguous",
"title": "Ambiguous name",
"status": 409,
"detail": "\"Nová Ves\" matches 10 areas; pass level, country or an id",
"candidates": [
{
"id": "r439539",
"ref": "osm:r439539",
"name": "Nová Ves",
"adminLevel": 8,
"context": "Česko › Karlovarský kraj › okres Sokolov"
},
{
"id": "r439063",
"ref": "osm:r439063",
"name": "Nová Ves",
"adminLevel": 8,
"context": "Česko › Liberecký kraj › okres Liberec"
}
],
"requestId": "3f2c9a1e-7b1d-4c0e-9a55-0d2f6e8b1c44"
}
```
## `unauthorized`
**401** · Unauthorized
No API key, or the key is unknown. Send `Authorization: Bearer sk_…` (server-to-server) or a signed URL with `apikey=pk_…&sig=…` (embeds). Apps connected with OAuth get this when their access token is expired, revoked or issued for another resource; they should refresh it.
```json
{
"type": "https://wherebox.dev/docs/errors#unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "missing API key",
"requestId": "3f2c9a1e-7b1d-4c0e-9a55-0d2f6e8b1c44"
}
```
## `forbidden`
**403** · Forbidden
The key is revoked, the account is disabled, the URL signature is wrong or expired, the origin is not allowed for this key, or a secret key was put in a URL.
```json
{
"type": "https://wherebox.dev/docs/errors#forbidden",
"title": "Forbidden",
"status": 403,
"detail": "invalid URL signature",
"requestId": "3f2c9a1e-7b1d-4c0e-9a55-0d2f6e8b1c44"
}
```
## `plan-limit`
**403** · Not included in your plan
The request needs something the key's plan does not include, such as a larger image or server-side routing.
```json
{
"type": "https://wherebox.dev/docs/errors#plan-limit",
"title": "Not included in your plan",
"status": 403,
"detail": "image is 4.2 MP; the free plan allows up to 1 MP",
"requestId": "3f2c9a1e-7b1d-4c0e-9a55-0d2f6e8b1c44"
}
```
## `rate-limited`
**429** · Too many requests
The key exceeded its requests per minute. Wait `Retry-After` seconds; see the `X-RateLimit-*` headers.
```json
{
"type": "https://wherebox.dev/docs/errors#rate-limited",
"title": "Too many requests",
"status": 429,
"detail": "rate limit of 30 requests/minute exceeded",
"requestId": "3f2c9a1e-7b1d-4c0e-9a55-0d2f6e8b1c44"
}
```
## `quota-exceeded`
**429** · Monthly quota exceeded
The account used all renders of its plan this month (UTC). Cached images keep working; new renders resume next month (UTC) or once the limit is raised. The `detail` says which allowance (all renders or routed renders) is used up.
```json
{
"type": "https://wherebox.dev/docs/errors#quota-exceeded",
"title": "Monthly quota exceeded",
"status": 429,
"detail": "monthly quota of 1000 renders used",
"requestId": "3f2c9a1e-7b1d-4c0e-9a55-0d2f6e8b1c44"
}
```
## `no-route`
**422** · No route found
The routing provider found no route between the points for this profile.
```json
{
"type": "https://wherebox.dev/docs/errors#no-route",
"title": "No route found",
"status": 422,
"detail": "no route found between the points for profile \"foot\"",
"requestId": "3f2c9a1e-7b1d-4c0e-9a55-0d2f6e8b1c44"
}
```
## `busy`
**503** · Busy
The render queue is full. Retry after the `Retry-After` seconds.
```json
{
"type": "https://wherebox.dev/docs/errors#busy",
"title": "Busy",
"status": 503,
"detail": "renderer is busy, retry shortly",
"requestId": "3f2c9a1e-7b1d-4c0e-9a55-0d2f6e8b1c44"
}
```
## `upstream-failed`
**502** · Upstream failure
A basemap, tile, routing, geocoder or asset server failed or returned something unusable.
Extra fields:
- `detail`: What failed (secrets removed).
```json
{
"type": "https://wherebox.dev/docs/errors#upstream-failed",
"title": "Upstream failure",
"status": 502,
"detail": "GET https://tiles.example.com/style.json -> 500",
"requestId": "3f2c9a1e-7b1d-4c0e-9a55-0d2f6e8b1c44"
}
```
## `upstream-timeout`
**504** · Upstream timeout
A routing, geocoder or asset server did not answer in time.
```json
{
"type": "https://wherebox.dev/docs/errors#upstream-timeout",
"title": "Upstream timeout",
"status": 504,
"detail": "routing provider did not answer within 10000 ms",
"requestId": "3f2c9a1e-7b1d-4c0e-9a55-0d2f6e8b1c44"
}
```
---
# Configuration
Environment variables read by the server and tools. Variables marked **secret** hold credentials: never log them or put them in URLs. Empty values count as unset.
## Server
| Variable | Default | Secret | Description |
|---|---|---|---|
| `PORT` | `8080` | | HTTP port of the API. |
| `HOST` | `0.0.0.0` | | Interface to listen on. |
| `LOG_LEVEL` | `info` | | Log level (fatal, error, warn, info, debug, trace). |
| `WHEREBOX_PUBLIC_URL` | `derived from the request` | | Public base URL used for `url` in JSON responses (e.g. `https://maps.example.com`). |
| `WHEREBOX_RENDER_CONCURRENCY` | `CPUs − 1` | | Renders running at the same time per process. |
| `WHEREBOX_MAP_REUSE` | `300` | | Renders per pooled MapLibre instance before it is recycled (bounds native memory). |
| `WHEREBOX_METRICS_TOKEN` | `(metrics disabled)` | secret | Bearer token for the Prometheus endpoint `/metrics`; unset keeps it disabled. Scrape with `Authorization: Bearer `. |
| `WHEREBOX_RENDER_QUEUE` | `64` | | Renders waiting beyond the concurrency limit; more get 503 with Retry-After. |
| `WHEREBOX_SITE_URL` | — | | Base URL of the website that hosts the playground page (e.g. `https://wherebox.dev`). When set, `/playground` redirects to `/playground/`, which must route `/v1/*` to this API on the same origin. Unset: this server serves its own standalone playground. |
| `WHEREBOX_DOCS_URL` | `https://wherebox.dev/docs` | | Base URL of the published docs. Problem `type` URIs point at `/errors#`; set it to serve your own copy of the docs (the code after `#` stays the same). |
## Data
| Variable | Default | Secret | Description |
|---|---|---|---|
| `DATABASE_URL` | `postgres:///wherebox` | secret | PostGIS database with the imported areas, places and countries. Country-level areas fall back to Natural Earth (the countries table) when the OSM import does not cover them. |
## Cache
| Variable | Default | Secret | Description |
|---|---|---|---|
| `WHEREBOX_GEOMETRY_CACHE` | `256` | | Area/subdivision geometries kept in memory (LRU; 0 = off). Cleared when the data version changes. |
| `WHEREBOX_CACHE` | `disk` | | Rendered-image cache: `disk`, `s3` (S3/R2/MinIO) or `none`. |
| `WHEREBOX_IMAGE_CACHE_DIR` | `data/cache/images` | | Directory for the disk image cache. |
| `WHEREBOX_IMAGE_CACHE_MAX_MB` | `2048` | | Disk image cache cap; least-recently-used images are evicted (0 = unlimited). |
| `WHEREBOX_TILE_TTL_HOURS` | `720` | | After this many hours cached tiles, glyphs and terrain are revalidated in the background (stale ones keep serving). |
| `WHEREBOX_STYLE_TTL_HOURS` | `24` | | Revalidation age for cached styles, TileJSON and sprites, so basemap updates show up. |
| `WHEREBOX_CACHE_DIR` | `data/cache/http` | | Cache for basemap styles, tiles, glyphs and terrain. |
| `WHEREBOX_S3_BUCKET` | — | | Bucket for `WHEREBOX_CACHE=s3` (credentials via the standard AWS variables). |
| `WHEREBOX_S3_PREFIX` | `maps/` | | Key prefix inside the bucket. |
| `WHEREBOX_S3_ENDPOINT` | `AWS` | | Endpoint for S3-compatible stores (R2, MinIO). |
| `WHEREBOX_S3_REGION` | `auto` | | Region for the S3 client. |
## Routing
| Variable | Default | Secret | Description |
|---|---|---|---|
| `WHEREBOX_ROUTING_PROVIDER` | `valhalla` | | Default routing provider: `valhalla`, `openrouteservice` or `graphhopper`. |
| `WHEREBOX_VALHALLA_URL` | `(routing disabled)` | | Valhalla server for car/bike/foot routes. Self-host it for production; the public FOSSGIS instance is for development only. |
| `WHEREBOX_ORS_KEY` | — | secret | Server-side openrouteservice key (callers can send `X-Routing-Key` instead). |
| `WHEREBOX_ORS_URL` | `https://api.openrouteservice.org` | | openrouteservice base URL. |
| `WHEREBOX_GRAPHHOPPER_KEY` | — | secret | Server-side GraphHopper key. |
| `WHEREBOX_GRAPHHOPPER_URL` | `https://graphhopper.com/api/1` | | GraphHopper base URL. |
| `WHEREBOX_ROUTING_TIMEOUT_MS` | `10000` | | Timeout for routing requests. |
## Geocoding
| Variable | Default | Secret | Description |
|---|---|---|---|
| `WHEREBOX_GEOCODER_URL` | `(fallback disabled)` | | Geocoder for place names the imported OSM data does not know (`place:` points and route ends). Query parameters in the URL (e.g. `?key=…`) are kept on every request and never appear in errors. Self-host Nominatim or Photon for production; the public instances allow about 1 request/s and are for development only. |
| `WHEREBOX_GEOCODER_PROVIDER` | `nominatim` | | API flavour of `WHEREBOX_GEOCODER_URL`: `nominatim` (also LocationIQ and other Nominatim-compatible services) or `photon`. |
| `WHEREBOX_GEOCODER_USER_AGENT` | `wherebox (self-hosted static map renderer)` | | User-Agent sent to the geocoder. Public Nominatim requires one that identifies your application. |
| `WHEREBOX_GEOCODER_RPS` | `1` | | Maximum geocoder requests per second per process (0 = unlimited). Results are cached, so repeated names cost nothing. |
| `WHEREBOX_GEOCODER_TIMEOUT_MS` | `5000` | | Timeout per lookup, including time spent waiting for a rate-limit slot. |
## Remote assets (security)
| Variable | Default | Secret | Description |
|---|---|---|---|
| `WHEREBOX_AUTH` | `none` | | API access: `none` (open, for self-hosting) or `keys` (API keys, signed URLs, rate limits, quotas, usage metering). |
| `WHEREBOX_AUTH_SECRET` | `(required with keys)` | secret | Master secret (≥ 32 chars) for key hashing and URL-signing secrets. Changing it invalidates all keys. |
| `WHEREBOX_PLANS` | `built-in free/plus/internal` | | JSON plan definitions replacing the built-in ones: `{"free": {"monthlyRenders": 10000, "monthlyRoutedRenders": 1000, "perMinute": 60, "maxPixels": 4194304, "routing": true}, …}`. Limits apply per account; 0 means unlimited. New accounts get `free`. |
| `WHEREBOX_MORE_USAGE_URL` | `(none)` | | Page explaining how to ask for higher limits, linked from quota and plan-limit errors (the hosted service uses its fair-use page, `https://wherebox.dev/docs/guides/fair-use/#need-more`). |
| `WHEREBOX_CONTACT_EMAIL` | `(none)` | | Address for limit requests (hosted: `hello@wherebox.dev`). The Usage page opens an email to it with the account details filled in; without it, the page links to `WHEREBOX_MORE_USAGE_URL`. |
| `WHEREBOX_GITHUB_CLIENT_ID` | `(GitHub sign-in off)` | | Client ID of the GitHub OAuth app for "Continue with GitHub" in the console (`WHEREBOX_AUTH=keys`). Its callback URL is `/auth/github/callback`. |
| `WHEREBOX_GITHUB_CLIENT_SECRET` | `(GitHub sign-in off)` | secret | Client secret of that GitHub OAuth app; set both or neither. |
| `WHEREBOX_RESEND_API_KEY` | `(links printed to stdout)` | secret | Resend API key for magic-link sign-in emails. Unset, sign-in links are printed to the log, which is allowed only when the public URL is http://localhost. |
| `WHEREBOX_MAIL_FROM` | — | | Sender of sign-in emails, e.g. `Wherebox ` (a domain verified in Resend). Required with `WHEREBOX_RESEND_API_KEY`. |
| `WHEREBOX_STRIPE_SECRET_KEY` | `(billing off)` | secret | Stripe secret (or restricted) API key for the console billing pages. Billing is on only when this, `WHEREBOX_STRIPE_WEBHOOK_SECRET` and `WHEREBOX_STRIPE_PRICES` are all set. |
| `WHEREBOX_STRIPE_WEBHOOK_SECRET` | `(billing off)` | secret | Signing secret (`whsec_…`) of the Stripe webhook endpoint `/webhooks/stripe`; every webhook is verified with it. |
| `WHEREBOX_STRIPE_PRICES` | `(billing off)` | | JSON mapping plan id to Stripe price id, e.g. `{"plus":"price_…"}`. Each plan must exist in the plans config (`WHEREBOX_PLANS` or the built-in ones). |
| `WHEREBOX_ICON_HOSTS` | `(remote icons disabled)` | | Comma-separated hosts allowed for remote marker icons (`*.example.com` matches subdomains). Empty = `data:` URIs only. |
| `WHEREBOX_ICON_MAX_BYTES` | `100000` | | Maximum icon size. |
| `WHEREBOX_ICON_TIMEOUT_MS` | `5000` | | Timeout for fetching a remote icon. |
| `WHEREBOX_FONT_HOSTS` | `same as WHEREBOX_ICON_HOSTS` | | Hosts allowed for custom font URLs. |
| `WHEREBOX_FONT_MAX_BYTES` | `3000000` | | Maximum custom font size. |
| `WHEREBOX_FONT_TIMEOUT_MS` | `8000` | | Timeout for fetching a custom font. |
## Import tools
| Variable | Default | Secret | Description |
|---|---|---|---|
| `WORK_DIR` | `data/work` | | Scratch directory for the OSM and Natural Earth imports. |
| `NE_WORLDVIEWS` | `all` | | Natural Earth worldviews imported by `pnpm import:countries` for the `worldview` parameter: `all`, `none`, or codes such as `in cn ma` (about 13 MB download each). Border lines for all worldviews are always imported; this picks the country shapes. |
| `NE_VERSION` | `v5.1.2` | | Natural Earth release used by `pnpm import:countries` (pinned for reproducible renders). |
## Deployment
| Variable | Default | Secret | Description |
|---|---|---|---|
| `WHEREBOX_DOMAIN` | `localhost` | | Public domain for Caddy (TLS) in docker-compose. |
## Debugging
| Variable | Default | Secret | Description |
|---|---|---|---|
| `WHEREBOX_DEBUG` | — | | Set to log MapLibre Native errors. |
## Local MCP server (`npx @wherebox/mcp`)
| Variable | Default | Secret | Description |
|---|---|---|---|
| `WHEREBOX_API_URL` | `https://api.wherebox.dev` | | Wherebox API the local MCP server calls: the hosted service, or your own server (e.g. `http://localhost:8080`). |
| `WHEREBOX_API_KEY` | `(none: only for servers without keys)` | secret | Secret key (`sk_…`) the local MCP server sends as `Authorization: Bearer`. Required for the hosted service; leave unset for a self-hosted server with `WHEREBOX_AUTH=none`. |
---
# Markers
Marker preset. Defaults depend on the role: plain point → `pin`, route start → `ring`, route finish → `flag`.
Set a preset with `marker:` inside `poi=`, `frommarker`/`tomarker` for routes, or `marker.shape` in the JSON body. Colors, sizes and outlines can be overridden per marker.
## Presets
| Preset | Swatch | Default size (px) | Default outline (px) | Default for |
|---|---|---|---|---|
| `pin` |  | 24 | 1 | `point` |
| `dot` |  | 11 | 1.5 | — |
| `ring` |  | 13 | 0 | `start` |
| `flag` |  | 20 | 0 | `finish` |
| `triangle` |  | 14 | 0 | — |
| `circle` |  | 12 | 0 | — |
| `square` |  | 12 | 0 | — |
| `star` |  | 16 | 0 | — |
| `diamond` |  | 14 | 0 | — |
Swatches show each preset in the default point color.
## Role defaults
| Role | Swatch | Shape | Color | Size (px) |
|---|---|---|---|---|
| `point` |  | `pin` | `#e02020` | 24 |
| `start` |  | `ring` | `#1a1a1a` | 13 |
| `finish` |  | `flag` | `#1a1a1a` | 20 |
Route start and finish markers take the basemap theme's label color instead of the color above (see [Presets](/docs/reference/presets.md#text-defaults)).
---
# Basemap presets
Built-in basemap: shaded relief (Wikipedia locator style), light, or dark. No key needed.
| Preset | Default |
|---|---|
| `locator-relief` | yes |
| `light` | |
| `dark` | |
A basemap given by `style` (a style URL) uses the `custom` column below.
## Highlight defaults
Effective value of each [highlight parameter](/docs/reference/parameters.md#highlight) per preset. **Bold** values differ from the base default; an empty cell means the field is unset.
| Field | `locator-relief` | `light` | `dark` | `custom` |
|---|---|---|---|---|
| `fade` | `0.45` | **`0.7`** | **`0.5`** | `0.45` |
| `fadeColor` | `#ffffff` | **`#dedede`** | **`#000000`** | `#ffffff` |
| `outlineColor` | `#555555` | **`#4a4a4a`** | **`#e6e6e6`** | `#555555` |
| `outlineWidth` | `1.1` | `1.1` | `1.1` | `1.1` |
| `tintOpacity` | `0.15` | `0.15` | **`0.08`** | `0.15` |
| `subdivisions` | `auto` | `auto` | `auto` | `auto` |
| `subdivisionColor` | `#8a8a8a` | **`#a0a0a0`** | **`#8c8c8c`** | `#8a8a8a` |
| `subdivisionWidth` | `0.6` | `0.6` | `0.6` | `0.6` |
| `desaturate` | `0` | `0` | `0` | `0` |
| `blur` | `0` | `0` | `0` | `0` |
| `subAreaColor` | `#d03030` | `#d03030` | `#d03030` | `#d03030` |
| `subAreaOpacity` | `0.35` | `0.35` | `0.35` | `0.35` |
| `subAreaOutlineWidth` | `1.2` | `1.2` | `1.2` | `1.2` |
| `tint` | | | **`#ffffff`** | |
## Text defaults
Label, halo and attribution colors per preset.
| Field | `locator-relief` | `light` | `dark` | `custom` |
|---|---|---|---|---|
| `labelColor` | `#1a1a1a` | `#1a1a1a` | `#f2f2f2` | `#1a1a1a` |
| `haloColor` | `#ffffff` | `#ffffff` | `#000000` | `#ffffff` |
| `attributionColor` | `#4a4a4a` | `#4a4a4a` | `#b4b4b4` | `#4a4a4a` |
| `neighborColor` | `#5f5f5f` | `#5f5f5f` | `#a6a6a6` | `#5f5f5f` |