# Ultimaps Public API: complete guide for developers and agents
Ultimaps renders vector maps of countries, states, provinces, counties and the world as images. One HTTP call, a declarative JSON body, a PNG (or SVG) back. No tiles, no JavaScript SDK, no map styling language. You say which regions get which colors, or hand over raw numbers and let the API build the choropleth, and the API returns the finished picture.
- Base URL: `https://api.ultimaps.com`
- Version: `/v1` (path-versioned, evolves additively, see "Versioning and deprecation")
- Contract: `https://api.ultimaps.com/v1/openapi.json` (OpenAPI 3.1)
- Request schema: `https://api.ultimaps.com/v1/schemas/render-request.json` (JSON Schema 2020-12)
- Tool definition: `https://api.ultimaps.com/v1/tools/render_map.json`
- Human reference: `https://ultimaps.com/docs/api/`
- Errors: `application/problem+json` (RFC 9457) with a stable `code` field
Every request example in this document is validated against the live request schema in CI. Copy them verbatim.
## 1. Thirty-second start
**No key, no signup.** Paste this in a terminal:
```bash
curl https://api.ultimaps.com/v1/renders \
-H "Content-Type: application/json" \
-d '{
"mapId": "united-states",
"regions": {
"US-CA": "#1D4ED8",
"US-TX": "#F59E0B",
"New York": "#10B981"
},
"title": {
"text": "Where we operate"
},
"style": {
"labels": {
"show": true
}
},
"output": {
"width": 1200
}
}' \
-o map.png
```
Or drop the same request into an `
` tag or Markdown image. The GET variant is keyless and cached for 24 hours:
```
https://api.ultimaps.com/v1/renders?spec=%7B%22mapId%22%3A%22united-states%22%2C%22regions%22%3A%7B%22US-CA%22%3A%22%231D4ED8%22%2C%22US-TX%22%3A%22%23F59E0B%22%2C%22New%20York%22%3A%22%2310B981%22%7D%2C%22title%22%3A%7B%22text%22%3A%22Where%20we%20operate%22%7D%2C%22style%22%3A%7B%22labels%22%3A%7B%22show%22%3Atrue%7D%7D%2C%22output%22%3A%7B%22width%22%3A1200%7D%7D
```
Keyless renders are PNG, at most 1600 px wide, carry an "ultimaps.com" attribution, and are limited to 30 per hour per IP (burst 5 per minute). Enough to try things and to embed a map in a README or a dashboard.
**With a key** (Free: 500 renders/month. Pro: 5,000/month with SVG and no watermark). See "Getting an API key" below, then:
```bash
export ULTIMAPS_API_KEY=um_live_…
curl https://api.ultimaps.com/v1/renders \
-H "Authorization: Bearer $ULTIMAPS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"mapId": "united-states",
"choropleth": {
"values": {
"California": 39.5,
"Texas": 30.5,
"Florida": 22.6,
"New York": 19.6,
"Pennsylvania": 13,
"Illinois": 12.5,
"Ohio": 11.8,
"Georgia": 11,
"North Carolina": 10.8,
"Michigan": 10
},
"type": "groups",
"palette": "blues",
"classes": 5,
"method": "quantile",
"noDataColor": "#EEEEEE",
"format": {
"decimals": 1,
"suffix": "M"
}
},
"legend": {
"position": "left"
},
"title": {
"text": "Population by state, 2025"
},
"style": {
"labels": {
"show": true,
"content": "value"
}
},
"output": {
"width": 1600,
"scale": 1
}
}' \
-o map.png
```
## 2. Getting an API key
Keys live in Studio, under **Workspace → API**: `https://studio.ultimaps.com/account/workspace/api`. Sign in, open your workspace, and create a key from the API tab. Free and Pro accounts both mint keys there.
The key is shown once, at creation. Copy it then and store it somewhere safe. If you lose it, rotate the key: the replacement is shown immediately and the old key keeps working for 72 hours, so you have time to update your integrations.
Send the key on every request:
```bash
curl https://api.ultimaps.com/v1/usage -H "Authorization: Bearer um_live_…"
```
`Authorization: Bearer ` is the canonical form. `X-API-Key: ` is an accepted alias for tools that cannot set the Authorization header. A request that carries an invalid key is always `401 unauthorized`. It never silently falls back to the keyless tier.
Keep keys server-side. A key in browser JavaScript is a key anyone can read and spend. For public embeds use the keyless `GET /v1/renders?spec=…` URL instead.
## 3. Tiers and limits
| | Keyless | Free key | Pro key |
|---|---|---|---|
| Auth | none | `Authorization: Bearer um_live_…` | same |
| Formats | PNG | PNG | PNG, SVG |
| Attribution | full watermark | full watermark | none |
| Canvas | ≤ 1600×1600 px, scale 1 | ≤ 1600×1600 px, scale ≤ 2 | ≤ 4000×4000 px, scale ≤ 4 (≤ 4096² pixels) |
| Rate limit | 30/hour per IP (IPv6 /64), burst 5/min | 10/min | 30/min |
| Daily cap | none | 50 | 1,000 |
| Monthly renders | none | 500 | 5,000 |
| Concurrency | 1 | 2 | 2 |
The documented limits are the ones you hit in practice, so a busy client gets a predictable `429 rate_limit_exceeded` with `Retry-After` rather than a surprising `concurrency_limit_reached`. Monthly quota is a billing state: `402 monthly_quota_exceeded` is never retryable, and carries `quota_resets_at` (first of next month, UTC) and `upgrade_url`. Only delivered images count, and a failed render refunds its reservation. `dryRun: true` requests are rate-limited but never consume quota.
Successful render responses carry `X-RateLimit-Limit` (per minute with a key, per hour keyless), `X-RateLimit-Remaining` and `X-RateLimit-Reset` (epoch seconds). The `429`, `403` and `402` errors do not, though a `429` carries `Retry-After` instead. Check `GET /v1/usage` with your key for the full picture. `renders.used` and `renders.quota` are the monthly workspace-wide counter, `renders.today` is this key's daily counter:
```bash
curl https://api.ultimaps.com/v1/usage -H "Authorization: Bearer $ULTIMAPS_API_KEY"
```
```json
{ "plan": "free", "renders": { "used": 42, "quota": 500, "resetsAt": "2026-10-01T00:00:00.000Z", "today": { "used": 7, "cap": 50 } } }
```
## 4. Discovery: which maps and which region keys exist
You cannot write a render request without a valid `mapId` and region keys. Both discovery endpoints are keyless and cached for an hour.
**List maps.** `GET /v1/maps` returns the catalog (id, title, what a "region" is on that map, extra layers such as lakes or roads). Ids are stable slugs that match the map's ultimaps.com URL and always point at the current edition of a map. `?q=` filters by id, title, region type or category:
```bash
curl https://api.ultimaps.com/v1/maps
curl "https://api.ultimaps.com/v1/maps?q=counties"
```
```json
{
"data": [
{ "id": "united-states", "title": "United States", "regionType": "State", "layers": ["roads", "cities"], "regionCount": 51, "labels": true },
{ "id": "europe", "title": "Europe", "regionType": "Country", "layers": [], "regionCount": 50, "labels": true },
{ "id": "california-counties", "title": "California Counties", "regionType": "County", "layers": [], "regionCount": 58, "labels": false }
],
"meta": { "matched": 3, "total": 250 }
}
```
(`layers` are the extra layers this map ships, toggled through the request's `layers` field. The ids come from one fixed vocabulary shared by every map: `admin0`, `admin0-mesh`, `admin1-mesh`, `admin1-mesh-inner`, `admin2-mesh`, `lakes`, `rivers`, `roads`, `cities`. `regionCount` tells you how many keys a full dataset needs. `labels` says whether the map ships curated region labels, which is what `style.labels` draws. Most county and district maps have none. `meta.matched` against `meta.total` shows what `?q=` filtered out.)
**Inspect one map.** `GET /v1/maps/{mapId}` returns every region as `{key, title}`:
```bash
curl https://api.ultimaps.com/v1/maps/united-states
```
```json
{
"id": "united-states",
"title": "United States",
"regionType": "State",
"regionTypePlural": "States",
"layers": ["roads", "cities"],
"regionCount": 51,
"labels": true,
"regions": [
{ "key": "US-CA", "title": "California" },
{ "key": "US-TX", "title": "Texas" }
],
"page": { "offset": 0, "limit": null, "matched": 51 }
}
```
`key` is the recommended identifier: the region's code in the map's primary scheme (ISO 3166, FIPS, ZCTA, …), or its title on maps with no published codes. It is unique within the map, and it is what matching reports and suggestions cite. `?q=` narrows the list by key, title *or any accepted alias*, so `?q=CA` finds California through its USPS abbreviation even though only `US-CA` is printed. `?offset=` and `?limit=` page through large maps (US counties has 3,143 regions). `regionCount` and `page.matched` always tell you the full size, so nothing is truncated silently.
**Region matching.** You rarely need this call before rendering. Keys in `regions`, `choropleth.values` and `categories.values` are matched case-insensitively against region keys, titles, common aliases (USPS abbreviations, alternate names) and normalized titles (diacritics stripped, "St." becomes "Saint"). `"US-CA"`, `"California"` and `"CA"` all color California. Unambiguous typos are auto-corrected ("Calfornia" → California) and every correction is reported: in the JSON body of dry runs and errors, and on image responses through the `X-Ultimaps-Corrected`, `X-Ultimaps-Unmatched` and `X-Ultimaps-Matching` headers. In production pipelines set `"onUnmatched": "error"` so a bad key fails the request instead of rendering a map with holes.
## 5. The render request
`POST /v1/renders` with `Content-Type: application/json`. One object, strict (unknown fields are rejected with a `400` that names them):
| Field | Purpose |
|---|---|
| `mapId` | required, from `GET /v1/maps` |
| `regions` | `{ regionKey: "#RRGGBB" }`, direct colors. Final override layer: composes with `choropleth` or `categories`. |
| `choropleth` | numeric choropleth: `values` `{ regionKey: number }` alone is a complete request. `type` is `auto` (default), `gradient`, `steps` or `groups`, and auto picks from the data (7 or fewer unique values gives groups, skewed data gives steps, else gradient). `palette` (ColorBrewer id, absent = suggested, diverging when the data crosses zero, else blues), `classes` 2–9 (absent = suggested, explicit counts honored exactly, tied data may still collapse to fewer), `method` `quantile` / `equalInterval` / `jenks` / `pretty` (absent = suggested), `noDataColor`, and `format`, the number format for EVERY choropleth surface (class labels, gradient ticks, tooltips). `format` takes a preset string, `auto` (default, inferred from magnitude), `plain` (39,538,223), `compact` (39.5M), `percent` (the input is a FRACTION, so 0.42 prints as "42.3%", and pre-scaled data uses `{"suffix": "%"}` instead) or `currency` ($…, USD), or a FormatSpec object with the fields `style`, `currency`, `compact`, `decimals` 0–6, `significantDigits` 1–6, `trim`, `grouping`, `sign` (`auto`/`always`/`exceptZero`), `prefix` (after the sign), `suffix` (e.g. `" per km²"`), or `{"d3": "~s"}` as the raw-grammar escape hatch. Raw d3-format strings are a `400`. The legend is generated. Gradient and steps legends are horizontal-only and render in a top/bottom band (default bottom). The resolved plan comes back in `dryRun` and the `X-Ultimaps-Choropleth` header. |
| `categories` | categorical coloring: `values` `{ regionKey: "Label" }`, optional `colors` `{ Label: "#RRGGBB" }`. Label names match exactly and case-sensitively. Unlisted labels get a built-in categorical palette in order of first appearance, so pin the colors for stable output. Legend is generated. |
| `legend` | `position` left/right (a 180 px column carved from the map area, capped at 30% of the canvas on narrow renders) or top/bottom (a horizontal band between the title and the map, 60 px for item legends, which flow in one centered row with no wrapping, and 70 px for gradient/steps bars). Absent = left for item legends, bottom for gradient/steps (horizontal-only, so left/right is coerced to bottom with a warning). `show` (default true whenever a legend exists), and `items` `[ { label, color } ]` for manual legends on regions-only maps |
| `title` | `{ text, position: top|bottom, color }` |
| `locations` | up to 200 pins: `{ title, lat, lon, color?, labelPosition?, showLabel? }`. `labelPosition` is `top`/`bottom`/`left`/`right` (default `bottom`). `showLabel: false` hides that pin's label. |
| `style` | `theme` (`paper`, `dark`, `newspaper`, …), `backgroundColor`, `defaultRegionColor`, `regionBorders { color, width }`, `mapBorder { color, width }`, `labels { show, content, color }` (region labels, off by default). `content` is `name` (default) or `value`. `value` prints each region's choropleth number, formatted by `choropleth.format`, instead of its name. Regions without a value get no label, and it requires `choropleth` (a `400` otherwise). Maps with no curated labels (`labels: false` in the catalog) draw nothing and report a `labels_unavailable` warning. |
| `layers` | extra geographic layers, e.g. `{ "cities": true, "roads": true }`. Keys are a fixed enum: `admin0` (surrounding land), `admin0-mesh` (country borders), `admin1-mesh` (state and province borders), `admin1-mesh-inner` (state borders over the map's own regions, on county and ZIP maps), `admin2-mesh` (county borders), `lakes`, `rivers`, `roads`, `cities` (labelled points). Any other key is a `400`. Each map ships a subset, listed as its `layers` in `GET /v1/maps/{mapId}`. Asking for one the map lacks still renders, minus that layer, with a `layer_unavailable` warning. Everything is off unless requested, except layers the map turns on by default (`admin0` on ZIP-code maps, the land no ZIP covers). Pass `false` to hide those. |
| `output` | `width` 100–4000 (default 1200), `height` (optional, and when omitted it derives as a snug fit: the map's aspect ratio applied to what remains after the title band and any legend column take their cut, plus the legend band for top/bottom legends), `scale` 1–4 (PNG pixel density, no effect on SVG), `format` `png` (default) or `svg` |
| `onUnmatched` | `warn` (default: render, report) or `error` (fail with suggestions) |
| `dryRun` | `true` → validate, match, resolve the visualization plan and return JSON. No render, no quota. |
Rules worth knowing: `choropleth` and `categories` are mutually exclusive, since a map has one scale, and `regions` may be combined with either. In choropleth mode, zero matched keys is a `400` even under `warn`, since there is nothing to build a scale from. Categories has no such check and just renders blank. Colors are `#RRGGBB` or `#RRGGBBAA`. `choropleth.values` and `categories.values` accept up to 5,000 entries, `regions` up to 5,000, bodies up to 2 MB, and `width×scale` and `height×scale` each cap at 8,192.
The full machine-readable schema is at `https://api.ultimaps.com/v1/schemas/render-request.json`.
## 6. Cookbook
### 6.1 Region colors for an embed (keyless)
Direct colors, a title, the default 1200 px canvas. Works with or without a key. Keyless output carries the attribution.
```bash
curl https://api.ultimaps.com/v1/renders \
-H "Content-Type: application/json" \
-d '{
"mapId": "united-states",
"regions": {
"US-CA": "#1D4ED8",
"US-TX": "#F59E0B",
"New York": "#10B981"
},
"title": {
"text": "Where we operate"
},
"style": {
"labels": {
"show": true
}
},
"output": {
"width": 1200
}
}' \
-o map.png
```
Response: `200`, `Content-Type: image/png`, `Content-Disposition: inline; filename="united-states.png"`, headers `X-Ultimaps-Render-Id`, `X-Ultimaps-Corrected: 0`, `X-Ultimaps-Unmatched: 0`, `X-RateLimit-Limit: 30`, `X-RateLimit-Remaining: 29`.
As an image URL (same request, URL-encoded in `spec`, and the response adds `Cache-Control: public, max-age=86400`):
```
https://api.ultimaps.com/v1/renders?spec=%7B%22mapId%22%3A%22united-states%22%2C%22regions%22%3A%7B%22US-CA%22%3A%22%231D4ED8%22%2C%22US-TX%22%3A%22%23F59E0B%22%2C%22New%20York%22%3A%22%2310B981%22%7D%2C%22title%22%3A%7B%22text%22%3A%22Where%20we%20operate%22%7D%2C%22style%22%3A%7B%22labels%22%3A%7B%22show%22%3Atrue%7D%7D%2C%22output%22%3A%7B%22width%22%3A1200%7D%7D
```
### 6.2 Choropleth from raw numbers (keyed)
Numbers in, colors and legend out. This example pins everything (`type: "groups"`, `classes`, `method`, `palette`). Send just `values` and the visualization is chosen from the data: `type: auto` picks gradient, steps or groups, and the `X-Ultimaps-Choropleth` response header tells you what it picked. `format: {"decimals": 1, "suffix": "M"}` renders 39.5 as "39.5M" everywhere the number shows, on class labels, gradient ticks and tooltips. Other formats: `"compact"` (39.5M from 39538223), `{"style": "percent", "decimals": 1}` (0.42 becomes "42.0%"), `{"d3": "~s"}` for the old d3 grammar. Requesting `scale: 2` at 1600 px gives a 3200 px wide PNG, which needs a key. `style.labels.content: "value"` prints each state's number on the map in the same format ("39.5M"), and states without a value get no label.
```bash
curl https://api.ultimaps.com/v1/renders \
-H "Authorization: Bearer $ULTIMAPS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"mapId": "united-states",
"choropleth": {
"values": {
"California": 39.5,
"Texas": 30.5,
"Florida": 22.6,
"New York": 19.6,
"Pennsylvania": 13,
"Illinois": 12.5,
"Ohio": 11.8,
"Georgia": 11,
"North Carolina": 10.8,
"Michigan": 10
},
"type": "groups",
"palette": "blues",
"classes": 5,
"method": "quantile",
"noDataColor": "#EEEEEE",
"format": {
"decimals": 1,
"suffix": "M"
}
},
"legend": {
"position": "left"
},
"title": {
"text": "Population by state, 2025"
},
"style": {
"labels": {
"show": true,
"content": "value"
}
},
"output": {
"width": 1600,
"scale": 1
}
}' \
-o map.png
```
Response: `200 image/png`. Headers as above plus your plan's rate limit (`X-RateLimit-Limit: 10` on Free) and `X-Ultimaps-Choropleth: {"type":"groups","method":"quantile","classes":5,"palette":"blues","format":{"decimals":1,"suffix":"M"}}`, where `format` is the resolved spec after preset expansion and auto inference.
### 6.3 Categories with fixed colors
Labels in, one color per label, legend generated. Unlisted labels get a built-in categorical palette, assigned in order of first appearance. Pin every label's color if the output must be stable.
```bash
curl https://api.ultimaps.com/v1/renders \
-H "Authorization: Bearer $ULTIMAPS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"mapId": "germany",
"categories": {
"values": {
"DE-BE": "Live",
"DE-HH": "Live",
"DE-NW": "Live",
"DE-BY": "Pilot",
"DE-HE": "Pilot",
"DE-SN": "Planned",
"DE-NI": "Planned"
},
"colors": {
"Live": "#16A34A",
"Pilot": "#F59E0B",
"Planned": "#94A3B8"
}
},
"legend": {
"position": "right"
},
"style": {
"theme": "paper",
"labels": {
"show": true
}
}
}' \
-o map.png
```
### 6.4 Pins on a map, as SVG (Pro)
`locations` puts labelled markers at coordinates. Each pin's `title` is its label, sitting below the pin unless `labelPosition` says otherwise. `"showLabel": false` on a pin drops that label and leaves the marker. `format: "svg"` needs a Pro key (Free and keyless get `403 plan_upgrade_required`). The SVG has fonts embedded so it renders identically everywhere.
```bash
curl https://api.ultimaps.com/v1/renders \
-H "Authorization: Bearer $ULTIMAPS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"mapId": "united-states",
"style": {
"theme": "paper",
"defaultRegionColor": "#F1F5F9"
},
"locations": [
{
"title": "Austin HQ",
"lat": 30.2672,
"lon": -97.7431,
"color": "#1D4ED8"
},
{
"title": "Denver",
"lat": 39.7392,
"lon": -104.9903,
"labelPosition": "right"
},
{
"title": "Seattle",
"lat": 47.6062,
"lon": -122.3321
}
],
"output": {
"width": 1400,
"format": "svg"
}
}' \
-o map.svg
```
### 6.5 Dry run: validate and preview matching without rendering
Free (rate-limited only). Returns the matching report, the resolved visualization plan and the legend the render would use. Use it in editors, before batch jobs, and whenever an LLM builds a request from user data.
```bash
curl https://api.ultimaps.com/v1/renders \
-H "Authorization: Bearer $ULTIMAPS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"mapId": "united-states",
"choropleth": {
"values": {
"Calfornia": 39.5,
"Texas": 30.5,
"Florida": 22.6,
"Atlantis": 1
}
},
"dryRun": true
}'
```
```json
{
"dryRun": true,
"mapId": "united-states",
"choropleth": {
"type": "groups",
"method": "pretty",
"classes": 3,
"palette": "blues",
"reason": "Only 3 unique values - groups work best",
"breaks": [20, 30, 35, 40]
},
"legend": [
{ "label": "20–30", "color": "#f7fbff" },
{ "label": "30–35", "color": "#6baed6" },
{ "label": "35–40", "color": "#08306b" }
],
"warnings": [],
"regionMatching": {
"matchedKeys": 3,
"corrected": [{ "input": "Calfornia", "matchedTo": "US-CA", "title": "California", "via": "fuzzy" }],
"unmatched": [{ "input": "Atlantis", "suggestions": [] }]
}
}
```
`choropleth` is the resolved plan. Under `type: auto` (the default) it is what the detection picked, `classes` is the effective count, and `breaks` are the class edges actually drawn (`classes + 1` values, fewer when tied data collapses duplicate edges). `legend` is exactly what the rendered map will show, and its shape follows the resolved type: groups and steps return `{label, color}` items (steps labels are class boundaries), and a gradient returns `{points: [min, mid, max], stops: [...]}` for the continuous scale. `choropleth.format` is the resolved FormatSpec, after preset expansion and auto inference. `warnings` lists non-fatal adjustments, the same objects image responses carry in the `X-Ultimaps-Warnings` header: `legend_position_coerced`, `percent_values_look_scaled` (percent multiplies by 100 but the data max exceeds 1.5, so the values look pre-scaled), `legend_overflow`, `layer_unavailable` (a requested layer this map does not ship was skipped, and the message lists what it does ship), `labels_unavailable` (`style.labels.show` on a map with no curated labels, so nothing was drawn). `regionMatching.matchedKeys` counts input keys that resolved, summed across the `choropleth`, `categories` and `regions` blocks. Two keys resolving to the same region count twice, and the later value wins.
### 6.6 Strict mode: an error with suggestions
With `"onUnmatched": "error"` an unmatched key fails the whole request and each bad key gets suggestions. Fix the keys and retry.
```bash
curl https://api.ultimaps.com/v1/renders \
-H "Authorization: Bearer $ULTIMAPS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"mapId": "united-states",
"choropleth": {
"values": {
"California": 39.5,
"Texassss": 30.5,
"Atlantis": 1
}
},
"onUnmatched": "error"
}' \
-o map.png
```
```json
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://ultimaps.com/docs/api/errors#validation_error",
"title": "Request validation failed",
"status": 400,
"code": "validation_error",
"detail": "2 region key(s) did not match any region of map \"united-states\".",
"errors": [
{ "param": "Texassss", "message": "\"Texassss\" doesn't match any region of map \"united-states\".", "suggestions": ["Texas (state-FIPS-48)"] },
{ "param": "Atlantis", "message": "\"Atlantis\" doesn't match any region of map \"united-states\".", "suggestions": [] }
],
"regionMatching": {
"matchedKeys": 1,
"corrected": [],
"unmatched": [
{ "input": "Texassss", "suggestions": ["Texas (state-FIPS-48)"] },
{ "input": "Atlantis", "suggestions": [] }
]
}
}
```
## 7. Responses and headers
Success (`200`):
- `Content-Type: image/png` or `image/svg+xml`, plus `Content-Disposition: inline; filename="."`.
- `X-Ultimaps-Render-Id`: unique per render, logged server-side, present on error responses too. Quote it in support requests.
- `X-Ultimaps-Corrected` / `X-Ultimaps-Unmatched`: counts. `X-Ultimaps-Matching`: JSON with the first five of each, present only when there is something to report.
- `X-Ultimaps-Choropleth`: the resolved visualization plan as JSON `{type, method, classes, palette}`, on image responses in choropleth mode.
- `X-Ultimaps-Warnings`: JSON array of `{code, message}` non-fatal adjustments (`legend_position_coerced`, `percent_values_look_scaled`, `legend_overflow`, `layer_unavailable`, `labels_unavailable`), present only when there is something to report.
- `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (epoch seconds). `Retry-After` on `429`.
- `Cache-Control: no-store` on POST, `public, max-age=86400` on keyless GET images.
- CORS: `Access-Control-Allow-Origin: *`, rate-limit headers exposed, no cookies. Call it straight from a browser if you want (keyless, or with a key you are comfortable exposing).
PNG dimensions are exactly `round(width × scale) × round(height × scale)`. The title band and legend column (or top/bottom legend band) take their space from the map area, never by growing the canvas. When you omit `height` it derives as a snug fit around the map plus whatever title and legend the request carries.
## 8. Errors
All errors are `application/problem+json` with `type` (a URL into the error docs), `title`, `status`, `detail` (human-readable, and it may change), `code` (stable, so branch on this), plus extensions such as `errors[]`, `suggestions[]`, `upgrade_url`, `signup_url` and `quota_resets_at`.
| `code` | HTTP | Meaning / what to do |
|---|---|---|
| `validation_error` | 400 | Body or `spec` failed validation. `errors[]` names each field (`param`) with a message and, for region keys, `suggestions`. Fix and retry. |
| `invalid_region_id` | 400 | A region key is malformed. Fix and retry. |
| `unauthorized` | 401 | Missing, malformed, revoked or expired key. Sending no key at all is allowed on render endpoints (keyless tier). |
| `monthly_quota_exceeded` | 402 | Billing state, not retryable. Wait for `quota_resets_at` or upgrade (`upgrade_url`). |
| `plan_upgrade_required` | 403 | SVG without Pro, a keyless request over 1600×1600 px at scale 1, or a free-key request over 1600×1600 px at scale 2. It costs no quota. `upgrade_url` and `signup_url` say where to go. |
| `unknown_map` | 404 | No such `mapId`, or it is not visible to you. `suggestions[]` lists close ids. See `GET /v1/maps`. |
| `not_found` | 404 | No such endpoint. |
| `payload_too_large` | 413 | POST body over 2 MB. Trim the payload. (A GET `spec` over 6,144 bytes is a different failure: `400 validation_error`. Switch to POST.) |
| `rate_limit_exceeded` | 429 | Per-minute/hour limit or daily cap. Honour `Retry-After`. |
| `concurrency_limit_reached` | 429 | Too many renders in flight for this key/IP, or the engine queue is full. Retry after `Retry-After` seconds. |
| `render_expired` | 410 | A hosted render URL has expired (reserved for future hosted outputs). |
| `render_failed` | 502 | The engine could not produce an image. Retry once. Report the `X-Ultimaps-Render-Id` if it persists. |
| `render_timeout` | 504 | The render exceeded the 30 s budget. Reduce size or data volume and retry. |
| `internal_error` | 500 | Unexpected. Safe to retry with backoff. |
Retry policy for clients: retry `429`, `502`, `504`, `500` with exponential backoff (honouring `Retry-After`). Never retry `400`, `401`, `402`, `403`, `404`, `413`.
## 9. Using the API from an LLM agent or MCP client
The render endpoint makes a natural tool: build the request from user data, dry-run it, show the matching report, then render. Publish this definition to your function-calling framework. The `input_schema` resolves to the same schema the server validates against, and a fully inlined copy is at `https://api.ultimaps.com/v1/tools/render_map.json`:
```json
{
"name": "render_map",
"description": "Render a vector map of a country, its states/provinces, counties, or the world as a PNG. Pass region colors directly (`regions`), numeric data for an automatic choropleth (`choropleth`), or category labels (`categories`). Region keys accept region keys (ISO/FIPS/… codes), titles or common aliases; unambiguous typos are auto-corrected and reported. Use GET /v1/maps and GET /v1/maps/{mapId} to discover valid mapId and region keys. `style.labels.content: \"value\"` prints each region's number on the map (choropleth only; maps with `labels: false` in the catalog have no region labels). Set `dryRun: true` to validate and preview matching without rendering.",
"input_schema": {
"$ref": "https://api.ultimaps.com/v1/schemas/render-request.json"
}
}
```
Recommended agent flow:
1. `GET /v1/maps` → pick a `mapId` (`?q=` to search by name, region type or category).
2. `GET /v1/maps/{mapId}` → region keys and titles (`?q=` to search by name, code or alias). Optional: the render endpoint resolves titles and aliases itself.
3. `POST /v1/renders` with `dryRun: true` to check `regionMatching.unmatched` and `.corrected` (fix keys or ask the user), and show the resolved `choropleth` plan and `legend` preview so the user can pin `type`, `classes` or `palette` if the suggestion is wrong.
4. `POST /v1/renders` to save or display the image. Keep `X-Ultimaps-Render-Id`.
5. For a shareable image URL without a key, use the GET variant with the spec URL-encoded (up to 6 KB).
An MCP server that wraps these endpoints 1:1 (`list_maps`, `get_map_regions`, `render_map`) is planned. Until then a thin HTTP client over the OpenAPI document is all you need.
## 10. Versioning and deprecation
`/v1` evolves additively: new optional fields, enum values, headers and endpoints may appear, and existing ones are never removed or repurposed within v1. Should a breaking change ever be needed it ships as `/v2`. `/v1` then keeps working for at least 12 months, announces the sunset date on every response through the `Sunset` and `Deprecation` headers, and the change is announced in the API docs at `https://ultimaps.com/docs/api/`. Nothing is sunset today.
## 11. Good citizenship
- Cache what you render. The same request gives the same image, and the keyless GET variant is cached at the edge for you.
- Use `dryRun` before batch jobs. It is free and catches every key problem up front.
- Keep keys server-side unless you are fine with the exposure. Rotate from Studio, where the old key keeps working for 72 hours.
- Attribution on keyless and Free renders must stay visible (see the content license at `https://ultimaps.com/license/`).