BuildMapper API · v1  ·  MCP server

BuildMapper API & MCP

Pull every permit, and your own Product Brain catalog, into the tools you already use (your CRM, data warehouse, spreadsheet, or a custom script) instead of exporting CSVs by hand. The API is plain REST over HTTPS with JSON responses and an API key. Permit endpoints are read-only; webhooks push new permits and saved projects to you. For an AI assistant rather than a system, BuildMapper is also an MCP server: a person signs in once and Claude works their account as them.

Who can use it

The API is part of the paid plans. A key can only be created on a paid account, and a key whose account has lapsed answers 403 with forbidden until the plan is active again. Trial accounts see the same data in the app but cannot mint a key. Contact records come back in full over the API because the key, not the in-app project meter, is the authorization.

Authentication

Create a key in Settings → Integrations. You'll see the full key once. Store it somewhere safe. Send it as a Bearer token on every request:

curl https://YOUR_DOMAIN/api/v1/me \
  -H "Authorization: Bearer bm_live_xxxxxxxxxxxxxxxxxxxx"

GET /api/v1/meconfirms the key works without pulling any data, which makes it the right target for a “Test connection” button:

{ "data": { "authenticated": true, "user_id": "…", "scopes": ["permits:read"] } }

Keys carry scopes. Every key is created with permits:read, which the permit endpoints require; the Product Brain endpoints accept any active key, because that catalog already belongs to the key's owner. A request without the scope it needs returns 403.

Keep keys server-side. A request with a missing or revoked key returns 401. Revoke a key any time from the same screen; it stops working immediately. The Access-Control-Allow-Origin header is open, but that is a convenience for tools like Postman, not an invitation to ship a key to a browser.

Endpoints

GET/api/v1/meVerify a key and view its scopes
GET/api/v1/permitsList, filter and page through permits
GET/api/v1/permits/{id}One permit (by id or permit_id)
GET/api/v1/permits/{id}/contactsPeople on a permit
POST/api/v1/kb/askAsk Product Brain a question
GET/api/v1/kb/productsYour Product Brain catalog

Full machine-readable schema: /api/v1/openapi.json (OpenAPI 3.1; drop it into Postman, Insomnia, or a code generator). Every route answers OPTIONS for CORS preflight.

Listing permits

Filter and page through permits. All parameters are optional.

curl "https://YOUR_DOMAIN/api/v1/permits?city=Vancouver&min_value=500000&limit=50" \
  -H "Authorization: Bearer $BM_KEY"

Supported query parameters:

