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.
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.
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.
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.
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 / until | Permits added to BuildMapper in a time window (ISO 8601, inclusive). This is the sync axis; see below. |
filed_after / filed_before | By the date the jurisdiction filed or issued the permit (filed_at). |
pulled_after / pulled_before | By the date of the weekly permit file the permit arrived in (pulled_at). |
latest_batch=true | Only the newest weekly permit file. New files land on Mondays. |
city, state, project_type, status, permit_class | Text 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_value | Minimum project value in dollars (the cost of the whole build as filed, not any supplier's share of it). |
bbox | Geographic box: minLng,minLat,maxLng,maxLat. |
order | desc (default, newest first) or asc (oldest first). Sorts by added_at. |
limit | 1 to 500 (default 100). Larger values are capped, not rejected. |
cursor | Opaque 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.
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.
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.
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 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"])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.
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.
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 }q | Text search across name, SKU and description (contains-match). |
category | Case-insensitive contains-match on the category. |
status | active, needs_review or archived. By default everything except archived is returned. |
updated_since | ISO 8601 timestamp; only products changed on or after it. Use it for incremental sync. |
limit | 1 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.
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 unauthorized | No key, or a revoked or unknown key. |
403 forbidden | The key is missing the scope this route needs, or its account is no longer on a paid plan. |
404 not_found | No permit with that id or permit_id. |
422 invalid_request | A parameter or body field failed validation; the message says which. |
429 rate_limited | Slow down and retry after Retry-After seconds. |
500 server_error | Something failed on our side. Safe to retry with backoff. |
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:
permit.created: a new permit landed in BuildMapper. data is a permit, exactly as /api/v1/permits returns it.project.saved: someone on your account saved a project (a permit became a lead on their pipeline). data is the project below. Only your own endpoints receive it.// 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.
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.