Skip to the main content

Machine access

For bots

Heritage Map is a directory of long-running American businesses, and the directory is readable by machine. This page is the whole contract: where the API lives, what it returns, and the rules it enforces.

Base URL

Production: https://heritagemap.net — every endpoint sits under /api/v1/. This prototype runs locally at http://localhost:4000; swap the host and nothing else changes.

The OpenAPI specification is served at /api/v1/openapi.json and interactive documentation at /api/v1/docs. The spec is generated from the schemas the server validates against, so the two cannot drift.

Endpoints

MethodPathReturns
GET/api/v1/businessesApproved listings, keyset-paginated. Filters combine with AND: q (full-text), category, subcategory (group/sub), state, precision (exact|city), verified, bbox (minLng,minLat,maxLng,maxLat), near (lat,lng) with radius_km, sort (name|distance|newest).
GET/api/v1/businesses/:idFull detail for one approved listing: heritage statement, phone, website, hours, timestamps.
GET/api/v1/categoriesThe 16 taxonomy groups with nested subcategories and per-group listing counts.
GET/api/v1/statsDirectory counts: businesses by precision and verification, category and subcategory totals.
GET/api/v1/zips/:zipCentroid for a five-digit ZIP, for near-me resolution.
GET/api/v1/showcaseThe oldest approved listing per category group plus live register counts.
GET/api/v1/configPublic map defaults: center, zoom, cluster threshold, tile template.

Reads are anonymous and public. Listings whose precision is city-level never expose a stored point: they return location: null, approximate: true, and a town centroid rounded to three decimals.

Examples

Responses below are quoted verbatim from the live register.

Directory counts
curl https://heritagemap.net/api/v1/stats
{"businesses":{"total":9,"exact":1,"city":8,"verified":9,"pending":0},"categories":16,"subcategories":212}
Single-state filter — the register's one exact-address row today
curl "https://heritagemap.net/api/v1/businesses?state=MT&verified=true&limit=2"
{
  "data": [
    {
      "id": 435,
      "name": "Mineral City LLC (Aviation Division)",
      "city": "Kalispell", "state": "MT",
      "precision": "exact", "approximate": false,
      "location": { "lat": 48.1810693, "lng": -114.3040493 },
      "address": "1880 Highway 93 South, Kalispell, MT 59901",
      "verified": true, "founded": 2021,
      "tags": ["aerial-photography", "aviation", "veteran-owned"],
      "category": { "slug": "arts-media-publishing", "label": "Arts, Media & Publishing" },
      "subcategory": { "slug": "photography", "label": "Photography", "key": "arts-media-publishing/photography" }
    }
  ],
  "page": {
    "next": null,
    "limit": 2
  }
}
Conditional GET — unchanged since the last fetch
curl -i -H 'If-None-Match: W/"j--WoVawpy3l40VbG1sus9dfrtw1rWVn"' \
  https://heritagemap.net/api/v1/categories
HTTP/1.1 304 Not Modified
Over the page-size ceiling — rejected, never clamped
curl "https://heritagemap.net/api/v1/businesses?limit=101"
{"error":{"code":"BAD_REQUEST","message":"querystring/limit must be <= 100","requestId":"ce90970f-aa1c-4bad-83f8-2c1baa10dd74"}}

Pagination

Every list endpoint uses keyset (cursor) pagination — there is no offset anywhere. The envelope is always:

{ "data": [ … ], "page": { "next": "<cursor>" | null, "limit": 20 } }

limit defaults to 20 and caps at 100; anything higher is a 400, never silently clamped. Feed the previous page’s page.next back as ?cursor=; treat it as opaque. next is null on the last page. Sort keys carry an id tiebreaker, so a full walk visits every row exactly once.

Rate limits

Anonymous clients get 100 requests per minute per source IP. API keys carry their own per-key limit, set at issuance (default 60 per minute). The limiter counts every request, including the ones it rejects; GET /api/v1/health is exempt so probes stay free.

Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds). Past the ceiling the answer is 429 with {"error":{"code":"RATE_LIMITED"}} and a Retry-After header — honour it and you will not see a second one.

Caching

Every read endpoint sends a weak content-hash ETag and honours If-None-Match with a 304, as in the third example above. Cache-Control is public, max-age=60 for lists, detail, stats and config, and public, max-age=300 for categories and zips — the near-static reference data.

API keys

Send a key as X-Api-Key: hmk_…. Two scopes exist: read:directory for the listings and read:stats for the counts. A key the server does not know is 403 INVALID_API_KEY, a revoked one 403 KEY_REVOKED, and a valid key outside its scopes 403 INSUFFICIENT_SCOPE — a bad key fails loudly rather than degrading to anonymous.

Keys are issued and revoked by the directory’s administrators; the plaintext is shown once at issuance. Request one at hello@heritagemap.net with a line about what you are building.