since / untilPermits added to BuildMapper in a time window (ISO 8601, inclusive). This is the sync axis; see below.
filed_after / filed_beforeBy the date the jurisdiction filed or issued the permit (filed_at).
pulled_after / pulled_beforeBy the date of the weekly permit file the permit arrived in (pulled_at).
latest_batch=trueOnly the newest weekly permit file. New files land on Mondays.
city, state, project_type, status, permit_classText filters. Each is a case-insensitive exact match, not a search: city=vancouver matches Vancouver but not North Vancouver. state takes the full province name, e.g. British Columbia.
min_valueMinimum project value in dollars (the cost of the whole build as filed, not any supplier's share of it).
bboxGeographic box: minLng,minLat,maxLng,maxLat.
orderdesc (default, newest first) or asc (oldest first). Sorts by added_at.
limit1 to 500 (default 100). Larger values are capped, not rejected.
cursorOpaque next_cursor from the previous page.

Responses are wrapped in a stable envelope:

{
  "data": [
    {
      "id": "8f3c…",                          // stable BuildMapper id (UUID)
      "permit_id": "BP-2026-04417",           // the jurisdiction's own number
      "address": "4533 W 5th Ave",
      "city": "Vancouver",
      "state": "British Columbia",
      "postal": null,
      "country": "Canada",
      "neighborhood": "Greater Vancouver/Fraser Valley",
      "region": "Greater Vancouver/Fraser Valley",
      "location": { "lat": 49.268, "lng": -123.209 },
      "project_type": "Double Family Dwelling",
      "permit_class": "Residential New",
      "status": "Approved/Issued",
      "description": "New two-family dwelling with attached garage",
      "floors": 3,
      "units": 2,
      "value": 1200000,
      "filed_at": "2026-09-04T00:00:00Z",
      "pulled_at": "2026-09-14T00:00:00Z",     // the weekly file it arrived in
      "added_at": "2026-09-15T14:57:29Z",      // when it became available here
      "builder":   { "id": "…", "name": "Reyes Builds Co", "kind": "builder", "trade": "GC", "website": null },
      "applicant": null,
      "owner":     null
    }
  ],
  "pagination": { "limit": 50, "order": "desc", "has_more": true, "next_cursor": "eyJ0cyI6…" }
}

Any field can be null when the jurisdiction did not publish it. The three company fields share one shape; applicantis the firm that filed the permit when that is someone other than the builder. BuildMapper's own lead score, tier and research are not part of the API.

One permit and its contacts

Both single-permit routes accept either the BuildMapper id or the source permit_id. An unknown id returns 404.

curl https://YOUR_DOMAIN/api/v1/permits/BP-2026-04417 \
  -H "Authorization: Bearer $BM_KEY"
# → { "data": { …one permit, same shape as the list… } }

curl https://YOUR_DOMAIN/api/v1/permits/BP-2026-04417/contacts \
  -H "Authorization: Bearer $BM_KEY"
# → { "data": [
#      { "id": "…", "full_name": "Dana Reyes", "role": "Builder", "email": "dana@reyesbuilds.ca", "phone": "604-555-0142" },
#      { "id": "…", "full_name": "Reyes Builds Co", "role": "Applicant", "email": null, "phone": null }
#    ] }

Contacts are the people and firms named on the permit, in the order the jurisdiction listed them. A row's full_name is sometimes a company rather than a person; role tells you which hat they wore on the filing.

Keeping a copy in sync

To keep pulling new permits, sort oldest-first and remember the added_at of the last record you saw. On each run, pass it back as since and page until has_more is false. Permits land in weekly files, so a job that runs once an hour is plenty; every 15 minutes is fine if you want them the moment they land.

# Python — a scheduled job
import requests

BASE = "https://YOUR_DOMAIN/api/v1"
HEADERS = {"Authorization": f"Bearer {BM_KEY}"}

since = load_checkpoint()            # the last added_at you processed
cursor = None
while True:
    params = {"order": "asc", "limit": 200, "since": since}
    if cursor:
        params["cursor"] = cursor
    res = requests.get(f"{BASE}/permits", params=params, headers=HEADERS, timeout=30)
    res.raise_for_status()
    body = res.json()
    for permit in body["data"]:
        upsert(permit)               # dedupe on permit["id"]
        since = permit["added_at"]
    if not body["pagination"]["has_more"]:
        break
    cursor = body["pagination"]["next_cursor"]
save_checkpoint(since)

The cursor is keyset-based (it encodes the last row's added_at and id), so pages stay consistent even while new permits arrive: no offset drift, no missed or doubled rows. since is inclusive, so the next run will see the last permit again; upserting on id makes that harmless.

Prefer a push? Webhooks deliver permit.created as each permit lands, with the same JSON body as this endpoint. Most integrations run both: webhooks for speed, a nightly since sweep as the safety net.

Node and Python

// Node 18+ (built-in fetch)
const res = await fetch("https://YOUR_DOMAIN/api/v1/permits?order=asc&limit=200", {
  headers: { Authorization: `Bearer ${process.env.BM_KEY}` },
});
if (!res.ok) throw new Error((await res.json()).error.message);
const { data, pagination } = await res.json();
console.log(`${data.length} permits, more: ${pagination.has_more}`);
# Python
import os, requests

res = requests.get(
    "https://YOUR_DOMAIN/api/v1/permits",
    params={"latest_batch": "true", "state": "British Columbia", "limit": 500},
    headers={"Authorization": f"Bearer {os.environ['BM_KEY']}"},
    timeout=30,
)
res.raise_for_status()
for p in res.json()["data"]:
    print(p["permit_id"], p["address"], p["city"], p["value"])

Product Brain

If your team uses Product Brain, the same catalog and the same answer engine the in-app Ask tab uses are available to your own tools: a rep app, an inventory system, a chat widget. Both routes read the catalog of the key owner's team (or their personal catalog when they have no team). Neither needs a scope beyond an active key.

Ask a question

curl -X POST https://YOUR_DOMAIN/api/v1/kb/ask \
  -H "Authorization: Bearer $BM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"question": "What is the list price on 5/4x6 cedar decking, and do we stock 16 ft?"}'

# → {
#     "data": {
#       "answer": "5/4x6 cedar decking (SKU CED-546) lists at $4.85/lf as of the March 2026 price book. …",
#       "tool_calls": [ { "tool": "search_products", "input": { "query": "5/4x6 cedar decking" } } ]
#     }
#   }

One question in, one answer out. The call is stateless: no thread is created and nothing is remembered between calls, so put any context you need in the question itself. question is required and capped at 2,000 characters (422 otherwise). tool_calls lists the catalog lookups the answer was built from, so you can show your users what was checked. Answers follow the same price rules as the app: a price is quoted only when it is on file, with its effective date, and the answer says so when it is not. Expect a few seconds per call; the route allows up to two minutes for a hard question.

Read the catalog

curl "https://YOUR_DOMAIN/api/v1/kb/products?q=cedar&status=active&limit=100" \
  -H "Authorization: Bearer $BM_KEY"

# → { "data": [ { "id": "…", "name": "5/4x6 Cedar Decking", "sku": "CED-546", "category": "Decking",
#                 "material_type": "Cedar", "unit": "lf", "cost_price": 3.10, "list_price": 4.85,
#                 "currency": "CAD", "description": "…", "attributes": { "length_ft": [12, 16, 20] },
#                 "status": "active", "confidence": 0.94,
#                 "created_at": "2026-03-02T…", "updated_at": "2026-09-01T…" } ],
#     "count": 1 }
qText search across name, SKU and description (contains-match).
categoryCase-insensitive contains-match on the category.
statusactive, needs_review or archived. By default everything except archived is returned.
updated_sinceISO 8601 timestamp; only products changed on or after it. Use it for incremental sync.
limit1 to 500 (default 100). Results are newest-updated first.

This route has no cursor. For a full mirror, page by updated_since: fetch with limit=500, remember the newest updated_at you saw, and pass it back next run. cost_price and list_price are null when no document has established them; the API never invents a price.

Rate limits & errors

Each key may make 120 requests per minute. Every response includes X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (a Unix timestamp); exceeding the limit returns 429 with a Retry-After header in seconds. The limit is applied per running instance of the API, so a burst can occasionally get a little more than the number above; never a lot less. Honour the headers and the 500-row page size and you will not meet it.

Errors share one shape:

{ "error": { "code": "unauthorized", "message": "Invalid or revoked API key." } }
401 unauthorizedNo key, or a revoked or unknown key.
403 forbiddenThe key is missing the scope this route needs, or its account is no longer on a paid plan.
404 not_foundNo permit with that id or permit_id.
422 invalid_requestA parameter or body field failed validation; the message says which.
429 rate_limitedSlow down and retry after Retry-After seconds.
500 server_errorSomething failed on our side. Safe to retry with backoff.

Webhooks (push)

Prefer events pushed to you instead of polling? Register an HTTPS endpoint in Settings → Integrations and we'll POST a JSON event as things happen. The URL must be https://. Each endpoint subscribes to one or both events:

// project.saved
{
  "id": "evt_…",
  "type": "project.saved",
  "created": "2026-09-15T18:00:00Z",
  "data": {
    "id": "…",                    // the lead id
    "stage": "new",
    "priority": null,
    "score": 78,
    "saved_at": "2026-09-15T17:59:58Z",
    "permit": { …same shape as /api/v1/permits… }
  }
}

Every delivery is a POST with headers X-BuildMapper-Event (the type), X-BuildMapper-Event-Id (unique per event; dedupe on it), X-BuildMapper-Delivery (unique per attempt) and X-BuildMapper-Signature, with User-Agent: BuildMapper-Webhooks/1. The body is the envelope above; its id matches the event-id header.

Verify the signature to be sure it came from us. The header is t=<unix>,v1=<hmac>; recompute HMAC-SHA256 of "{t}.{raw_body}"with your endpoint's signing secret (shown in Settings, starts with whsec_), compare in constant time, and reject a t more than 5 minutes from your clock so a captured delivery cannot be replayed:

import crypto from "node:crypto";

function verify(rawBody, header, secret, toleranceSec = 300) {
  const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (!t || !v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSec) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  if (v1.length !== expected.length) return false;     // timingSafeEqual throws on a length mismatch
  return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}

// Express: keep the raw body — a re-serialised JSON body will not verify.
app.post("/webhooks/buildmapper", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body.toString("utf8"), req.get("X-BuildMapper-Signature"), process.env.BM_WEBHOOK_SECRET)) {
    return res.status(400).end();
  }
  const event = JSON.parse(req.body);
  res.status(200).end();                                // acknowledge first, then do the work
  handle(event);
});

