API
Search, read metadata, and download battlemaps programmatically. Read access needs a key but no login per request; uploading and downloading originals needs a full-scope key.
Getting a key
Sign in, then create a key from the API keys panel on your account page. A key is shown to you exactly once, at creation — it is never stored anywhere in a form that could be read back, only its hash, so copy it immediately.
Authentication
Send your key as a bearer token on every request:
Authorization: Bearer bmc_read_<your key>A full-scope key also works anywhere a read-scope key does — full is a superset, not a separate track.
Rate limits
Two limits apply together, whichever trips first — a short burst cap and a daily cap, per key:
| Scope | Per minute | Per day |
|---|---|---|
| read | 30 | 2,000 |
| full (uploads + original downloads) | 5 | 50 |
A request over the limit gets a 429 with a Retry-After header. These are sized for the site's real scale today — if you need more, use the bandwidth request form and just ask. If it's one especially large map that's the problem rather than volume, there is a separate size exception form for that.
Preview vs. original
Search results and metadata always include a previewUrl and thumbnailUrl — a watermark-free but resized derivative, not the original file, reachable with a read-scope key. downloadUrl requires a full-scope key and serves the original, exactly as uploaded (after EXIF stripping) — the same split the site itself enforces between its own browse page and its download button.
A map uploaded from a Universal VTT file (.dd2vtt, .uvtt, .df2vtt) also carries its walls, doors and lights. To upload one through the API, split the file yourself: send the embedded image as the upload, presign with an extra vttByteSize (the JSON with the image field removed), PUT that JSON to the returned vttPutUrl, and pass vtt: true to finalize. The finalize step refuses VTT data that still contains an image. The site’s own map page re-assembles the download in the browser.
GET /api/v1/maps
Search published maps. Every query parameter is optional.
| Parameter | Meaning |
|---|---|
q | Full-text search across title and description. |
contentType | handcrafted or ai. |
license | A licence id, e.g. cc-by-4.0. |
gridSize | Grid size in pixels, exact match. |
creator | A creator’s handle. |
sort | newest (default), downloads, or alphabetical. |
page | 1-based, 24 results per page. |
curl "https://battlemap-commons-staging.netlify.app/api/v1/maps?q=tavern&sort=downloads" \
-H "Authorization: Bearer bmc_read_..."
{
"ok": true,
"results": [
{
"id": "…",
"title": "Riverside Tavern — Ground Floor",
"description": "…",
"contentType": "handcrafted",
"license": { "id": "cc0-1.0", "shortName": "CC0 1.0" },
"gridSizePx": 70,
"widthPx": 1200,
"heightPx": 900,
"downloadCount": 12,
"createdAt": "2026-09-01T00:00:00.000Z",
"creator": { "handle": "SomeCreator", "displayName": null },
"previewUrl": "https://…/api/maps/…/image?variant=full",
"thumbnailUrl": "https://…/api/maps/…/image?variant=thumb",
"downloadUrl": "https://…/api/v1/maps/…/download",
"mapPageUrl": "https://…/maps/…"
}
],
"count": 1,
"page": 1,
"pageCount": 1
}GET /api/v1/maps/:id
One map’s metadata, same shape as a single search result, under result instead of results.
GET /api/v1/maps/:id/download
Requires a full-scope key. Redirects (302) to a short-lived signed URL for the original file — the same way the site’s own download button works, and counted the same way toward each map’s download count.
POST /api/v1/uploads/presign, then POST /api/v1/uploads/finalize
Requires a full-scope key. The same two-step flow the upload page itself uses: presign returns short-lived pre-signed PUT URLs for the original image and a thumbnail; you upload the bytes directly to those URLs; finalize validates the real bytes, strips embedded metadata, runs the content-safety scan, and publishes the map. The map is credited to your account’s own handle — there is no field to upload as someone else.
Add "visibility": "private" to the finalize body to keep the map for yourself instead. It is validated and scanned exactly the same way, but never listed, searched or returned by these endpoints, and it carries no licence — send "licenseId": null. It can never be made public later. Open it from My maps on the site.
POST /api/v1/uploads/presign
{ "declaredFormat": "png", "byteSize": 512000, "thumbnailByteSize": 40000 }
→ { "uploadId": "...", "originalPutUrl": "...", "thumbnailPutUrl": "..." }
PUT <originalPutUrl> (the image bytes)
PUT <thumbnailPutUrl> (a small preview you generate — any real image works)
POST /api/v1/uploads/finalize
{
"uploadId": "...", "declaredFormat": "png", "title": "Riverside Tavern",
"contentType": "handcrafted", "licenseId": "cc0-1.0"
}
→ { "map": { "id": "...", "status": "published", "published": true } }Errors
Every response is JSON with an ok field. An error looks like:
{ "ok": false, "code": "unauthorized", "message": "That API key is not valid, or has been revoked." }