Respond with a 2xx within 10 seconds to acknowledge. Anything else, including a timeout, is retried on a growing schedule (1 min, 5 min, 30 min, 2 h, 6 h, 24 h) and marked failed after the last attempt, about 33 hours after the first. An endpoint that fails 15 deliveries in a row is switched off and can be re-enabled from Settings once it is fixed. Deliveries are at-least-once: a slow 2xx can still be retried, so dedupe on the event id.

Use the Test button in Settings to send a signed sample event of type ping and confirm your verification works before real events arrive. Deliveries go out within a minute of the event.

Claude and other AI agents (MCP)

BuildMapper is also an MCP server (Model Context Protocol, Streamable HTTP) at https://www.buildmapper.ai/api/mcp. Add that address to Claude as a connector and each user signs in with their own BuildMapper account through OAuth 2.1; every tool then runs as that user, with the same visibility rules as the app. Searches and reads run on their own, every change asks the user first, and nothing is ever sent on their behalf. This is a per-user sign-in, not an API key: the keys above are for your systems, the connector is for a person's AI assistant.

TransportStreamable HTTP, MCP protocol 2026-07-28 with the 2025 stateless fallback. GET /api/mcp answers 405; POST carries JSON-RPC.
Discoverytools/list, prompts and the public tools (help guides, the free coverage check) work with no token. Any other tools/call answers 401 with a WWW-Authenticate header pointing at /.well-known/oauth-protected-resource/api/mcp (RFC 9728), which is what makes Claude show its Connect card.
AuthorizationSupabase Auth's OAuth 2.1 server with PKCE and dynamic client registration; the access token is the user's own session token plus a client_id claim. Consent is a BuildMapper page; two-step verification applies when the account uses it.
ToolsRead-only tools carry readOnlyHint and auto-run; writes carry destructiveHint and prompt. Searches and project cards also ship an MCP App (an interactive map and card rendered inside a host that supports MCP Apps).
Your datamatch_companies checks a list of company names Claude read elsewhere (a sheet, a CRM, an export) against the permit database without importing it; import_customer_list is the explicit save; integration_status says what the account has connected.
Use the www hostThe apex domain redirects, and a redirect drops the Authorization header. Always connect to www.buildmapper.ai.
ClientsClaude (claude.ai, desktop, mobile, Cowork: Customize → Connectors → Add custom connector) and ChatGPT (Developer mode: Settings → Connectors → Advanced settings → Create, authentication OAuth). Both register their own OAuth client dynamically and render the MCP App. Any client that speaks Streamable HTTP and OAuth 2.1 with dynamic client registration works the same way; one that needs a pre-registered client id can be given one on request.

Other MCP clients that speak OAuth 2.1 and Streamable HTTP can use the same address. Setup for a person is under Settings → Integrations → Claude and other AI apps; the overview is BuildMapper MCP and the step-by-step guide is Use BuildMapper from Claude.

Versioning

This is v1. We only make additive changes within a version (new fields, new endpoints, new event types); anything breaking ships under a new version path. Treat unknown JSON fields and unknown event types as forward-compatible and ignore them.

Questions, or a field you need that is not here? Write to info@buildmapper.com.