# Boardwalk API documentation --- # Boardwalk documentation URL: https://boardwalkai.com/docs/ Everything you need to build on Boardwalk land use data — the REST API, the MCP server, and the field dictionary. Boardwalk turns city and county meeting minutes into structured records about development projects — what is being built, where, by whom, and what the decision was. These docs cover three ways to get at that. - [The REST API](https://boardwalkai.com/docs/api/) — Search, filter, and pull project records. One credit per record. - [MCP and agents](https://boardwalkai.com/docs/mcp/) — Query Boardwalk from Claude, Cursor or any MCP client. - [The field dictionary](https://boardwalkai.com/docs/api/fields/) — Every field we return, what it means, and when it can be empty. --- # Boardwalk API URL: https://boardwalkai.com/docs/api/ Programmatic access to land use development projects extracted from city and county meeting records. One credit per record, everywhere. The Boardwalk API returns structured records about land use development projects — apartments, subdivisions, warehouses, rezonings — extracted from the meeting minutes and agendas that city and county bodies publish. Each record carries what is proposed, where, who is behind it, what was decided, and the evidence for all of it. ### What it costs > **One credit per record delivered** — That is the entire price list. It is the same on every endpoint that delivers records, in every representation, whether you asked for contact enrichment or not, and whether the search was a structured filter or a natural-language query. Errors cost nothing. Zero results cost nothing. A record you already hold, unchanged, costs nothing to receive again. Counting, taxonomy, location lookups, alert configuration and the whole analytics plane are free. - [The full credit model](https://boardwalkai.com/docs/api/credits/) - [Price a query before you run it](https://boardwalkai.com/docs/api/budgets/) - [Plans and pricing](https://boardwalkai.com/pricing/api) ### Three steps to a first response 1. Create a free account, then mint a key under [Settings → API keys](/profile/api). A free trial includes 100 API credits — no paid subscription needed to start. 2. Call a free endpoint to prove the key works, spending nothing. 3. Count what your real query would cost, then run it. - [Getting started — five minutes](https://boardwalkai.com/docs/api/quickstart/) - [Interactive reference](https://boardwalkai.com/docs/api/reference/) ### Where to start, by what you are building - **Get net-new development leads, with contacts** — Standing searches on a cadence, contact enrichment inside the same credit, and delivery state so you never pay twice for a lead you already worked. - **Track competing projects and their status changes** — Target an exact set of markets, follow decisions and named votes, and see what changed since you last looked. - **Query Boardwalk from Claude, Cursor or ChatGPT** — An MCP server, a free planning surface an agent can use to bound its own spend, and a compact representation that fits a hundred records in a prompt. - **Embed Boardwalk data in your product** — A stable field contract, a lossless incremental sync feed, and a versioning policy that says what we will and will not change under you. ### What we are honest about Two things are worth knowing before you build, and we would rather you read them here than discover them in production: coverage is per-state and is not nationwide, and extracted fields can be empty when the public record does not establish them. [Both are written up in full.](/docs/api/limitations/) --- # Getting started URL: https://boardwalkai.com/docs/api/quickstart/ Create a free account, mint a key under Settings → API keys, and make your first successful call — including a metered one — in about five minutes. ### How to get started 1. [Create a free account](/auth/signup). 2. After you sign in, open [Settings → API keys](/profile/api). 3. Click **Create new secret key**, copy it once, and store it safely. 4. Your free trial includes **100 API credits** (1 credit per record delivered). Trial credits do not reset monthly — they are a one-off evaluation budget. 5. Send `Authorization: Bearer …` to `https://api.boardwalkai.com/api/v1`. The rest of this page walks those calls. The first three cost nothing, so you will know the key works and what your query costs before you spend a credit. ### 1. Get a key [Sign up](/auth/signup) for a free account, then mint a key under [Settings → API keys](/profile/api). **You do not need a paid subscription to create a key**; entitlement is checked when you call, not when you mint. A free trial includes **100 credits** for evaluation over the API. The key you create there begins with `bwk_live_` and reads the production corpus against your trial balance. That is the right credential for getting started. Keys prefixed `bwk_test_` are reserved for a separate sandbox environment that is not serving data yet — do not use one for these first calls. Prefix detail and rotation: [Authentication](/docs/api/authentication/). ### 2. Prove the key works, for free **List the states you can filter by** ```bash curl 'https://api.boardwalkai.com/api/v1/locations/states' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": [ { "id": 54, "name": "Arizona", "abbreviation": "AZ", "latitude": 34.0489, "longitude": -111.0937, "entitled": false }, { "id": 57, "name": "Colorado", "abbreviation": "CO", "latitude": 39.5501, "longitude": -105.7821, "entitled": false }, { "id": 1, "name": "Utah", "abbreviation": "UT", "latitude": 39.321, "longitude": -111.0937, "entitled": true } ], "meta": { "geographyScope": "states", "entitledStateIds": [ 1 ], "countyRestricted": false } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ **Cost:** Free — 0 credits — Free. Resolve the ids you filter with. A `200` here means your key is good. If you get `401`, the credential did not reach us — check that the header is `Authorization: Bearer …` and that you copied the whole key. ### 3. Ask what your real query would cost **Narrow the query until it becomes affordable** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/count?states=UT&assetClasses=Apartment%20Building&meetingDateFrom=2026-05-01&totalUnitCountMin=50' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": { "matchCount": 250, "matchCountIsExact": true, "matchCountBasis": "enumerated", "countedAs": "distinct_projects", "collapseApplied": true, "duplicatesCollapsed": 0, "scanCeiling": 10000, "billable": { "records": 250, "credits": 250, "creditRate": 1, "isUpperBound": true, "nonBillable": { "alreadyDeliveredUnchanged": 0, "ledgerApplied": true } }, "affordability": { "affordable": true, "blockReason": null, "shortfallCredits": 0, "maxAffordableRecords": 500, "boundBy": "period_balance", "unlimited": false }, "ordering": { "sort": "meetingDate", "order": "desc", "tiebreak": null, "meaning": "Most recent meeting evidence first \u2014 the date of the meeting the record was extracted from, not when Boardwalk ingested it and not when the project was created." }, "truncation": { "policy": "refuse", "wouldTruncate": false, "deliverableRecords": null, "explanation": "A metered request for this filter set fits inside the remaining balance, so it would deliver every matching record." } }, "meta": { "schemaVersion": "2026-08-13", "representation": "standard", "deliveryMode": "all", "credits": { "limit": 500, "used": 0, "remaining": 500, "charged": 0, "periodStart": "2026-08-01", "periodEnd": "2026-08-31", "isTeamPool": true }, "appliedFilters": { "states": [ 1 ], "assetClasses": [ "Apartment Building" ], "meetingDateFrom": "2026-05-01T00:00:00+00:00", "totalUnitCountMin": 50 }, "appliedDefaults": [], "resolvedFrom": { "states": [ { "input": "UT", "id": 1 } ] }, "warnings": [] }, "links": { "self": "https://api.boardwalkai.com/api/v1/projects/count?states=UT&assetClasses=Apartment%20Building&meetingDateFrom=2026-05-01&totalUnitCountMin=50", "search": "https://api.boardwalkai.com/api/v1/projects/search?states=UT&assetClasses=Apartment%20Building&meetingDateFrom=2026-05-01&totalUnitCountMin=50", "docs": "https://boardwalkai.com/docs/api/budgets/" } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ **Cost:** Free — 0 credits — Free, always, including when your balance is zero. This is the endpoint that tells you what a query would cost before you buy it. This step is not a best practice, it is how the product works. On a paid plan, a request that costs more than your balance is **refused**, not truncated, so counting first is how you avoid a `402`. A free-trial key instead delivers what its remaining balance covers and reports the cutoff in `meta.trialTruncation`. Read `affordability.affordable` and `billable.credits` before either call. ### 4. Make the metered call **Your first metered call** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/search?states=UT&assetClasses=Apartment%20Building&meetingDateFrom=2026-05-01&totalUnitCountMin=50&limit=1' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": [ { "id": 184203, "recordType": "project", "projectName": "Alta Ridge Phase II", "projectHeadline": "Planning Commission approved 240 apartments with conditions", "boardwalkLink": "https://boardwalkai.com/map/#project=184203", "mergedFrom": [ 184203, 184987 ], "lastUpdated": "2026-06-18T09:22:41Z", "meetingDate": "2026-06-17", "delivery": { "alreadyExported": false, "updatedSinceExport": null, "lastExportedAt": null, "billed": true }, "address": "3600 S Constitution Blvd, West Valley City, UT", "city": "West Valley City", "county": "Salt Lake", "state": "UT", "postalCode": "84119", "latitude": 40.6916, "longitude": -112.0011, "locationPrecision": "address", "locationPrecisionLabel": "Address", "isApproximate": false, "parcelApn": [ "15-27-301-004" ], "propertyType": "residential", "propertyTypeLabel": "Residential", "propertySubtype": "multifamily", "propertySubtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "landUses": [ { "type": "residential", "typeLabel": "Residential", "subtype": "multifamily", "subtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "matchedOn": [] } ], "status": "approved_with_conditions", "statusLabel": "Approved with Conditions", "statusDetail": "The commission approved the preliminary plat subject to eight conditions.", "requestType": "final_plat", "requestTypeLabel": "Final Plat", "decisionBody": "Planning Commission", "caseNumbers": [ "PLAT-2026-0142" ], "acreage": 9.8, "squareFootage": null, "unitCount": [ { "count": 240, "type": "apartment", "typeLabel": "Apartment" } ], "totalUnitCount": 240, "bedroomCount": null, "buildingStories": null, "floorCount": null, "buildingHeightFeet": null, "parkingSpaces": 372, "existingZoning": "A-1", "proposedZoning": "RM-16", "isRezone": true, "constructionType": "new_building", "constructionTypeLabel": "New Building", "ownerType": "private", "ownerTypeLabel": "Private", "constructionDescription": "A 240-unit garden-style apartment community on 9.8 acres.", "developerCompany": "Alta Ridge Development LLC", "developerCompanyRole": "developer", "developerCompanyRoleLabel": "Developer", "contacts": [ { "name": "Rachel Okafor", "title": "Director of Development", "role": "developer", "roleLabel": "Developer", "partyClass": "external", "partyClassLabel": "External Party", "entityType": "person", "company": "Alta Ridge Development LLC", "email": "r.okafor@example.com", "emailConfidence": "high", "emailSource": "filing", "phone": "+1-555-0142", "phoneConfidence": "high", "phoneSource": "filing", "linkedinUrl": null, "linkedinConfidence": null, "linkedinSource": null, "contactProvenance": "filing", "enrichmentSource": null, "enrichedAt": null, "enrichmentStatus": "not_requested", "contactsWithheldReason": null } ], "contactEnrichment": { "status": "not_requested", "statusLabel": "Not Requested", "attemptedAt": null, "contactsFound": 0, "source": "registry" }, "companyEnrichment": [], "publicOfficials": [ { "name": "Dana Whitfield", "title": "Senior Planner", "role": "city_planner", "roleLabel": "City Planner", "partyClass": "government", "organization": "West Valley City" } ], "contactSummary": { "externalCount": 1, "governmentCount": 1, "unclassifiedCount": 0, "withEmail": 1, "withPhone": 1, "withLinkedin": 0, "enrichedCount": 0, "lowConfidenceWithheld": 0, "withheldCount": 0, "withheldReason": null }, "keyFacts": [ { "fact": "240 apartment units across six buildings", "category": "unit_mix", "categoryLabel": "Unit Mix" } ], "evidence": "Commissioner Reyes moved to approve subject to conditions 1-8.", "voteSummary": { "yes": 2, "no": 1, "abstain": 0, "absent": 1, "total": 4, "isUnanimous": false, "meetingDate": "2026-06-17" } } ], "meta": { "schemaVersion": "2026-08-13", "representation": "standard", "total": 1, "pageCount": 1, "hasMore": true, "limit": 1, "offset": 0, "creditsUsed": 1, "creditsRemaining": 299, "trialTruncation": null, "searchMethod": "redisearch", "appliedFilters": { "states": [ 1 ], "assetClasses": [ "Apartment Building" ], "meetingDateFrom": "2026-05-01T00:00:00+00:00", "totalUnitCountMin": 50 }, "appliedDefaults": [], "resolvedFrom": { "states": [ { "input": "UT", "id": 1 } ] }, "warnings": [], "matchCount": null, "matchCountIsExact": false, "matchCountBasis": "not_computed", "duplicatesCollapsed": 0, "collapseApplied": true, "delivery": { "mode": "all", "notPreviouslyExported": 1, "previouslyExported": 0, "updatedSinceExport": 0, "billed": 1, "notRebilled": 0, "ledgerApplied": true } } } ``` _Record bodies on this page were produced by running Boardwalk's production response mapper over a documented sample project, so the field set, the labels and the empty fields are exactly what the API emits. The project itself is a sample, not a real filing._ **Cost:** 1 credit per record delivered — JSON pages: charged for records actually delivered after collapse, with unchanged re-delivery free. Zero results cost nothing. `format=csv` does not use this search charge — it reserves product export credits (1 per core CSV row) and returns HTTP 202 with poll/download URLs. This call reuses the exact filter set from `links.search` in step 3 and adds only `limit=1`, so the quote and the purchase cannot silently describe different markets. One record, one credit. Two things to notice on your first response: `meta.creditsRemaining` has gone down by exactly one, and the record carries a `delivery` block. That block is why pulling this same record again tomorrow, if it has not changed, is free. ### Rate limits before you loop Paid keys are limited to **60 requests per minute**. Watch `X-RateLimit-Remaining` and back off before you hit `429` — a well-behaved client rarely needs the error path. Full headers, Retry-After, and cookbook: [Rate limits](/docs/api/rate-limits/). ### What you just spent | Step | Endpoint | Credits | | --- | --- | --- | | 1 | Minting a key | 0 | | 2 | `GET /locations/states` | 0 | | 3 | `GET /projects/count` | 0 | | 4 | `GET links.search` with `limit=1` | 1 | | | **Total** | **1** | ### Where to go next - [The free sandbox](https://boardwalkai.com/docs/api/recipes/free-sandbox/) - [Recipes for your use case](https://boardwalkai.com/docs/api/recipes/) - [Every filter you can send](https://boardwalkai.com/docs/api/filters/) - [Every field you can receive](https://boardwalkai.com/docs/api/fields/) - [Interactive reference](https://boardwalkai.com/docs/api/reference/) --- # Authentication URL: https://boardwalkai.com/docs/api/authentication/ One credential: an API key. How keys are owned, how to hold several, and how to rotate one without downtime. Every request carries an API key, as a bearer token or in `X-API-Key`. There is exactly one kind of credential for this API. A web session cookie or a login token from the Boardwalk app is **not** one, and will not authenticate a `/api/v1` request. ```bash curl 'https://api.boardwalkai.com/api/v1/locations/states' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` ### Key prefixes | Prefix | What it is | | --- | --- | | `bwk_live_` | Production. Reads the real corpus and spends credits. | | `bwk_test_` | Sandbox. Safe to put in a staging environment. | | `csk_` | Issued before the Boardwalk rename. Still accepted, indefinitely. You do not need to migrate. | The prefix is the only environment signal you need. There is no `sandbox: true` field on a record, and responses carry an `X-Boardwalk-Environment` header. ### Keys belong to the team, and name the person A key is held by the team and attributed to whoever created it. Two consequences worth designing for: a key keeps working when its creator leaves, and a team owner can see and revoke every key that can spend the team's credits. Usage is attributed to the key, so separate keys per service is a reasonable way to tell your integrations apart. ### Rotating without downtime 1. Mint a second, named key. You may hold several at once. 2. Deploy it. The previous key keeps working during a **24-hour** overlap. 3. Revoke the old one once nothing is using it. While a key is inside its rotation window its responses carry `X-Boardwalk-Key-Rotating: true` and a `Warning` header naming the moment it stops working — so a service that missed the rotation tells you before it breaks rather than after. ### What the failures mean | Response | Meaning | What to do | | --- | --- | --- | | `401 missing_api_key` | No credential reached us. | Check the header name and that the whole key was copied. | | `401 unauthorized` | The key did not resolve — revoked, or past its rotation grace window. | Mint a new one. | | `402 subscription_required` | The key is valid; the plan has lapsed. **The key is not revoked.** | Reactivate the plan. No redeploy, no re-issue. | | `402 insufficient_credits` | The key and plan are fine; the pool is empty for this request. | Narrow the query or top up. [More here.](/docs/api/budgets/) | > **A lapsed subscription keeps its keys** — We do not revoke credentials when a plan lapses. Your integration starts receiving `402` and starts working again the moment the plan is reactivated, with no key rotation and no deploy. --- # The credit model URL: https://boardwalkai.com/docs/api/credits/ The authoritative rate card: one credit per record delivered, what is free, and what never bills twice. > **The whole rate card** — **1 credit per record delivered.** Everywhere. There is no second rate anywhere in this product. ### What that covers | Rule | Detail | | --- | --- | | Representation does not change the price | A record costs the same in `compact`, `standard` and `full`. You are billed for the record, not the bytes. | | AI search is the same rate | A record confirmed by a natural-language search costs the same as one matched by a structured filter. Candidates we reviewed and rejected cost you nothing. | | Contact enrichment is included | A record costs 1 credit whether or not you asked for contacts, and whether or not we found any. | | Errors are free | No `4xx` or `5xx` response is ever charged. A refused request leaves your balance exactly where it was. | | Zero results are free | A query that matches nothing costs nothing. | | Duplicates are collapsed before billing | Two filings for one development are merged into one record and charged once. | | Unchanged re-delivery is free | A record this pool already holds, which has not changed since, is delivered again without a charge. | | One pool | CSV exports, API calls and MCP tool calls all spend the same monthly balance. | ### Every endpoint, and what it costs | Method | Path | Cost | Notes | | --- | --- | --- | --- | | GET | `/alerts/{alertId}/matches` | 1 credit / record | Records already delivered and unchanged since a scheduled run are re-readable here without a second charge; anything new or changed bills once. | | POST | `/alerts/{alertId}/run` | 1 credit / record | A manual run is a real delivery: records new to you or changed since their last delivery bill at the standard rate. Runs are capped per alert per day. | | GET | `/documents/{id}` | 1 credit / record | One source document, one credit. | | POST | `/projects/ai-search` | 1 credit / record | The same rate as a structured search. You pay for confirmed matches that are new to you or changed since their last delivery; rejected candidates and unchanged re-deliveries are free. | | POST | `/projects/ai-search/jobs` | 1 credit / record | Credits are reserved up front against the requested limit and settled down to confirmed records that are new to you or changed since their last delivery. Unused reservation, including unchanged re-deliveries, is refunded. | | GET | `/projects/search` | 1 credit / record | JSON pages: charged for records actually delivered after collapse, with unchanged re-delivery free. Zero results cost nothing. `format=csv` does not use this search charge — it reserves product export credits (1 per core CSV row) and returns HTTP 202 with poll/download URLs. | | GET | `/projects/sync` | 1 credit / record | Only records that are new to you, or that changed since you last received them, are billable. A sync page that returns nothing but unchanged records is free. | | GET | `/projects/{id}` | 1 credit / record | One credit when the record is new to you or changed since your last delivery; an unchanged re-read is free, just like the same record inside a search page. | | GET | `/account/credits` | Free | Free. Your balance, your plan and your rate limit. | | GET | `/alerts` | Free | Free. Configuring alerts never costs credits; only delivered records do. | | POST | `/alerts` | Free | Free. | | GET | `/alerts/{alertId}` | Free | Free. | | PATCH | `/alerts/{alertId}` | Free | Free. | | DELETE | `/alerts/{alertId}` | Free | Free. | | GET | `/alerts/{alertId}/deliveries` | Free | Free. The delivery history and what each run charged. | | GET | `/alerts/{alertId}/deliveries/{deliveryId}` | Free | Free. | | POST | `/alerts/{alertId}/dismissals/{projectId}` | Free | Free. Dismissing a match stops it being re-offered. | | DELETE | `/alerts/{alertId}/dismissals/{projectId}` | Free | Free. | | POST, GET | `/alerts/{alertId}/preview` | Free | Free. Preview returns how many records a rule would deliver and what that would cost, not the records themselves. | | POST | `/analytics/aggregate` | Free | Free. POST only (GET returns 404/405). Returns counts and group keys, never a project id, name, address, contact or document. | | GET | `/analytics/datasets` | Free | Free. GET only. What the analytics plane can and cannot answer, self-described. | | POST | `/analytics/rankings` | Free | Free. POST only (GET returns 405). Same small-group suppression floor as /aggregate. | | GET | `/exports` | Free | Free. Lists export jobs for your team (including jobs queued with format=csv on search). Credits were reserved when the job was created. | | GET | `/exports/{id}` | Free | Free. Poll job status until ready. | | GET | `/exports/{id}/download` | Free | Free re-download of a ready product-core CSV. Creating the job reserved export credits (1 per row) when you queued it. | | GET | `/health` | Free | Free and unauthenticated. | | GET | `/locations/cities/{id}` | Free | Free. | | GET | `/locations/counties/{id}` | Free | Free. | | GET | `/locations/states` | Free | Free. Resolve the ids you filter with. | | GET | `/locations/states/{id}` | Free | Free. | | GET | `/locations/states/{id}/counties` | Free | Free. | | GET | `/locations/states/{stateId}/counties/{countyId}/cities` | Free | Free. | | GET | `/projects/ai-search/jobs/{id}` | Free | Polling is free. The records the job delivers were charged when the job settled, not when you read them. | | GET | `/projects/count` | Free | Free, always, including when your balance is zero. This is the endpoint that tells you what a query would cost before you buy it. | | GET | `/reference/decision-bodies` | Free | Free. Canonical values accepted by the decisionBodies filter. | | GET | `/reference/owner-types` | Free | Free. Owner-type values accepted by ownerTypes filters. | | GET | `/reference/request-types` | Free | Free. Canonical values accepted by the requestTypes filter. | | GET | `/reference/statuses` | Free | Free. Canonical status filter tokens plus display aliases (e.g. scheduled). | | GET | `/taxonomy` | Free | Free. The complete land use taxonomy tree. | | GET | `/taxonomy/action-categories` | Free | Free. | | GET | `/taxonomy/action-types` | Free | Free. | | GET | `/taxonomy/asset-classes` | Free | Free. | | GET | `/taxonomy/project-types` | Free | Free. | | GET | `/taxonomy/subtypes` | Free | Free. | ### The free plane is not a teaser Counting, the location tree, the taxonomy, alert configuration and the entire analytics surface are free permanently, on paid and trial keys alike, and `GET /projects/count` answers even when your balance is zero. A metered API that will not tell you what something costs until you have paid for it is a worse product, so we do not ship one. ### Allowances How many credits your plan includes each month depends on the geography you bought. Credits are shared across every export surface, and unused credits do not roll over. See [plans and pricing](/pricing/api) for what a plan costs; this page is about what a credit buys. > **On a paid plan, a request you cannot afford is refused, not trimmed** — We never deliver "as much as you can afford" and bill you for it. You get a `402` with a quote and nothing is charged. The one exception is a free-trial key, which delivers what its remaining trial balance covers and says so in `meta.trialTruncation`. ### Reading your balance **Read the shared balance** ```bash curl 'https://api.boardwalkai.com/api/v1/account/credits' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": { "plan": "single_state", "planDisplayName": "State", "creditsLimit": 500, "creditsUsed": 200, "creditsRemaining": 300, "periodStart": "2026-08-01", "periodEnd": "2026-08-31", "resetsAt": "2026-09-01T00:00:00+00:00", "rateLimit": 60, "rateLimitPer": "minute", "isTeamPool": true, "poolUserId": 8814 }, "meta": { "requestId": "req_01K1QF3M0000EXAMPLE0001" } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ Metered responses also carry `X-Credits-Remaining`, and `meta.creditsUsed` / `meta.creditsRemaining` on the body. Read one of those rather than keeping a local counter — the pool is shared, and someone else may be spending it. --- # Budgets, quotes and the 402 URL: https://boardwalkai.com/docs/api/budgets/ Price any request for free before you run it, and know exactly what a request you cannot afford returns. Boardwalk never overshoots your balance and never partially delivers a paid request. That guarantee is only useful if you can see it coming, so the same quote arithmetic is available for free before the request and is attached to the refusal after it. ### The free quote **Price a broad query — before spending anything** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/count?states=UT&assetClasses=Apartment%20Building&dateRange=all' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": { "matchCount": 5000, "matchCountIsExact": true, "matchCountBasis": "enumerated", "countedAs": "distinct_projects", "collapseApplied": true, "duplicatesCollapsed": 0, "scanCeiling": 10000, "billable": { "records": 5000, "credits": 5000, "creditRate": 1, "isUpperBound": true, "nonBillable": { "alreadyDeliveredUnchanged": 0, "ledgerApplied": true } }, "affordability": { "affordable": false, "blockReason": "insufficient_credits", "shortfallCredits": 4500, "maxAffordableRecords": 500, "boundBy": "period_balance", "unlimited": false }, "ordering": { "sort": "meetingDate", "order": "desc", "tiebreak": null, "meaning": "Most recent meeting evidence first \u2014 the date of the meeting the record was extracted from, not when Boardwalk ingested it and not when the project was created." }, "truncation": { "policy": "refuse", "wouldTruncate": false, "deliverableRecords": null, "explanation": "Paid plans are never partially delivered. A request that costs more than the balance is refused with 402 and a quote; narrow the filters or top up." } }, "meta": { "schemaVersion": "2026-08-13", "representation": "standard", "deliveryMode": "all", "credits": { "limit": 500, "used": 0, "remaining": 500, "charged": 0, "periodStart": "2026-08-01", "periodEnd": "2026-08-31", "isTeamPool": true }, "appliedFilters": { "states": [ 1 ], "assetClasses": [ "Apartment Building" ], "dateRange": "all" }, "appliedDefaults": [], "resolvedFrom": { "states": [ { "input": "UT", "id": 1 } ] }, "warnings": [] }, "links": { "self": "https://api.boardwalkai.com/api/v1/projects/count?states=UT&assetClasses=Apartment%20Building&dateRange=all", "search": "https://api.boardwalkai.com/api/v1/projects/search?states=UT&assetClasses=Apartment%20Building&dateRange=all", "docs": "https://boardwalkai.com/docs/api/budgets/" } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ **Cost:** Free — 0 credits — Free, always, including when your balance is zero. This is the endpoint that tells you what a query would cost before you buy it. ### Reading a quote | Field | What it tells you | | --- | --- | | `matchCount` | How many distinct projects match. Post-collapse, so it counts developments, not filings. | | `matchCountIsExact` | `false` when the index held more rows than one request may read, and the true total is higher. `scanCeiling` is that row budget — not a project count, and not a bound on `matchCount`. | | `billable.records` | How many of those you would actually be charged for. Lower than `matchCount` when you already hold some unchanged. | | `billable.credits` | The cost. `records × creditRate`. | | `billable.isUpperBound` | Always `true`, and it is a promise: the real charge can only be lower. | | `affordability.affordable` | Whether the balance covers it. | | `affordability.shortfallCredits` | How many more you would need. | | `affordability.maxAffordableRecords` | How many records the balance can pay for right now. | | `truncation.policy` | `refuse` on a paid plan, `trial_truncate` on a trial key. This is what would happen if you called the metered endpoint anyway. | | `ordering` | The sort that would be applied, so "the first 500" is a defined set rather than an arbitrary one. | ### The refusal **What a request you cannot afford looks like** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/search?states=UT&assetClasses=Apartment%20Building&dateRange=all&limit=100' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `402`: ```json { "error": { "code": "insufficient_credits", "message": "This request would deliver 5,000 records and cost 5,000 credits. You have 500.", "param": null, "details": { "quote": { "matchCount": 5000, "matchCountIsExact": true, "creditRate": 1, "creditsRequired": 5000, "creditsRemaining": 500, "affordableRecordCount": 500, "countUrl": "https://api.boardwalkai.com/api/v1/projects/count?states=UT&assetClasses=Apartment%20Building&dateRange=all" } }, "docsUrl": "https://boardwalkai.com/docs/api/errors#insufficient_credits", "requestId": "req_01K1QF3M0000EXAMPLE0001" } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ `error.details.quote` is the same arithmetic as the count endpoint — one object, built once, so the number you narrow against and the number a refusal names cannot disagree. `countUrl` is a free URL that prices this exact filter set, which is what lets an agent recover without a human. ### Why not just deliver what I can afford? Because a short page is indistinguishable from the end of the results. A customer who receives 500 of 5,000 records with no signal concludes our coverage is thin, builds on a partial dataset, and finds out months later. Refusing is louder and cheaper for everyone. Trial keys are the exception, and they say so explicitly in `meta.trialTruncation`. --- # Representations URL: https://boardwalkai.com/docs/api/representations/ compact, standard and full — three sizes of the same record, all costing exactly one credit. `?representation=` selects how much of each record you receive. Each one nests inside the next, so `compact`'s fields are all present in `standard`, and `standard`'s in `full`. | Value | Use it for | | --- | --- | | `compact` | Agents and list views. Small enough to hold a hundred records in a prompt without spending the context window. | | `standard` (default) | The calibrated field set — the same concepts the CSV export carries. | | `full` | Adds decisions with named votes, document metadata, and `rawText`. | > **All three cost one credit** — The price is per record, not per byte. Choose the representation that suits your client, not your budget. An unrecognised value returns `400` and is never silently coerced to the default. ### The same record, twice **The same record in `compact`** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/search?states=UT&representation=compact&limit=1' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": [ { "id": 184203, "recordType": "project", "projectName": "Alta Ridge Phase II", "boardwalkLink": "https://boardwalkai.com/map/#project=184203", "mergedFrom": [ 184203, 184987 ], "lastUpdated": "2026-06-18T09:22:41Z", "meetingDate": "2026-06-17", "delivery": { "alreadyExported": false, "updatedSinceExport": null, "lastExportedAt": null, "billed": true }, "address": "3600 S Constitution Blvd, West Valley City, UT", "city": "West Valley City", "county": "Salt Lake", "state": "UT", "latitude": 40.6916, "longitude": -112.0011, "propertyType": "residential", "propertyTypeLabel": "Residential", "landUses": [ { "type": "residential", "typeLabel": "Residential", "subtype": "multifamily", "subtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "matchedOn": [] } ], "status": "approved_with_conditions", "statusLabel": "Approved with Conditions" } ], "meta": { "schemaVersion": "2026-08-13", "representation": "compact", "total": 1, "pageCount": 1, "hasMore": true, "limit": 1, "offset": 0, "creditsUsed": 1, "creditsRemaining": 296, "trialTruncation": null, "searchMethod": "redisearch", "appliedFilters": { "states": [ 1 ] }, "appliedDefaults": [ "dateRange=last_1_year" ], "resolvedFrom": { "states": [ { "input": "UT", "id": 1 } ] }, "warnings": [ { "code": "default_date_window_applied", "message": "No date filter was given, so the last 12 months of meetings were searched \u2014 the same window the product shows. Pass dateRange=all to search everything.", "params": [ "meetingDateFrom" ] } ], "matchCount": null, "matchCountIsExact": false, "matchCountBasis": "not_computed", "duplicatesCollapsed": 0, "collapseApplied": true, "delivery": { "mode": "all", "notPreviouslyExported": 1, "previouslyExported": 0, "updatedSinceExport": 0, "billed": 1, "notRebilled": 0, "ledgerApplied": true } } } ``` _Record bodies on this page were produced by running Boardwalk's production response mapper over a documented sample project, so the field set, the labels and the empty fields are exactly what the API emits. The project itself is a sample, not a real filing._ **One record in `full`, including decisions, documents and `rawText`** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/184203?representation=full' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": { "id": 184203, "recordType": "project", "projectName": "Alta Ridge Phase II", "projectHeadline": "Planning Commission approved 240 apartments with conditions", "boardwalkLink": "https://boardwalkai.com/map/#project=184203", "mergedFrom": [ 184203, 184987 ], "lastUpdated": "2026-06-18T09:22:41Z", "meetingDate": "2026-06-17", "delivery": { "alreadyExported": false, "updatedSinceExport": null, "lastExportedAt": null, "billed": true }, "address": "3600 S Constitution Blvd, West Valley City, UT", "city": "West Valley City", "county": "Salt Lake", "state": "UT", "postalCode": "84119", "latitude": 40.6916, "longitude": -112.0011, "locationPrecision": "address", "locationPrecisionLabel": "Address", "isApproximate": false, "parcelApn": [ "15-27-301-004" ], "stateId": 1, "countyId": 2917, "cityId": 31688, "additionalAddresses": [], "propertyType": "residential", "propertyTypeLabel": "Residential", "propertySubtype": "multifamily", "propertySubtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "landUses": [ { "type": "residential", "typeLabel": "Residential", "subtype": "multifamily", "subtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "matchedOn": [] } ], "classifications": [ { "role": "primary", "type": "residential", "typeLabel": "Residential", "subtype": "multifamily", "subtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building" } ], "tags": [ "multifamily", "phase-ii" ], "status": "approved_with_conditions", "statusLabel": "Approved with Conditions", "statusDetail": "The commission approved the preliminary plat subject to eight conditions.", "requestType": "final_plat", "requestTypeLabel": "Final Plat", "decisionBody": "Planning Commission", "caseNumbers": [ "PLAT-2026-0142" ], "isAppeal": false, "nextHearingDate": "2026-07-15", "acreage": 9.8, "squareFootage": null, "unitCount": [ { "count": 240, "type": "apartment", "typeLabel": "Apartment" } ], "totalUnitCount": 240, "affordableUnits": null, "bedroomCount": null, "lotCount": null, "buildingCount": 6, "buildingStories": null, "floorCount": null, "buildingHeightFeet": null, "parkingSpaces": 372, "densityPerAcre": 24.5, "amenities": [ "Pool", "Clubhouse", "Dog run" ], "existingZoning": "A-1", "proposedZoning": "RM-16", "isRezone": true, "variancesRequested": [ "setback" ], "constructionType": "new_building", "constructionTypeLabel": "New Building", "ownerType": "private", "ownerTypeLabel": "Private", "constructionDescription": "A 240-unit garden-style apartment community on 9.8 acres.", "likelyTrades": [ { "trade": "site_work", "tradeLabel": "Site Work", "basis": "Grading plan referenced" } ], "estimateEligible": true, "developerCompany": "Alta Ridge Development LLC", "developerCompanyRole": "developer", "developerCompanyRoleLabel": "Developer", "contacts": [ { "name": "Rachel Okafor", "title": "Director of Development", "role": "developer", "roleLabel": "Developer", "partyClass": "external", "partyClassLabel": "External Party", "entityType": "person", "company": "Alta Ridge Development LLC", "email": "r.okafor@example.com", "emailConfidence": "high", "emailSource": "filing", "phone": "+1-555-0142", "phoneConfidence": "high", "phoneSource": "filing", "linkedinUrl": null, "linkedinConfidence": null, "linkedinSource": null, "contactProvenance": "filing", "enrichmentSource": null, "enrichedAt": null, "enrichmentStatus": "not_requested", "contactsWithheldReason": null } ], "contactEnrichment": { "status": "not_requested", "statusLabel": "Not Requested", "attemptedAt": null, "contactsFound": 0, "source": "registry" }, "companyEnrichment": [], "publicOfficials": [ { "name": "Dana Whitfield", "title": "Senior Planner", "role": "city_planner", "roleLabel": "City Planner", "partyClass": "government", "organization": "West Valley City" } ], "contactSummary": { "externalCount": 1, "governmentCount": 1, "unclassifiedCount": 0, "withEmail": 1, "withPhone": 1, "withLinkedin": 0, "enrichedCount": 0, "lowConfidenceWithheld": 0, "withheldCount": 0, "withheldReason": null }, "keyFacts": [ { "fact": "240 apartment units across six buildings", "category": "unit_mix", "categoryLabel": "Unit Mix" } ], "evidence": "Commissioner Reyes moved to approve subject to conditions 1-8.", "conditionsOfApproval": [ "1. Landscaping per the approved plan." ], "summary": "A 240-unit garden-style apartment community on 9.8 acres, approved with conditions.", "extendedSummary": "The applicant sought preliminary plat approval for a 240-unit garden-style apartment community across six four-storey buildings, with 372 parking stalls.", "decisions": [ { "meetingDate": "2026-06-17", "decisionBody": "Planning Commission", "actionType": "preliminary_plat", "actionTypeLabel": "Preliminary Plat", "requestType": "final_plat", "requestTypeLabel": "Final Plat", "outcome": "approved_with_conditions", "outcomeLabel": "Approved with Conditions", "motionType": "approve_with_conditions", "motionOutcome": "passed", "evidenceBasis": null, "evidenceBasisLabel": null, "isConsentAgenda": null, "caseNumber": "PLAT-2026-0142", "evidence": "Commissioner Reyes moved to approve subject to conditions 1-8.", "conditions": "Landscaping per the approved plan.", "voteTally": { "yes": 2, "no": 1, "abstain": 0, "absent": 1, "total": 4, "isUnanimous": false }, "votes": [ { "name": "Maria Reyes", "role": "Commissioner", "vote": "yes", "voteLabel": "Yes" }, { "name": "Tom Alder", "role": "Commissioner", "vote": "yes", "voteLabel": "Yes" }, { "name": "Priya Raman", "role": "Commissioner", "vote": "no", "voteLabel": "No" }, { "name": "Ben Cole", "role": "Commissioner", "vote": "absent", "voteLabel": "Absent" } ] } ], "voteSummary": { "yes": 2, "no": 1, "abstain": 0, "absent": 1, "total": 4, "isUnanimous": false, "meetingDate": "2026-06-17" }, "documents": [ { "meetingDocumentId": 902551, "title": "Planning Commission Minutes", "meetingDate": "2026-06-17", "documentType": "meeting_minutes", "documentTypeLabel": "Meeting Minutes", "rawText": "PLANNING COMMISSION MINUTES \u2014 June 17, 2026\n\nITEM 4. PLAT-2026-0142 \u2014 Alta Ridge Phase II, preliminary plat, 3600 S Constitution Blvd.\nStaff presented the report and recommended approval subject to eight conditions. Commissioner Reyes moved to approve subject to conditions 1-8. Motion carried 2-1, Commissioner Cole absent.\n\n[This excerpt is shortened for the documentation. A live response carries the complete document text.]", "rawTextTruncated": false } ] }, "meta": { "schemaVersion": "2026-08-13", "representation": "full", "creditsUsed": 1, "creditsRemaining": 295 } } ``` _Record bodies on this page were produced by running Boardwalk's production response mapper over a documented sample project, so the field set, the labels and the empty fields are exactly what the API emits. The project itself is a sample, not a real filing._ ### `rawText` is `full` only The source meeting document's text ships under `documents[].rawText`, in the `full` representation only, and never in a list response. Real minutes run to tens or hundreds of kilobytes per document — putting them in a page of a hundred records would be a multi-megabyte response nobody asked for. There is no separate charge for it: `full` is one credit like everything else. > **The `rawText` in these docs is shortened** — The examples on this site carry a truncated excerpt, because a real transcript will not fit on a documentation page. A live `full` response carries the complete document text. ### Field counts | Field | Type | Description | | --- | --- | --- | | `id` | integer | Boardwalk's stable identifier for this project. | | `recordType` | string | What kind of record this is. Always "project" today; reserved for future record types. | | `projectName` | string | The project's display name. | | `boardwalkLink` | string | Deep link to this project in the Boardwalk map. | | `mergedFrom` | array | The project ids folded into this record as duplicates of the same development, including its own. You are billed once for the record, not once per id. | | `lastUpdated` | string (nullable) | When this project record last changed, RFC 3339 UTC. | | `meetingDate` | string (nullable) | The date of the most recent public meeting this project appeared at. | | `delivery` | object (nullable) | What Boardwalk has already delivered to your credit pool for this record, and whether this delivery was billed. GET /projects/sync returns these same four keys plus a change payload (what moved, and which new filing caused it) — see the SyncDeliveryState schema. | | `address` | string (nullable) | The project's street address as stated in the public filing. | | `city` | string (nullable) | City the project is in. | | `county` | string (nullable) | County the project is in. | | `state` | string (nullable) | Two-letter state code. | | `latitude` | number (nullable) | Latitude of the project's mapped location. | | `longitude` | number (nullable) | Longitude of the project's mapped location. | | `propertyType` | string (nullable) | Top-level land-use classification, e.g. residential. | | `propertyTypeLabel` | string (nullable) | Human-readable form of propertyType. | | `landUses` | array | Every land use on this project, to the deepest level we know for each — a mixed-use development lists all of them. `assetClass` is null on an entry where the record only told us the type and subtype; we never copy a parent value down. One use is listed once: where the source describes the same use twice and one description stops short of the other, only the fuller one is listed. `matchedOn` names which taxonomy levels of that use satisfied your filter — `["subtype"]`, `["subtype","assetClass"]` — so you can see why the project is in your results; it is an empty list on uses that did not match and on every use when you filtered on no taxonomy at all. Procedural items such as rezones are not land uses and appear as requestType instead. | | `matchRole` | string (nullable) | Deprecated — use `landUses[].matchedOn`, which covers all three taxonomy levels rather than asset class alone. When an assetClasses filter is active: `primary` if the project's primary asset class matched, `secondary` if only another classification did. Absent when no asset-class filter was applied. | | `matchedClassification` | object (nullable) | Deprecated — use `landUses[].matchedOn`. The classification that satisfied the active assetClasses filter (often not the primary one). Absent when no asset-class filter was applied. Primary taxonomy fields stay the project's primary. | | `status` | string (nullable) | Where this project stands, as a stable machine token. | | `statusLabel` | string (nullable) | Human-readable form of status; identical to the CSV's Current Status cell. | --- # Field dictionary URL: https://boardwalkai.com/docs/api/fields/ Every field the API can return, which representation carries it, and what it means. This page is generated from the same registry the API serialises responses through, so a field that is not listed here cannot be returned, and a field that is returned cannot be missing from here. ### Conventions - **Enums ship twice.** A frozen machine token (`status`) and a human label (`statusLabel`). Branch on the token; display the label. Rewording a label is not a breaking change; changing a token is. - **The vocabulary is the CSV export's.** A column in your CSV and a field in the API are the same concept under the same name, so reconciling the two is a join, not a mapping exercise. - **Ids are integers.** Not numeric strings. - **Date-times are ISO-8601 with an offset.** `meetingDate` is a plain calendar date, because a meeting happened on a day. - **`alertMatch` and `aiMatch` are absent, not null, when they do not apply.** Every other declared field is present at its representation, so you can index without checking. > **Read the units before you trust a number** — `acreage` and its range filters are in acres. `lotSizeSqftMin` and `lotSizeSqftMax` are in square feet; `buildingHeightFeet` is returned in feet. These are different units for related things, and mixing them is how "1,500" becomes 1,500 acres for a 1,500 square-foot building. Both the CSV and the API use the same units for the same field name. ### Contacts and personal data | Party | Free trial | Paid plan | | --- | --- | --- | | External parties — developers, applicants, consultants | Name, title, role, company. **Contact channels withheld**, with a stated reason. | Name, title, role, company, plus email, phone and LinkedIn where we have them, each with its own confidence and source. | | Government staff — planners, clerks, commissioners | Name, title, organisation. **Never a contact channel.** | Name, title, organisation. **Never a contact channel.** | | Anyone on the suppression list | Removed. | Removed. | Government staff contact details are never returned on any tier. There is no plan that unlocks them. If your details appear in our data and you want them removed, [ask us](/privacy/contact-opt-out/). ### When a field can be empty Any extracted attribute can be empty on a given record: minutes are not uniform, and where our current extraction standard produced no value, the field is empty rather than filled from a superseded reading. [The full explanation, and what it means for filtering, is here.](/docs/api/data-availability/) ### `compact` | Field | Type | Description | | --- | --- | --- | | `id` | integer | Boardwalk's stable identifier for this project. | | `recordType` | string | What kind of record this is. Always "project" today; reserved for future record types. | | `projectName` | string | The project's display name. | | `boardwalkLink` | string | Deep link to this project in the Boardwalk map. | | `mergedFrom` | array | The project ids folded into this record as duplicates of the same development, including its own. You are billed once for the record, not once per id. | | `lastUpdated` | string (nullable) | When this project record last changed, RFC 3339 UTC. | | `meetingDate` | string (nullable) | The date of the most recent public meeting this project appeared at. | | `delivery` | object (nullable) | What Boardwalk has already delivered to your credit pool for this record, and whether this delivery was billed. GET /projects/sync returns these same four keys plus a change payload (what moved, and which new filing caused it) — see the SyncDeliveryState schema. | | `address` | string (nullable) | The project's street address as stated in the public filing. | | `city` | string (nullable) | City the project is in. | | `county` | string (nullable) | County the project is in. | | `state` | string (nullable) | Two-letter state code. | | `latitude` | number (nullable) | Latitude of the project's mapped location. | | `longitude` | number (nullable) | Longitude of the project's mapped location. | | `propertyType` | string (nullable) | Top-level land-use classification, e.g. residential. | | `propertyTypeLabel` | string (nullable) | Human-readable form of propertyType. | | `landUses` | array | Every land use on this project, to the deepest level we know for each — a mixed-use development lists all of them. `assetClass` is null on an entry where the record only told us the type and subtype; we never copy a parent value down. One use is listed once: where the source describes the same use twice and one description stops short of the other, only the fuller one is listed. `matchedOn` names which taxonomy levels of that use satisfied your filter — `["subtype"]`, `["subtype","assetClass"]` — so you can see why the project is in your results; it is an empty list on uses that did not match and on every use when you filtered on no taxonomy at all. Procedural items such as rezones are not land uses and appear as requestType instead. | | `matchRole` | string (nullable) | Deprecated — use `landUses[].matchedOn`, which covers all three taxonomy levels rather than asset class alone. When an assetClasses filter is active: `primary` if the project's primary asset class matched, `secondary` if only another classification did. Absent when no asset-class filter was applied. | | `matchedClassification` | object (nullable) | Deprecated — use `landUses[].matchedOn`. The classification that satisfied the active assetClasses filter (often not the primary one). Absent when no asset-class filter was applied. Primary taxonomy fields stay the project's primary. | | `status` | string (nullable) | Where this project stands, as a stable machine token. | | `statusLabel` | string (nullable) | Human-readable form of status; identical to the CSV's Current Status cell. | ### `standard` — adds | Field | Type | Description | | --- | --- | --- | | `projectHeadline` | string (nullable) | A one-line description of the latest development on this project. | | `postalCode` | string (nullable) | Postal code of the project's primary address. | | `locationPrecision` | string (nullable) | How precisely this project is placed: address, parcel, location_description, city or county. | | `locationPrecisionLabel` | string (nullable) | Human-readable form of locationPrecision. | | `isApproximate` | boolean | True when the coordinates are inferred rather than taken from a street address. | | `parcelApn` | array | Assessor parcel numbers named in the filing. | | `propertySubtype` | string (nullable) | Second-level land-use classification, e.g. multifamily. | | `propertySubtypeLabel` | string (nullable) | Human-readable form of propertySubtype. | | `assetClass` | string (nullable) | Third-level land-use classification for the project's headline use, e.g. apartments — the first one we know where that use stops short of this level. Blank means the project has no asset class recorded at all; see `landUses` for every use it does have. Never a duplicated parent value. | | `assetClassLabel` | string (nullable) | Human-readable form of assetClass. | | `statusDetail` | string (nullable) | A sentence expanding on the current status. | | `requestType` | string (nullable) | What the applicant asked the jurisdiction for, as a stable machine token. | | `requestTypeLabel` | string (nullable) | Human-readable form of requestType. | | `decisionBody` | string (nullable) | The body that heard or decided the request, e.g. Planning Commission. | | `caseNumbers` | array | Jurisdiction case numbers for this project. | | `acreage` | number (nullable) | Total site area in acres. | | `squareFootage` | number (nullable) | Total gross building floor area explicitly stated across building or use rows. | | `unitCount` | array | Dwelling or space counts by type. Entry N's count always describes entry N's type. | | `totalUnitCount` | integer (nullable) | Total dwelling units across all unit types. | | `bedroomCount` | integer (nullable) | Total bedrooms across the project. | | `buildingStories` | integer (nullable) | Number of storeys. | | `floorCount` | integer (nullable) | Highest stated above-grade story count among explicit building rows. | | `buildingHeightFeet` | number (nullable) | Building height in feet. Null where the filing stated a height whose unit we cannot resolve — we do not guess. | | `parkingSpaces` | integer (nullable) | Parking spaces proposed. | | `existingZoning` | string (nullable) | The site's zoning code before the request. | | `proposedZoning` | string (nullable) | The zoning code being requested. | | `isRezone` | boolean (nullable) | True when the request changes the site's zoning. Null means unknown, never false. | | `constructionType` | string (nullable) | The kind of construction work proposed, as a stable machine token. | | `constructionTypeLabel` | string (nullable) | Human-readable form of constructionType. | | `ownerType` | string (nullable) | Whether the owner is public, private or institutional, as a stable machine token. | | `ownerTypeLabel` | string (nullable) | Human-readable form of ownerType. | | `constructionDescription` | string (nullable) | A short sourced description of the construction work, written for business development. | | `developerCompany` | string (nullable) | The external company developing or applying for this project. Government entities never appear here, even when the filing names one as the applicant. | | `developerCompanyRole` | string (nullable) | How the developer company is named in the filing: developer, builder or applicant. | | `developerCompanyRoleLabel` | string (nullable) | Human-readable form of developerCompanyRole. | | `contacts` | array | External parties on this project — developer, applicant, architect, engineer, contractor, owner. Contact channels are included on paid plans; on a free trial they are null and contactsWithheldReason says why. | | `contactEnrichment` | object | What happened when we tried to enrich this record's contacts. status is not_requested unless the request set include=contacts. Enrichment never changes what a record costs: every record is 1 credit whether enrichment found somebody, found nobody, or was never attempted — so a blank contact block always says which of those it was. | | `companyEnrichment` | array | Companies on this project resolved against Boardwalk's company registry: official name, website, LinkedIn page, archetype and main phone. Names and websites are included on every plan — they are corporate facts, not personal data. The remaining keys are null outside a paid plan, and the array is empty unless the request set include=contacts. | | `publicOfficials` | array | Government staff and elected officials named in the public record. Their names, roles and votes are public-record facts. Boardwalk never distributes their personal contact details, on any plan. | | `contactSummary` | object | externalCount is the number of entries in contacts[]; governmentCount the number in publicOfficials[]; unclassifiedCount the parties — people or companies — the filing did not identify clearly enough for us to place in either, which are therefore in neither. withheldCount and withheldReason say how many contact channels were withheld and why. lowConfidenceWithheld counts contacts we hold but do not publish because the evidence for them is only pattern-inferred or uncorroborated — a blank there is a decision, not a gap in coverage. | | `keyFacts` | array | Discrete facts extracted from the filing, each with the category it belongs to. | | `evidence` | string (nullable) | The passage of the public record that supports the current status. | | `alertMatch` | object (nullable) | Why this record matched the alert that delivered it. Absent on ordinary searches. | | `aiMatch` | object (nullable) | Why the AI search matched this record, and how strongly. Absent on structured searches, which do not rank. | | `voteSummary` | object (nullable) | The tally of the most recent recorded vote on this project: yes, no, abstain, absent, total, and whether it was unanimous. | ### `full` — adds | Field | Type | Description | | --- | --- | --- | | `stateId` | integer (nullable) | Boardwalk id of the state, for use with /locations/*. | | `countyId` | integer (nullable) | Boardwalk id of the county, for use with /locations/*. | | `cityId` | integer (nullable) | Boardwalk id of the city, for use with /locations/*. | | `additionalAddresses` | array | Other addresses associated with this project. | | `classifications` | array | Every land-use classification on this project, primary and secondary. | | `tags` | array | Free-form tags attached to the project. | | `isAppeal` | boolean (nullable) | True when this proceeding is an appeal. Null means unknown, never false. | | `nextHearingDate` | string (nullable) | The next scheduled hearing date, where one was stated. | | `affordableUnits` | integer (nullable) | Units designated affordable. | | `lotCount` | integer (nullable) | Number of lots in the subdivision or plat. | | `buildingCount` | integer (nullable) | Number of buildings proposed. | | `densityPerAcre` | number (nullable) | Dwelling units per acre. Null where the filing's density unit is not units/acre. | | `amenities` | array | Amenities named in the filing. | | `variancesRequested` | array | Variances or deviations the applicant asked for. | | `likelyTrades` | array | Trades this project is likely to need, with the evidence for each. | | `estimateEligible` | boolean (nullable) | True when the filing carries enough detail to support a cost estimate. | | `conditionsOfApproval` | array | Verbatim conditions attached to an approval. | | `summary` | string (nullable) | A paragraph summarising the project. | | `extendedSummary` | string (nullable) | A longer narrative summary. | | `decisions` | array | Every recorded decision on this project, newest meeting first, with the named votes cast and a derived tally. | | `documents` | array | The public meeting documents this project was extracted from. Pass `meetingDocumentId` to `GET /documents/{id}` to fetch one. rawText is the document's transcribed text and is returned in the full representation only. | --- # Filter reference URL: https://boardwalkai.com/docs/api/filters/ Every parameter the record-returning endpoints accept, with its aliases, values and combination rules. Generated from the same registry the API validates requests against. Anything listed here is accepted; anything not listed returns `400`. ### How filters combine - Values **within** one parameter are an **OR**. `states=UT,CO` means Utah or Colorado. - Different parameters are **AND**ed. `states=UT&assetClasses=Apartment%20Building` means Utah **and** apartment buildings. - `propertyTypes`, `propertySubtypes` and `assetClasses` are OR-joined with each other first, then ANDed with everything else. Selecting a type and an unrelated asset class gives you the union, not the intersection. - A county or city narrows **within** the states you named rather than adding to them. > **Unknown parameters are refused, never ignored** — A misspelled filter name returns `400 invalid_filter` and costs nothing. The alternative — dropping it silently — would run a broader query than you asked for and bill you for the difference. **An unknown parameter is refused, not ignored** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/search?states=UT&estimatedProjectValueMin=1000000' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `400`: ```json { "error": { "code": "invalid_filter", "message": "1 filter problem. No credits were charged.", "param": "estimatedProjectValueMin", "details": { "problems": [ { "param": "estimatedProjectValueMin", "code": "unknown_parameter", "message": "Unknown parameter \"estimatedProjectValueMin\". Nothing was charged.", "details": [] } ] }, "docsUrl": "https://boardwalkai.com/docs/api/errors#invalid_filter", "requestId": "req_01K1QF3M0000EXAMPLE0001" } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ ### Defaults that are applied for you Two defaults change what a bare query returns, and both are disclosed in the response so you can assert on them rather than remember them: | Default | Effect | Opt out | | --- | --- | --- | | `dateRange=last_1_year` | A request with no date filter searches the last 12 months of meetings — the same window the web product shows. Reported in `meta.appliedDefaults` and as a `default_date_window_applied` warning. | `dateRange=all` | | `includeCivic=false` | Civic and government-owned projects are excluded by default, matching the product. | `includeCivic=true` | | `sort=meetingDate`, `order=desc` | Newest meeting evidence first. | Pass your own `sort` and `order`. | ### The receipt Every filtered response carries `meta.appliedFilters` — what actually ran after normalisation and name resolution — plus `meta.appliedDefaults` and `meta.resolvedFrom`. If you ever need to prove the API searched what you meant, diff those against what you sent. > **Filtering and output use the same evidence** — The index nominates candidates, then Boardwalk rechecks status, taxonomy, geography, dates and measurement ranges against the collapsed record that will be returned. Missing evidence is a non-match. For land-use filters, `landUses[].matchedOn` also names the classification level that matched. [Full explanation.](/docs/api/data-availability/) ### Every filter | Parameter | Type | Aliases | Description | | --- | --- | --- | --- | | `states` | string (list) | state, stateId, stateIds | States, as numeric ids, USPS abbreviations ("UT") or full names ("Utah"). A list is an OR: pass every state you want. | | `counties` | string (list) | county, countyId, countyIds | Counties, as numeric ids or names (a trailing "County" is optional). A name is resolved against a single `states` value when one is given. | | `cities` | string (list) | city, cityId, cityIds | Cities, as numeric ids or names. A name is resolved against a single `states`/`counties` value when one is given; an ambiguous name is an error, never a silently wider search. | | `bbox` | object | bounds | Rectangle {north, south, east, west}. Records are returned only when their coordinates fall inside the rectangle — the engine prefilters with a circle and the corners of that circle are then removed. | | `circle` | object | — | Radius search {lat, lng, radiusMeters}. radiusMeters is 100–500000. | | `polygon` | geojson_ring | — | GeoJSON linear ring [[lng, lat], …], 4–1000 vertices, first vertex equal to last. The engine prefilters with the ring's bounding circle and exact point-in-polygon runs afterwards. | | `propertyTypes` | string (list) | type, types, propertyType | Top-level land-use classification, e.g. "Residential". Case-insensitive. | | `propertySubtypes` | string (list) | subtype, subtypes, propertySubtype | Second-level classification. Also accepts "Type - Subtype" compounds, which are split server-side. | | `assetClasses` | string (list) | assetClass, assetClasses[] | Third-level classification, e.g. "Apartment Building". | | `excludePropertySubtypes` | string (list) | excludeSubtype, excludeSubtypes | Second-level classifications to exclude. Applied after the taxonomy OR, as AND-NOT. | | `excludeAssetClasses` | string (list) | excludeAssetClass | Third-level classifications to exclude. | | `taxonomyMatchMode` | string | — | Whether a land use has to be the project's primary one to count as a match. Defaults to primary_or_secondary whenever you filter on any taxonomy level, so a project matches when it has the land use you asked for — whether that is its headline use or one component of a larger development. The same rule applies at all three levels (projectTypes, subtypes, assetClasses). Every delivered record names which of its land uses matched, and at which level, in `landUses[].matchedOn`. Send primary_only to count a project only when the use you searched for is its primary one — that returns fewer records and bills for fewer. Values: primary_only, primary_or_secondary, any. | | `status` | string (list) | statuses, statusSelection | Where the project stands. Accepts the machine token or the human label ("approved" or "Approved"). "unknown" selects records with no decision recorded. Values: pending, approved, denied, recommended_for_approval, recommended_for_denial, continued, referred, withdrawn, unknown. | | `decisionBodies` | string (list) | decisionBody | The body that heard the request, normalised to its short name ("Salt Lake City Council" → "City Council"). Matching is exact against that short name, never a substring. Values: Planning & Development Services, Planning and Development Services, Community Development Department, Community Development Division, Development Services Department, Municipal Planning Authority, Historic Preservation, Redevelopment Agency, Reinvestment Agency, Building Commission, Building Authority, Planning Department, Planning Division, Planning Commission, Planning Director, Local Planning Body, Parks & Recreation, Board of Adjustment, Board of Appeals, Township Council, Development Services, County Council, City Council, City Staff, Town Council, Council, Commission, Mayor, Staff, other_bodies, unknown_status. | | `statusCombos` | string (list) | statusCombo | "\|" pairs, e.g. "City Council\|approved". This is what the retired CC…/PC… buckets decompose into, and it is strictly more expressive. | | `requestTypes` | string (list) | requestType, applicationTypes, askType, askTypes | What the applicant asked the jurisdiction for, e.g. "rezone". Values: conditional_use_permit, rezone, annexation, preliminary_plat, site_plan_approval, general_plan_amendment, development_agreement, subdivision_plat, variance, planned_unit_development, design_review. | | `decisionTypes` | string (list) | decisionType | The kind of action recorded on the project. | | `constructionTypes` | string (list) | constructionType | What kind of construction the filing describes. Values: new_building, renovation, site_work, infrastructure, demolition_only. | | `constructionMagnitudes` | string (list) | constructionMagnitude | How large the construction is. Values: major, standard, minor, negligible. | | `constructionLeadOnly` | boolean | — | true keeps only records the pipeline marked as real construction leads. | | `hasConstructionIntel` | boolean | — | Whether construction intelligence was extracted for this record. | | `ownerTypes` | string (list) | ownerType | Who the owner is, e.g. private or public. A list is an OR. | | `includeCivic` | boolean | showAllRecords | Include non-real-estate government decisions. Defaults to false, which is what the product shows — and which reduces what a naive query bills for. | | `civicSubtypes` | string (list) | — | Narrows the civic side only. Requires includeCivic=true. | | `civicAssetClasses` | string (list) | — | Narrows the civic side only. Requires includeCivic=true. | | `meetingDateFrom` | date | dateFrom | Earliest meeting date. Accepts YYYY, YYYY-MM, YYYY-MM-DD or RFC3339. Inclusive: a "To" bound snaps to the END of the period named. | | `meetingDateTo` | date | dateTo | Latest meeting date. Accepts YYYY, YYYY-MM, YYYY-MM-DD or RFC3339. Inclusive: a "To" bound snaps to the END of the period named. | | `lastUpdatedFrom` | date | updatedFrom, updatedAtFrom, updatedSince | Earliest last-updated date. On GET /projects/sync this seeds the first cursor and is then carried inside it, so it does not need re-sending on later pages. Accepts YYYY, YYYY-MM, YYYY-MM-DD or RFC3339. Inclusive: a "To" bound snaps to the END of the period named. | | `lastUpdatedTo` | date | updatedTo, updatedAtTo | Latest last-updated date. Accepts YYYY, YYYY-MM, YYYY-MM-DD or RFC3339. Inclusive: a "To" bound snaps to the END of the period named. | | `dateRange` | string | — | A relative meeting-date window. Defaults to last_1_year, matching what the product shows. Pass dateRange=all to search the whole corpus. Values: last_1_month, last_3_months, last_6_months, last_1_year, since_jan_2025, all. | | `totalUnitCountMin` | number | unitCountMin | Lower bound for totalUnitCount, in count. Inclusive. 0 is a real bound, not "unset". | | `totalUnitCountMax` | number | unitCountMax | Upper bound for totalUnitCount, in count. Inclusive. 0 is a real bound, not "unset". | | `acreageMin` | number | — | Lower bound for acreage, in acres. Inclusive. 0 is a real bound, not "unset". | | `acreageMax` | number | — | Upper bound for acreage, in acres. Inclusive. 0 is a real bound, not "unset". | | `lotSizeSqftMin` | number | lotSizeMin | Lower bound for lotSizeSqft, in sqft. Inclusive. 0 is a real bound, not "unset". | | `lotSizeSqftMax` | number | lotSizeMax | Upper bound for lotSizeSqft, in sqft. Inclusive. 0 is a real bound, not "unset". | | `squareFootageMin` | number | — | Lower bound for squareFootage, in sqft. Inclusive. 0 is a real bound, not "unset". | | `squareFootageMax` | number | — | Upper bound for squareFootage, in sqft. Inclusive. 0 is a real bound, not "unset". | | `parkingSpacesMin` | number | — | Lower bound for parkingSpaces, in count. Inclusive. 0 is a real bound, not "unset". | | `parkingSpacesMax` | number | — | Upper bound for parkingSpaces, in count. Inclusive. 0 is a real bound, not "unset". | | `buildingStoriesMin` | number | storiesMin | Lower bound for buildingStories, in count. Inclusive. 0 is a real bound, not "unset". | | `buildingStoriesMax` | number | storiesMax | Upper bound for buildingStories, in count. Inclusive. 0 is a real bound, not "unset". | | `buildingHeightFeetMin` | number | buildingHeightFtMin | Lower bound for buildingHeightFeet, in feet. Inclusive. 0 is a real bound, not "unset". | | `buildingHeightFeetMax` | number | buildingHeightFtMax | Upper bound for buildingHeightFeet, in feet. Inclusive. 0 is a real bound, not "unset". | | `bedroomCountMin` | number | — | Lower bound for bedroomCount, in count. Inclusive. 0 is a real bound, not "unset". | | `bedroomCountMax` | number | — | Upper bound for bedroomCount, in count. Inclusive. 0 is a real bound, not "unset". | | `floorCountMin` | number | — | Lower bound for floorCount, in count. Inclusive. 0 is a real bound, not "unset". | | `floorCountMax` | number | — | Upper bound for floorCount, in count. Inclusive. 0 is a real bound, not "unset". | | `q` | string | search, query | Keyword search. Several words are matched as an exact phrase; pipe-separated groups ("mixed use\|apartments") are alternatives. | | `representation` | string | — | Payload depth: compact, standard or full. All cost the same. | | `include` | string | — | Optional work to do before delivering: contacts. | | `contactOptions` | string | — | Enrichment options: minConfidence, maxProjects, cachedOnly, companyOnly. | | `deliveryMode` | string | — | Which delivery states to return. | | `cursor` | string | — | Opaque sync watermark from meta.sync.cursor on GET /projects/sync. Omit it to start a fresh sync — a restart is free for every record you already hold that has not changed. | | `page` | string | — | Page number, 1-based. | | `limit` | string | — | Records per page. | | `offset` | string | — | Record offset. Retained while page-based pagination lands. | | `format` | string | — | json (default) or csv. | | `sort` | string | — | Ordering field. The product's own sort tokens are accepted as aliases. Direction-bearing aliases such as units_lowest encode their order; a conflicting order parameter is rejected. Values: meetingDate, lastUpdated, dataRichness, totalUnitCount, acreage. | | `order` | string | — | asc or desc. Omit this for a direction-bearing sort alias, or send the direction its suffix implies; a conflict is rejected. Values: asc, desc. | --- # Why a field can be empty URL: https://boardwalkai.com/docs/api/data-availability/ What Boardwalk guarantees about the values it returns and what it will not guess. > **The guarantee** — Every value Boardwalk returns is produced by our current extraction standard. Where the current standard produced no value for an attribute, that field is empty. We do not fill the gap with an older or superseded reading of the record. Boardwalk reads meeting minutes and agendas, and those documents are not uniform. One city's planning commission minutes state the acreage, the unit count and the zoning designation; the next city's record the motion and nothing else. So any extracted attribute can be absent on any given record. We could fill those gaps. We do not, and this is the reason: the failure mode of a guess is not a slightly wrong number, it is "1,500 acres" for a 1,500 square-foot building, sitting in your CRM, in front of a client. An empty field is something you can handle. A confident wrong one is not. ### What it looks like **A record we could not fully establish** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/search?states=UT&cities=Provo&limit=1' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": [ { "id": 191774, "recordType": "project", "projectName": "Foothill Commons", "projectHeadline": "Concept plan continued to a future meeting", "boardwalkLink": "https://boardwalkai.com/map/#project=191774", "mergedFrom": [ 191774 ], "lastUpdated": "2026-06-24T14:08:03Z", "meetingDate": "2026-06-23", "delivery": { "alreadyExported": false, "updatedSinceExport": null, "lastExportedAt": null, "billed": true }, "address": null, "city": "Provo", "county": "Utah", "state": "UT", "postalCode": "84606", "latitude": 40.2338, "longitude": -111.6585, "locationPrecision": "city_centroid", "locationPrecisionLabel": "City Centroid", "isApproximate": true, "parcelApn": [], "propertyType": "residential", "propertyTypeLabel": "Residential", "propertySubtype": "multifamily", "propertySubtypeLabel": "Multifamily", "assetClass": null, "assetClassLabel": null, "landUses": [ { "type": "residential", "typeLabel": "Residential", "subtype": "multifamily", "subtypeLabel": "Multifamily", "assetClass": null, "assetClassLabel": null, "matchedOn": [] } ], "status": "continued", "statusLabel": "Continued", "statusDetail": "The item was continued; no decision was recorded.", "requestType": "concept_plan", "requestTypeLabel": "Concept Plan", "decisionBody": "Planning Commission", "caseNumbers": [], "acreage": null, "squareFootage": null, "unitCount": [], "totalUnitCount": null, "bedroomCount": null, "buildingStories": null, "floorCount": null, "buildingHeightFeet": null, "parkingSpaces": null, "existingZoning": null, "proposedZoning": null, "isRezone": null, "constructionType": null, "constructionTypeLabel": null, "ownerType": null, "ownerTypeLabel": null, "constructionDescription": null, "developerCompany": null, "developerCompanyRole": null, "developerCompanyRoleLabel": null, "contacts": [], "contactEnrichment": { "status": "not_requested", "statusLabel": "Not Requested", "attemptedAt": null, "contactsFound": 0, "source": "registry" }, "companyEnrichment": [], "publicOfficials": [], "contactSummary": { "externalCount": 0, "governmentCount": 0, "unclassifiedCount": 0, "withEmail": 0, "withPhone": 0, "withLinkedin": 0, "enrichedCount": 0, "lowConfidenceWithheld": 0, "withheldCount": 0, "withheldReason": null }, "keyFacts": [], "evidence": "The chair continued the item to a date uncertain.", "voteSummary": null } ], "meta": { "schemaVersion": "2026-08-13", "representation": "standard", "total": 1, "pageCount": 1, "hasMore": true, "limit": 1, "offset": 0, "creditsUsed": 1, "creditsRemaining": 294, "trialTruncation": null, "searchMethod": "redisearch", "appliedFilters": { "states": [ 1 ], "cities": [ 31955 ] }, "appliedDefaults": [ "dateRange=last_1_year" ], "resolvedFrom": { "states": [ { "input": "UT", "id": 1 } ], "cities": [ { "input": "Provo", "id": 31955 } ] }, "warnings": [ { "code": "default_date_window_applied", "message": "No date filter was given, so the last 12 months of meetings were searched \u2014 the same window the product shows. Pass dateRange=all to search everything.", "params": [ "meetingDateFrom" ] } ], "matchCount": null, "matchCountIsExact": false, "matchCountBasis": "not_computed", "duplicatesCollapsed": 0, "collapseApplied": true, "delivery": { "mode": "all", "notPreviouslyExported": 1, "previouslyExported": 0, "updatedSinceExport": 0, "billed": 1, "notRebilled": 0, "ledgerApplied": true } } } ``` _Record bodies on this page were produced by running Boardwalk's production response mapper over a documented sample project, so the field set, the labels and the empty fields are exactly what the API emits. The project itself is a sample, not a real filing._ `assetClass: null` and `acreage: null` on a record that has a property type, a subtype, a city, a decision body and a status. Nothing failed. The minutes said what they said. ### What you will notice | You will see | Because | | --- | --- | | Some records look emptier than the same project does on the Boardwalk map | The API holds every served value to the current standard. Older values are not shown. | | A filtered search omits records you can find in an unfiltered search | The record did not carry current evidence for the requested constraint after merge collapse. Missing evidence is a non-match. | ### How filters stay aligned > **The index nominates; the returned record proves** — Boardwalk rechecks customer constraints after hydration and merge collapse. The recheck uses the same shaped record and source snapshot that are serialized, so stale index values cannot create a billable match whose returned value is null or contradictory. Three things follow: - **A missing value is a non-match**, not a wildcard. - **Different filter parameters remain ANDed** after collapse, including state, county and city. - **The response is the audit surface.** Status and measurements used to prove the filter are serialized from the same snapshot. Some quantities are intentionally not filterable yet. Unknown filter names return `400` without charge rather than relying on a legacy index value that the response cannot let you inspect. ### What this is not - It is not a coverage statement. Whether we cover your market at all is a [different question](/docs/api/limitations/). - It is not a completeness percentage. We do not publish a fill rate per attribute, because we have not measured one we would stand behind. --- # Delivery state URL: https://boardwalkai.com/docs/api/delivery-state/ Every record tells you whether you have had it before, whether it changed, and whether this delivery charged you. Delivery state is a block on every record, on the API and in the CSV export alike. It is not a mode you switch on — the information is always there, so a client never has to guess whether it is paying twice. | Field | Type | Meaning | | --- | --- | --- | | `delivery.alreadyExported` | boolean | Has this credit pool received this record before, through any surface — API, CSV, alert delivery? | | `delivery.updatedSinceExport` | boolean or null | Has it changed since then? `null` on a record you have never received, because the question does not apply. | | `delivery.lastExportedAt` | ISO-8601 or null | When you last received it. | | `delivery.billed` | boolean | Whether **this** delivery charged you for it. | > **The rule the fields encode** — A record is billable when it is new to you, **or** when it has changed since you last received it. An unchanged record you already hold ships again for free. ### The page totals `meta.delivery` sums it for the whole response: `notPreviouslyExported`, `previouslyExported`, `updatedSinceExport`, `billed`, and `notRebilled` — how many records you received without a charge. **One net-new record and one that changed since you last saw it** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/sync?states=UT&representation=compact&limit=2' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": [ { "id": 184203, "recordType": "project", "projectName": "Alta Ridge Phase II", "boardwalkLink": "https://boardwalkai.com/map/#project=184203", "mergedFrom": [ 184203, 184987 ], "lastUpdated": "2026-06-18T09:22:41Z", "meetingDate": "2026-06-17", "delivery": { "alreadyExported": false, "updatedSinceExport": null, "lastExportedAt": null, "billed": true }, "address": "3600 S Constitution Blvd, West Valley City, UT", "city": "West Valley City", "county": "Salt Lake", "state": "UT", "latitude": 40.6916, "longitude": -112.0011, "propertyType": "residential", "propertyTypeLabel": "Residential", "landUses": [ { "type": "residential", "typeLabel": "Residential", "subtype": "multifamily", "subtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "matchedOn": [] } ], "status": "approved_with_conditions", "statusLabel": "Approved with Conditions" }, { "id": 191774, "recordType": "project", "projectName": "Foothill Commons", "boardwalkLink": "https://boardwalkai.com/map/#project=191774", "mergedFrom": [ 191774 ], "lastUpdated": "2026-06-24T14:08:03Z", "meetingDate": "2026-06-23", "delivery": { "alreadyExported": true, "updatedSinceExport": true, "lastExportedAt": "2026-07-19T02:14:55Z", "billed": true }, "address": null, "city": "Provo", "county": "Utah", "state": "UT", "latitude": 40.2338, "longitude": -111.6585, "propertyType": "residential", "propertyTypeLabel": "Residential", "landUses": [ { "type": "residential", "typeLabel": "Residential", "subtype": "multifamily", "subtypeLabel": "Multifamily", "assetClass": null, "assetClassLabel": null, "matchedOn": [] } ], "status": "continued", "statusLabel": "Continued" } ], "meta": { "schemaVersion": "2026-08-13", "representation": "compact", "limit": 2, "creditsUsed": 2, "creditsRemaining": 284, "delivery": { "mode": "all", "notPreviouslyExported": 1, "previouslyExported": 1, "updatedSinceExport": 1, "billed": 2, "notRebilled": 0, "ledgerApplied": true }, "cursor": "eyJ1cGRhdGVkQXQiOiIyMDI2LTA4LTAyVDE0OjA5OjIyWiIsImlkIjoxOTE3NzR9", "hasMore": true } } ``` _Record bodies on this page were produced by running Boardwalk's production response mapper over a documented sample project, so the field set, the labels and the empty fields are exactly what the API emits. The project itself is a sample, not a real filing._ ### What counts as a change A change is a change to the content we would deliver you — a new decision, a revised unit count, an added document. Re-processing that produces the same values is not a change and does not make a record billable again. The comparison is made against what you were actually sent, not against a timestamp, which is why re-running a stable query is genuinely free rather than nearly free. > **`ledgerApplied`** — If `meta.delivery.ledgerApplied` is `false`, this response's delivery state — and therefore its charge — was computed without your delivery history, so records you already hold may have been billed as new. It is surfaced rather than hidden precisely so that a retro-credit conversation is possible. It should be `true`. --- # Pagination URL: https://boardwalkai.com/docs/api/pagination/ Offset paging with a hard ceiling, and the cursor-based sync feed for when you need every record. `GET /projects/search` pages with `limit` and `offset`. `limit` is the page size **per request** (integer from 1 to 100) — not a hard ceiling on how many rows you can retrieve overall. Values below 1 or non-integers return `400 invalid_request` (same posture as an unknown `representation`). Asking for more than 100 is clamped to 100 and the response says so in `meta.warnings` rather than failing. Keep paging with `offset` (credits still apply per returned row). `offset` must be a non-negative integer — negative values return `400 invalid_request` (they are not silently floored to 0). You can also page with `page` (1-based); when `offset` is absent it becomes `(page - 1) * limit`. ### CSV output Default response format is JSON. Pass `format=csv` to queue a **product-core CSV export** for the current page of results. That is the same single-value column set as the map Export CSV button (Project ID, name, status, location, taxonomy, dates, Boardwalk link, …). Multi-value and contact groups are not included on the API path, so the file stays spreadsheet-safe. CSV does not stream in the search response body. You get HTTP **202** with `exportJobId`, `pollUrl` (`GET /api/v1/exports/{id}`), `downloadUrl`, and `webExportsUrl` (`/exports` in the product UI). Credits are reserved on the export job (1 per row), same as a web export. Re-download of a ready file is free. If every match on the page was already delivered and unchanged, no job is queued: the response status is `no_billable_rows` (not `pending`) with `meta.freeRedeliveryExcluded` explaining why. > **Health check URL** — Liveness is `GET /api/v1/health` (not bare `/health`). Status filter tokens such as `scheduled` or `approved_with_conditions` map to the filterable buckets listed on `GET /api/v1/reference/statuses`. Prefer `representation=standard` with `include=contacts&cachedOnly=true` (or `contactOptions[cachedOnly]=true`) for free cached contacts on GET; live enrichment requires AI search jobs. ### Know when to continue `meta.pageCount` is the number of records in this response and `meta.hasMore` tells you whether another offset page is retrievable. Continue by adding `pageCount` to your current offset while `hasMore` is true. `meta.total` is a deprecated compatibility field containing `offset + pageCount`; despite its old name, it is **not** a total match count. > **`hasMore` is the terminator — a short page is not** — A page may come back shorter than the `limit` you asked for and still have more behind it. Records are removed after the index matches them — duplicate filings are folded into one development, and records we cannot currently stand behind are withheld — so a full page is not guaranteed even in the middle of a result set. **Stop when `hasMore` is `false`, never when `pageCount < limit`.** A client that treats a short page as the end will stop early and silently miss the rest of its results. When you need the size of the whole selection, call the free `GET /projects/count` route with the same filters. On a search response, the `matchCount`, `matchCountIsExact` and `matchCountBasis` fields describe only what that request's bounded candidate scan could prove. Count on a large state with `dateRange=all` can take several seconds under load — fine for a one-shot quote, but cache identical queries in agent loops rather than re-hitting count on every page. ### The honest ceiling > **There is a maximum retrievable result set, and it is not unlimited** — The search backend scans up to a fixed candidate ceiling per request. Beyond that, `matchCountIsExact` comes back `false` and `matchCountBasis` is `scan_ceiling_reached`, meaning the true total is at least the number reported and we are not going to pretend to know how much more. Paging deeper than the ceiling will not reach records the scan never considered. In practice this only bites on very broad queries. The answer is not deeper paging, it is narrowing — by geography, by date window, or by a measurement bound — and counting each narrowed variant for free until the total is one you can actually retrieve. ### When you need every record: use sync Offset paging over a changing dataset can skip records: if a record is updated and re-sorts while you are on page 4, you can miss it. `GET /projects/sync` walks by cursor in last-updated order, which makes it lossless, and it refuses `sort`, `order`, `offset` and `page` because a caller-chosen ordering would break that guarantee while appearing to work. **One net-new record and one that changed since you last saw it** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/sync?states=UT&representation=compact&limit=2' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": [ { "id": 184203, "recordType": "project", "projectName": "Alta Ridge Phase II", "boardwalkLink": "https://boardwalkai.com/map/#project=184203", "mergedFrom": [ 184203, 184987 ], "lastUpdated": "2026-06-18T09:22:41Z", "meetingDate": "2026-06-17", "delivery": { "alreadyExported": false, "updatedSinceExport": null, "lastExportedAt": null, "billed": true }, "address": "3600 S Constitution Blvd, West Valley City, UT", "city": "West Valley City", "county": "Salt Lake", "state": "UT", "latitude": 40.6916, "longitude": -112.0011, "propertyType": "residential", "propertyTypeLabel": "Residential", "landUses": [ { "type": "residential", "typeLabel": "Residential", "subtype": "multifamily", "subtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "matchedOn": [] } ], "status": "approved_with_conditions", "statusLabel": "Approved with Conditions" }, { "id": 191774, "recordType": "project", "projectName": "Foothill Commons", "boardwalkLink": "https://boardwalkai.com/map/#project=191774", "mergedFrom": [ 191774 ], "lastUpdated": "2026-06-24T14:08:03Z", "meetingDate": "2026-06-23", "delivery": { "alreadyExported": true, "updatedSinceExport": true, "lastExportedAt": "2026-07-19T02:14:55Z", "billed": true }, "address": null, "city": "Provo", "county": "Utah", "state": "UT", "latitude": 40.2338, "longitude": -111.6585, "propertyType": "residential", "propertyTypeLabel": "Residential", "landUses": [ { "type": "residential", "typeLabel": "Residential", "subtype": "multifamily", "subtypeLabel": "Multifamily", "assetClass": null, "assetClassLabel": null, "matchedOn": [] } ], "status": "continued", "statusLabel": "Continued" } ], "meta": { "schemaVersion": "2026-08-13", "representation": "compact", "limit": 2, "creditsUsed": 2, "creditsRemaining": 284, "delivery": { "mode": "all", "notPreviouslyExported": 1, "previouslyExported": 1, "updatedSinceExport": 1, "billed": 2, "notRebilled": 0, "ledgerApplied": true }, "cursor": "eyJ1cGRhdGVkQXQiOiIyMDI2LTA4LTAyVDE0OjA5OjIyWiIsImlkIjoxOTE3NzR9", "hasMore": true } } ``` _Record bodies on this page were produced by running Boardwalk's production response mapper over a documented sample project, so the field set, the labels and the empty fields are exactly what the API emits. The project itself is a sample, not a real filing._ **Cost:** 1 credit per record delivered — Only records that are new to you, or that changed since you last received them, are billable. A sync page that returns nothing but unchanged records is free. Carry `meta.cursor` into the next request and stop when `hasMore` is `false`. A cursor is only valid for the filter set and entitlement scope it was minted for; using it with a different query returns `400 invalid_cursor` rather than quietly walking the wrong set. --- # Rate limits URL: https://boardwalkai.com/docs/api/rate-limits/ Authenticated per-key limits, pre-authentication safeguards, backoff headers, and the AI-search daily ceiling. Authenticated requests are limited **per API key**, over a trailing 60 seconds. The limit is on requests, not on credits — the two ceilings are independent and you meet whichever you reach first. The gate also runs before authentication, so a `401` still consumes its applicable bucket. A request with no credential to an authenticated route uses a 10-per-minute source-IP bucket. An unknown or otherwise unusable Boardwalk-shaped key uses its own 10-per-minute credential-digest bucket. Neither case consumes a valid key's allowance. | Caller | Requests per minute | | --- | --- | | Any paid plan | 60 | | Free-trial key | 10 | | Unauthenticated health check (per source IP) | 120 | | Missing credential (per source IP) | 10 | | Unknown/unusable Boardwalk key (per credential digest) | 10 | ### The headers | Header | On | Meaning | | --- | --- | --- | | `X-RateLimit-Limit` | Every response | Your ceiling for the window. | | `X-RateLimit-Remaining` | Every response | What is left in it. | | `X-RateLimit-Reset` | Every response | When the window rolls. | | `Retry-After` | `429` only | Seconds to wait. | A well-behaved client reads `X-RateLimit-Remaining` and paces itself, and never sees a `429`. If you do get one, back off for `Retry-After` seconds — do not retry immediately, and do not fan out. ### Separate ceilings worth knowing about | Ceiling | What it bounds | | --- | --- | | Page size | Records per search page. | | Candidate scan | How many candidates one request will consider — see [pagination](/docs/api/pagination/). | | AI-search candidates reviewed | How much reading one AI search will do. | | AI-search confirmed matches | The largest result set one AI search can return. | | Active alerts per account | How many standing searches you can hold at once. | | Manual alert runs per day | How often you can trigger a run by hand, per alert. | > **The per-account daily AI-search budget has no published number** — There is a daily ceiling on AI-search work per account, and it is enforced by the worker. We have not measured the right value to publish, and publishing a guessed one would be worse than publishing none — you would build against it. A job that reaches it settles as `partial` with `partialReason: daily_ai_budget_exhausted`; confirmed matches remain valid and unused reserved credits are refunded. --- # Idempotency URL: https://boardwalkai.com/docs/api/idempotency/ Retry a billable request safely: the same key replays the same result instead of charging twice. Send an `Idempotency-Key` header on any billable request. If the request is repeated with the same key — because your process crashed, your load balancer retried, or your job runner is at-least-once — we replay the original result and charge nothing the second time. ```bash curl -X POST 'https://api.boardwalkai.com/api/v1/projects/ai-search/jobs' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' \ -H 'Idempotency-Key: nightly-utah-pull-2026-08-02' \ -H 'Content-Type: application/json' \ -d '{"query":"projects with unresolved traffic concerns","stateIds":[1],"limit":50}' ``` ### The rules - Keys are scoped to the **credit pool**, so a teammate cannot collide with your key by accident and cannot replay your result either. - Reusing a key with a **different** request body or filter set is `409 idempotency_key_reuse`. Replaying is safe; changing the request under the key is not. - Reusing a key while the first request is still running is `409 idempotency_in_flight` with a `Retry-After`. Do not fan out the same key in parallel — the whole point is that only one of them charges. - Accepted key characters are `A-Z a-z 0-9 _ . : -`, up to 255 — the same grammar as `X-Request-Id`, so there is one rule to learn. ### Where it matters most On `POST /projects/ai-search/jobs`. That route **reserves** credits up front for the effective `limit` (default **10**, max 100). On completion it charges 1 credit per delivered record and refunds the unused portion. Failures refund the full reservation. Charge-exempt / unlimited pools may show `creditsReserved: 0`; always trust `meta.creditsUsed` on completed responses for what was actually spent. Two identical submits without an idempotency key can hold the whole pool while both run — with a key, the retry replays the same job instead of queueing a second one. --- # Errors URL: https://boardwalkai.com/docs/api/errors/ Every error code the API emits, when it happens, and what to do about it. Every error has the same shape, and every one carries a `docsUrl` pointing at its own entry on this page. **What a request you cannot afford looks like** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/search?states=UT&assetClasses=Apartment%20Building&dateRange=all&limit=100' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `402`: ```json { "error": { "code": "insufficient_credits", "message": "This request would deliver 5,000 records and cost 5,000 credits. You have 500.", "param": null, "details": { "quote": { "matchCount": 5000, "matchCountIsExact": true, "creditRate": 1, "creditsRequired": 5000, "creditsRemaining": 500, "affordableRecordCount": 500, "countUrl": "https://api.boardwalkai.com/api/v1/projects/count?states=UT&assetClasses=Apartment%20Building&dateRange=all" } }, "docsUrl": "https://boardwalkai.com/docs/api/errors#insufficient_credits", "requestId": "req_01K1QF3M0000EXAMPLE0001" } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ | Field | Meaning | | --- | --- | | `error.code` | A stable machine token. Branch on this. | | `error.message` | Human copy. May be reworded; do not parse it. | | `error.param` | The offending parameter, where there is one. | | `error.details` | Structured context — the quote on a `402`, the problem list on a `400`. | | `error.docsUrl` | A link to the entry below. | | `error.requestId` | The same id as the `X-Request-Id` header, our logs and your usage row. Quote it to support. | > **No error is ever charged** — Every `4xx` and `5xx` response leaves your balance exactly where it was. On a `402`, the charge is attempted inside a transaction that is rolled back, so the refusal really is the whole outcome. You can send your own `X-Request-Id` and we will echo it, which lets you join our logs to yours. ### Every code | Code | HTTP | When | What to do | | --- | --- | --- | --- | | `account_disabled` | 401 | The key resolved, but the owning account has been disabled. | Ask a team owner or Boardwalk support to restore the account. Rotating the key does not change the account state. | | `alert_limit_reached` | 403 | The account already holds the maximum number of active alerts. | Pause or delete one. `error.details.maxActiveAlerts` states the ceiling. | | `credits_exhausted` | 402 | The credit pool cannot afford even one billable record. Paid balances reset on their billing schedule; trial credits are a one-off allowance. | Use the free count endpoint to narrow the selection, wait for the paid reset, or add credits. Nothing was delivered and nothing was charged. | | `enrichment_incompatible_representation` | 400 | `include=contacts` was combined with a representation that carries no contact fields — asking to pay for enrichment and then not receive it. | Use `standard` or `full`. | | `enrichment_requires_async` | 400 | `include=contacts` was sent to a synchronous route. Enrichment calls out to third-party sources and cannot complete inside a request. | Submit the same search to `POST /projects/ai-search/jobs` with `include: "contacts"` and poll for the result. `error.details` names the endpoint. | | `export_expired` | 409 | The file lived out its retention window and was swept. `expiresAt` on the job is the deadline, and it had passed. | Run the search again with `format=csv` to build a fresh file. Records you already hold unchanged are free to receive again, so a rebuild usually costs less than the original — price it first with `/projects/count`. | | `export_not_ready` | 409 | The export job exists but has not finished building. `format=csv` is asynchronous — the queue response is a `202` with an id, not a file. | Poll `GET /api/v1/exports/{id}` until `status` is `ready`, then download. The job carries `creditsReserved` from the moment it is queued, so there is nothing further to spend. | | `export_unavailable` | 409 | The job finished and reported `ready`, but its file could not be read back from storage, so it was never delivered. This is **not** expiry — the retention window had not run out. It is a failure on our side. | Nothing to do about the credits: the reservation is returned automatically when this is detected, and `error.details.creditsRefunded` reports how many came back. The job then reads `failed` with an explanation rather than continuing to advertise itself as downloadable. Run the search again to rebuild the file, and quote the `X-Request-Id` if it recurs. | | `forbidden` | 403 | The authenticated account is not permitted to perform this operation, and no more specific policy code applies. | Check the key's account and plan permissions. Nothing was charged. | | `geographic_access_denied` | 403 | A requested state or county falls outside the geography licensed to this pool. | Remove the denied geography or expand the plan. `details.deniedStates` and `details.allowedStates` carry the id, name and abbreviation of each, so you do not have to resolve ids to answer "what can I query instead?" (`deniedStateIds` and `allowedStateIds` remain alongside them). To avoid the error entirely, call the free `GET /locations/states` and read the `entitled` flag on each state before you build a filter. | | `idempotency_in_flight` | 409 | The same `Idempotency-Key` is currently being processed by another request. | Wait for `Retry-After` seconds and retry. Do not fan out the same key in parallel; the point of the key is that only one of them charges. | | `idempotency_key_reuse` | 409 | An `Idempotency-Key` was reused with a **different** request body or filter set. Replaying a key is safe; changing the request under it is not. | Use a fresh key for a different request. Keys are scoped to the credit pool, so a teammate's key cannot collide with yours by accident and cannot be replayed by you either. | | `insufficient_credits` | 402 | The request would have cost more credits than the pool has left. **Nothing was delivered and nothing was charged** — the charge is attempted inside a transaction that is rolled back, so a 402 leaves your balance exactly where it was. | Read `error.details.quote`. It carries `creditsRequired`, `creditsRemaining`, `affordableRecordCount` and a `countUrl` — a free URL that prices this exact filter set. Narrow the filters until `creditsRequired` fits, or top up. An agent can recover from this response without a human, which is why the URL is in the body. | | `internal_error` | 500 | Something failed on our side. | Retry. Nothing was charged — 4xx and 5xx responses never spend credits. Quote the `X-Request-Id` header when you contact support; it is the same id in your response body, in our logs and on the usage row. | | `invalid_cursor` | 400 | A `/projects/sync` cursor was malformed, or was issued against a different filter set or a different entitlement scope than the one now being requested. A cursor is only lossless for the query it was minted for. | `error.details.recovery` says it: repeat the request without the cursor. Records you already hold are not re-billed, so restarting a walk is cheap. | | `invalid_filter` | 400 | One or more filter parameters were rejected: an unknown parameter name, a value outside the enum, a non-numeric range bound, or a place name that resolved ambiguously. Unknown parameters are **refused, never ignored** — silently dropping one would bill you for a broader query than you asked for. | `error.details.problems[]` names every offending parameter with its own message. The filter reference lists every accepted name, alias and value. | | `invalid_request` | 400 | A parameter was present but unusable — an unknown `representation`, a `sort` on the sync feed, a malformed body. `error.param` names it. | Read `error.param` and `error.details.supported`, which lists the accepted values where there is a closed set. | | `key_expired` | 401 | A rotated key reached the end of its 24-hour overlap window. | Switch to the replacement key issued at rotation. The old key cannot be reactivated, but no account or subscription change is required. | | `method_not_allowed` | 405 | The path exists, but the request used an HTTP method that route does not accept. | Read the `Allow` response header and retry with one of the listed methods. The request was not processed and nothing was charged. | | `missing_api_key` | 401 | No credential was presented, or the value was not a Boardwalk API key. | Send `Authorization: Bearer bwk_live_...` or `X-API-Key: bwk_live_...`. A web session cookie or a login JWT is **not** a credential for this API; there is exactly one, and it is an API key. | | `rate_limit_exceeded` | 429 | More requests than the applicable allowance in a trailing 60 seconds. Valid keys are limited per key; missing credentials use a source-IP bucket and unknown or unusable Boardwalk-shaped keys use a credential-digest bucket. | Back off for `Retry-After` seconds. `X-RateLimit-Remaining` and `X-RateLimit-Reset` are on every response, so a well-behaved client never needs to see this code. | | `rate_limiter_unavailable` | 503 | The rate limiter itself could not be reached. The request was refused rather than waved through. | Retry after `Retry-After`. Nothing was charged. | | `resource_not_found` | 404 | The project, document, alert or job id does not exist, or is not visible to this key. Both cases return 404 — an id that exists but belongs to someone else must not be distinguishable from one that does not exist. | Check the id. If you are following a `documents[].meetingDocumentId` from a record, use that value rather than `documents[].id`. | | `search_unavailable` | 503 | The search backend could not be reached, so no count and no results could be produced. This is deliberately **not** reported as zero matches: "we found nothing" and "we could not look" are opposite answers. | Retry. Nothing was charged. | | `service_unavailable` | 503 | The requested API subsystem is temporarily unable to serve the request. | Retry with backoff. Nothing was charged. Quote the request id if the problem persists. | | `subscription_required` | 402 | The key is valid and the account has no active plan or trial — usually a lapsed subscription. **The key is not revoked.** It keeps working the moment the plan is reactivated. | Reactivate the plan. You do not need to re-issue keys or redeploy. | | `sync_tie_unresolved` | 503 | More records share one last-updated timestamp than a single sync page can position, so advancing the cursor could not be done without risking a skip. | `error.details.suggestion`: split the walk by geography and run one cursor per slice. Nothing was charged. | | `unauthorized` | 401 | The key was presented but did not resolve to an account — most often a revoked key, or one whose 24-hour rotation grace window has closed. | Mint a new key on your profile. A rotating key's responses carry `X-Boardwalk-Key-Rotating: true` and a `Warning` header naming the moment it stops, so this is avoidable. | --- # Alerts URL: https://boardwalkai.com/docs/api/alerts/ Standing searches on a cadence. Configuration is free; delivered records bill at the standard rate. An alert is a saved filter set plus a cadence. Each run produces a numbered **delivery** — a batch of records with its own charge — which you can then read. > **Configuration is free, delivery is billed** — Creating, listing, updating, previewing, dismissing and deleting alerts costs nothing, and so does reading the delivery history. Only a record being delivered to you spends credits, at the standard rate — and a record you already hold unchanged is not billed again. ### The endpoints | Endpoint | What it does | | --- | --- | | `POST /alerts` | Create a rule. Free. | | `GET /alerts` | List your rules and how many you have active. Free. | | `GET /alerts/{id}` | Read one rule. Free. | | `PATCH /alerts/{id}` | Change filters, cadence, budget or recipients. Free. | | `DELETE /alerts/{id}` | Remove a rule. Free. | | `POST /alerts/{id}/preview` | How many records would this deliver, and what would it cost? Free, and delivers nothing. | | `POST /alerts/{id}/run` | Trigger a real run now. **Billed** like any delivery, and capped per day. | | `GET /alerts/{id}/deliveries` | The run history with per-run counts and charges. Free. | | `GET /alerts/{id}/matches` | The records from a delivery. Records already billed by the run are re-readable free. | | `POST\|DELETE /alerts/{id}/dismissals/{projectId}` | Stop a match being re-offered. Free. | **Create a daily alert — configuration is free** ```bash curl -X POST 'https://api.boardwalkai.com/api/v1/alerts' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' \ -H 'Content-Type: application/json' \ -d '{ "name": "Utah apartments, 50+ units", "filters": { "states": [ "UT" ], "assetClasses": [ "Apartment Building" ], "totalUnitCountMin": 50 }, "cadence": { "frequency": "daily", "timezone": "America/Denver", "hourLocal": 7 }, "budget": { "maxRecordsPerRun": 100 }, "notify": { "email": true, "emailTo": [ "leads@example.com" ] } }' ``` Response `201`: ```json { "data": { "id": "alr_01K1QF3M0000ALERT000001", "name": "Utah apartments, 50+ units", "status": "active", "filters": { "states": [ "UT" ], "assetClasses": [ "Apartment Building" ], "totalUnitCountMin": 50 }, "deliveryMode": "new", "cadence": { "frequency": "daily", "timezone": "America/Denver", "hourLocal": 7, "backfillDays": 7, "nextRunAt": "2026-08-03T07:00:00-06:00", "lastWindowTo": null }, "budget": { "maxRecordsPerRun": 100 }, "notify": { "email": true, "emailTo": [ "leads@example.com" ], "webhookUrl": null }, "consecutiveFailures": 0, "createdAt": "2026-08-02T14:12:00+00:00", "updatedAt": "2026-08-02T14:12:00+00:00" }, "meta": { "creditsUsed": 0 } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ **What each run delivered, and what it charged** ```bash curl 'https://api.boardwalkai.com/api/v1/alerts/alr_01K1QF3M0000ALERT000001/deliveries?limit=2' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": [ { "id": "dlv_01K1QF3M0000DELIVERY002", "alertId": "alr_01K1QF3M0000ALERT000001", "sequence": 2, "status": "delivered", "window": { "from": "2026-08-01T07:00:00-06:00", "to": "2026-08-02T07:00:00-06:00" }, "counts": { "matched": 9, "delivered": 9, "billable": 7 }, "credits": { "unit": "record", "rate": 1, "charged": 7, "remaining": 286 }, "matchesUrl": "/api/v1/alerts/alr_01K1QF3M0000ALERT000001/matches?deliveryId=dlv_01K1QF3M0000DELIVERY002" } ], "meta": { "limit": 2, "offset": 0, "creditsUsed": 0 } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ ### Email is a notification, not the payload `notify.email` tells people that a delivery happened. The records come from the API. Building against the email would mean parsing a message we reserve the right to redesign. > **Why web alerts and API alerts are priced differently** — Alert delivery inside the Boardwalk web product is included in the subscription price; over the API, delivered records are metered at the standard rate. That is a deliberate difference between two ways of buying — the web product costs more and includes delivery, the API is metered and cheaper to start — not an oversight. --- # Contacts and enrichment URL: https://boardwalkai.com/docs/api/contacts/ Who is behind a project, what we will and will not tell you about them, and why enrichment does not cost extra. Every record carries the parties named on the filing itself, split into two arrays that are governed by different rules. | Array | Who is in it | What ships | | --- | --- | --- | | `contacts` | External parties — developers, applicants, architects, consultants. | Name, title, role, company. On a paid plan, also email, phone and LinkedIn where we have them, each with its own confidence and source. | | `publicOfficials` | Government staff — planners, clerks, commissioners. | Name, title, organisation. **Never a contact channel, on any tier.** | `contactSummary` counts both, plus how many contacts were withheld and why — so a record with no contact channels is distinguishable from a record where we withheld them. ### Enrichment > **Enrichment is inside the 1 credit** — A record costs 1 credit whether you asked for contacts or not, whether we enriched it now, whether we already knew the contact from an earlier lookup, and whether we found nothing. Ask for 50 records with enrichment on, find contacts for 10, receive all 50, pay 50 credits. Add `include=contacts` to an asynchronous request. Enrichment calls out to third-party sources and cannot complete inside a synchronous request, so a synchronous route returns `400 enrichment_requires_async` and names the endpoint to use. **The same search, with contact enrichment switched on** ```bash curl -X POST 'https://api.boardwalkai.com/api/v1/projects/ai-search/jobs' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' \ -H 'Content-Type: application/json' \ -d '{ "query": "multifamily projects that were continued for parking concerns", "stateIds": [ 1 ], "limit": 50, "include": "contacts", "contactOptions": { "minConfidence": "high" } }' ``` Response `202`: ```json { "requestId": 918509, "status": "queued", "pollUrl": "https://api.boardwalkai.com/api/v1/projects/ai-search/jobs/918509", "meta": { "creditsReserved": 50, "limit": 50, "trialTruncation": null, "requestId": "req_01K1QF3M0000EXAMPLE0001" } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ The full option list and the status enum are on the [contact enrichment recipe](/docs/api/recipes/contact-enrichment/). ### The limits we will not paper over > **Coverage is narrower than our project coverage, and there is no freshness promise** — Contact enrichment does not cover everywhere we cover projects. We are not going to describe it as nationwide, because it is not. Separately, a contact we established earlier is served at the same price and carries the age of that original lookup, which we do not currently expose — so if recency is critical to your workflow, verify before you use it. ### Opting out If your contact details appear in Boardwalk and you want us to stop distributing them, [tell us](/privacy/contact-opt-out/). Suppression applies everywhere, immediately, for every customer. --- # Analytics URL: https://boardwalkai.com/docs/api/analytics/ Free aggregates and rankings over the same corpus — counts and group keys, never record identity. The analytics plane answers questions about the shape of a market without selling you the records behind it. It is free — every endpoint under `/analytics` costs zero credits — and it never returns a project id, name, address, contact or document. > **Aggregate and rankings are POST-only** — `GET /analytics/datasets` is the only analytics route that accepts GET. `/analytics/aggregate` and `/analytics/rankings` are **POST** with a JSON body. A GET to either returns 404 or 405 — that is intentional, not a missing route. | Endpoint | Method | What it does | | --- | --- | --- | | `/analytics/datasets` | GET | Self-description: what you can group by, which metrics exist, which are rankable, the ceilings, and a `notAnswerable` list. | | `/analytics/aggregate` | POST | Group-by with metrics over the published dimensions. | | `/analytics/rankings` | POST | A leaderboard with a minimum denominator and a comparison baseline. | **Ask where apartment development concentrates — for zero credits** ```bash curl -X POST 'https://api.boardwalkai.com/api/v1/analytics/aggregate' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' \ -H 'Content-Type: application/json' \ -d '{ "dataset": "projects", "groupBy": [ "city" ], "metrics": [ "count", "unitCountSum" ], "filters": { "states": [ "UT" ], "assetClasses": [ "Apartment Building" ] } }' ``` Response `200`: ```json { "data": { "dataset": "projects", "groupBy": [ "city" ], "groups": [ { "keys": { "city": "Salt Lake City" }, "metrics": { "count": 142, "unitCountSum": 18240 }, "coverage": { "count": 1, "unitCountSum": 0.9718 } }, { "keys": { "city": "Provo" }, "metrics": { "count": 64, "unitCountSum": 7112 }, "coverage": { "count": 1, "unitCountSum": 0.9531 } } ], "totals": { "count": 412, "unitCountSum": 51230 }, "suppressed": { "groups": 3, "records": 9, "floorK": 5 } }, "meta": { "schemaVersion": "2026-08-13", "suppressionFloorK": 5, "credits": { "charged": 0 }, "requestId": "req_01K1QF3M0000EXAMPLE0001" } } ``` _We were not able to execute this call while writing the page, so treat the body as indicative of the shape rather than as a captured response. If it disagrees with what you receive, what you receive is correct — please tell us._ > **`groupBy` and `metrics` are required** — Pass both keys on every aggregate body. Omitting them used to return nationwide totals with an empty groupBy — a quiet footgun. Explicit `groupBy: []` with `metrics: ["count"]` is still valid when you want totals only. ### Default date window If you omit a meeting-date filter, analytics applies the same default as search: roughly the last year (`dateRange=last_1_year`). That shows up in `meta.appliedDefaults` and a `default_date_window_applied` warning — pass `dateRange=all` (or an explicit `meetingDateFrom` / `meetingDateTo`) when you want the full history. **There is no silent default state filter.** `appliedFilters.states` only appears when you sent a state filter. Plan geography ceilings live under `meta.geographicScope.enforcedStateIds`, not as a fake client filter. ### Filters and groupBy use the same state `filters.states` and `groupBy: ["state"]` both mean meeting jurisdiction (the same RediSearch `state_id` search uses). Send abbreviations, names, or numeric ids — we resolve them to ids for the lookup and echo the mapping in `meta.resolvedFrom`. ### Small groups are suppressed A group with fewer members than the suppression floor is not returned as its own row. The count of withheld groups and records is under `data.suppressed` (`groups`, `records`, `floorK`). That stops the free plane being used to reconstruct individual records, and stops a leaderboard whose top row has a denominator of one. Use `/rankings` rather than assembling a leaderboard from `/aggregate`. A ranking carries a minimum denominator and a baseline; a hand-rolled one has neither. ### The intended workflow 1. Aggregate to find where the activity is. Free. 2. Count the records in the narrowed set. Free. 3. Search — and pay for — only the records you will act on. --- # All endpoints and their cost URL: https://boardwalkai.com/docs/api/endpoints/ Every endpoint on the surface, with what it charges, in one table. Generated from the same map the biller reads, so the cost stated here is the cost charged. The interactive reference at [/docs/api/reference/](/docs/api/reference/) carries the full request and response schemas. The raw OpenAPI document is at [https://api.boardwalkai.com/api/reference.json](https://api.boardwalkai.com/api/reference.json) (also mirrored on the docs host as `/api/reference.json`). > **Watch the Method column** — Several free routes are **POST-only** even though they return no records: `POST /analytics/aggregate`, `POST /analytics/rankings`, and most alert write paths. A GET to a POST-only path is a 404 or 405, not a free alternative. [Analytics methods](/docs/api/analytics/). | Method | Path | Cost | Notes | | --- | --- | --- | --- | | GET | `/alerts/{alertId}/matches` | 1 credit / record | Records already delivered and unchanged since a scheduled run are re-readable here without a second charge; anything new or changed bills once. | | POST | `/alerts/{alertId}/run` | 1 credit / record | A manual run is a real delivery: records new to you or changed since their last delivery bill at the standard rate. Runs are capped per alert per day. | | GET | `/documents/{id}` | 1 credit / record | One source document, one credit. | | POST | `/projects/ai-search` | 1 credit / record | The same rate as a structured search. You pay for confirmed matches that are new to you or changed since their last delivery; rejected candidates and unchanged re-deliveries are free. | | POST | `/projects/ai-search/jobs` | 1 credit / record | Credits are reserved up front against the requested limit and settled down to confirmed records that are new to you or changed since their last delivery. Unused reservation, including unchanged re-deliveries, is refunded. | | GET | `/projects/search` | 1 credit / record | JSON pages: charged for records actually delivered after collapse, with unchanged re-delivery free. Zero results cost nothing. `format=csv` does not use this search charge — it reserves product export credits (1 per core CSV row) and returns HTTP 202 with poll/download URLs. | | GET | `/projects/sync` | 1 credit / record | Only records that are new to you, or that changed since you last received them, are billable. A sync page that returns nothing but unchanged records is free. | | GET | `/projects/{id}` | 1 credit / record | One credit when the record is new to you or changed since your last delivery; an unchanged re-read is free, just like the same record inside a search page. | | GET | `/account/credits` | Free | Free. Your balance, your plan and your rate limit. | | GET | `/alerts` | Free | Free. Configuring alerts never costs credits; only delivered records do. | | POST | `/alerts` | Free | Free. | | GET | `/alerts/{alertId}` | Free | Free. | | PATCH | `/alerts/{alertId}` | Free | Free. | | DELETE | `/alerts/{alertId}` | Free | Free. | | GET | `/alerts/{alertId}/deliveries` | Free | Free. The delivery history and what each run charged. | | GET | `/alerts/{alertId}/deliveries/{deliveryId}` | Free | Free. | | POST | `/alerts/{alertId}/dismissals/{projectId}` | Free | Free. Dismissing a match stops it being re-offered. | | DELETE | `/alerts/{alertId}/dismissals/{projectId}` | Free | Free. | | POST, GET | `/alerts/{alertId}/preview` | Free | Free. Preview returns how many records a rule would deliver and what that would cost, not the records themselves. | | POST | `/analytics/aggregate` | Free | Free. POST only (GET returns 404/405). Returns counts and group keys, never a project id, name, address, contact or document. | | GET | `/analytics/datasets` | Free | Free. GET only. What the analytics plane can and cannot answer, self-described. | | POST | `/analytics/rankings` | Free | Free. POST only (GET returns 405). Same small-group suppression floor as /aggregate. | | GET | `/exports` | Free | Free. Lists export jobs for your team (including jobs queued with format=csv on search). Credits were reserved when the job was created. | | GET | `/exports/{id}` | Free | Free. Poll job status until ready. | | GET | `/exports/{id}/download` | Free | Free re-download of a ready product-core CSV. Creating the job reserved export credits (1 per row) when you queued it. | | GET | `/health` | Free | Free and unauthenticated. | | GET | `/locations/cities/{id}` | Free | Free. | | GET | `/locations/counties/{id}` | Free | Free. | | GET | `/locations/states` | Free | Free. Resolve the ids you filter with. | | GET | `/locations/states/{id}` | Free | Free. | | GET | `/locations/states/{id}/counties` | Free | Free. | | GET | `/locations/states/{stateId}/counties/{countyId}/cities` | Free | Free. | | GET | `/projects/ai-search/jobs/{id}` | Free | Polling is free. The records the job delivers were charged when the job settled, not when you read them. | | GET | `/projects/count` | Free | Free, always, including when your balance is zero. This is the endpoint that tells you what a query would cost before you buy it. | | GET | `/reference/decision-bodies` | Free | Free. Canonical values accepted by the decisionBodies filter. | | GET | `/reference/owner-types` | Free | Free. Owner-type values accepted by ownerTypes filters. | | GET | `/reference/request-types` | Free | Free. Canonical values accepted by the requestTypes filter. | | GET | `/reference/statuses` | Free | Free. Canonical status filter tokens plus display aliases (e.g. scheduled). | | GET | `/taxonomy` | Free | Free. The complete land use taxonomy tree. | | GET | `/taxonomy/action-categories` | Free | Free. | | GET | `/taxonomy/action-types` | Free | Free. | | GET | `/taxonomy/asset-classes` | Free | Free. | | GET | `/taxonomy/project-types` | Free | Free. | | GET | `/taxonomy/subtypes` | Free | Free. | > **Base URL** — All paths are relative to `https://api.boardwalkai.com/api/v1`. --- # Known limitations URL: https://boardwalkai.com/docs/api/limitations/ What Boardwalk does not do yet, in one place, so you find out here rather than in production. Every limitation on this page is also stated where you would meet it. They are collected here so you can read all of them before you commit to building, rather than discovering them one at a time. ### Coverage is per-state, not nationwide > **Check your market before you buy** — Boardwalk ingests meeting records from specific states and localities, and the set is growing. We do not make a blanket nationwide claim, because it would not be true. `GET /locations/states` is free and lists exactly what we have — call it before you commit, and count a real query in your actual market to see the density rather than the presence. Within a covered state, coverage varies by locality and by how a given body publishes its records. A jurisdiction that posts scanned image PDFs with no text layer yields less than one that posts structured minutes. ### Filtering uses the response's evidence standard > **Missing evidence is excluded before billing** — Search-index matches are rechecked against the collapsed record and exact source snapshot. If Boardwalk cannot confirm the requested status, taxonomy, date, geography or measurement, the record is not returned. [Full explanation.](/docs/api/data-availability/) ### Free-trial keys truncate instead of refusing > **A short page on a trial key may be your budget, not the end of the results** — A paid plan is never partially delivered — a request it cannot afford is refused with `402` and nothing is charged. A **trial** key does the opposite: it delivers as many records as the remaining trial balance affords, in the stated sort order, and reports it in `meta.trialTruncation`. Check that field before concluding a market is small. This is the behaviour most likely to mislead someone evaluating us, which is why it is called out three times across these docs. ### There is a maximum retrievable result set Very broad queries hit a candidate scan ceiling. `matchCountIsExact: false` with `matchCountBasis: "scan_ceiling_reached"` means the true total is at least the number reported. Paging deeper will not reach records the scan never considered — narrow instead. [Details.](/docs/api/pagination/) > **`scanCeiling` counts index rows, not projects** — The ceiling bounds how much of the **index** one request may read, and a project occupies several index rows — we store its text in chunks, and every chunk matches your structured filters. So `scanCeiling: 10000` is ten thousand rows, and the number of distinct projects they resolve to is smaller and depends on how much text those particular projects have. Do not read `matchCount` and `scanCeiling` as the same unit, and do not expect a truncated `matchCount` to approach `scanCeiling` before `matchCountIsExact` goes false — a truncated count in the low thousands against a ceiling of ten thousand is the normal shape of this, not a contradiction. ### Contact enrichment coverage and freshness Enrichment coverage is narrower than project coverage, and a re-served contact carries the age of the original lookup, which we do not currently expose. [Details.](/docs/api/contacts/) ### Not available over the API - **A per-member spend breakdown.** `GET /account/credits` reports the pool, not who in the team spent it. - **Webhooks.** There is no outbound event delivery yet. Alerts on a cadence plus the sync feed cover most of what people want webhooks for; if you need push delivery, tell us. - **Bulk export.** The paged and sync endpoints are the supported ways to take a large set, and the sync feed is the right one for a full mirror. - **A maintained client library.** We publish an OpenAPI document and generated code samples instead. [Why, and what to generate.](/docs/api/sdks/) - **A published uptime or latency figure.** We have not measured one we would stand behind, so we are not publishing one. ### Tell us If one of these blocks you, that is useful information — write to `support@boardwalkai.com` with the `X-Request-Id` from a relevant response. --- # Versioning and change policy URL: https://boardwalkai.com/docs/api/versioning/ What counts as a breaking change, what does not, how much notice you get, and how to tell versions apart. The API is versioned in the path (`/api/v1`). Within a version, the response contract carries its own date-stamped schema version in the `X-Boardwalk-Schema-Version` header on every response. Selected successful JSON envelopes also echo it as `meta.schemaVersion` for convenience, but clients should treat the header as authoritative. It is a date rather than a semantic version, so you can compare it against the changelog without a mapping table. ### What is a breaking change | Breaking — you get notice | Not breaking — ships without notice | | --- | --- | | Removing a field | Adding a field | | Changing a field's type | Adding an optional request parameter | | Removing or renaming a request parameter | Adding a request parameter alias | | Removing or renaming an enum token | Adding a new enum token | | Removing an endpoint | Adding an endpoint | | Raising a credit rate | Adding a new error code | | Making an optional request parameter required | Rewording an error `message` or a `*Label` field | > **Your client must tolerate unknown fields** — We add fields. A client that rejects a response because it contains a key it has not seen before is not compatible with this API and will break on an additive change. This is the one requirement we place on your side. A breaking change ships with at least **90 days** of notice, a `Deprecation` header and a `Sunset` header on the affected responses, and a changelog entry. The headers appear before the change, so an integration can detect a deadline without anyone reading an email. ### Changes to which records come back > **Correctness fixes can make a result set smaller** — Filters are held to the same evidence standard as the values returned ([see why](/docs/api/data-availability/)). A stale indexed candidate or a record with no current evidence is excluded before billing, so a saved query can return fewer records after a correctness fix. We classify that as a **correctness change, not a breaking change**: the response shape does not narrow, while records that did not satisfy the requested constraint stop appearing. Such changes ship with a changelog entry and a schema-version bump so clients can audit when the behavior changed. ### Legacy aliases Some paths and key prefixes exist for compatibility and are not going away: `csk_` keys are accepted indefinitely, and the previous API hostname continues to resolve. They are not advertised, but nothing built against them will be switched off without the notice above. - [Changelog](https://boardwalkai.com/docs/api/changelog/) --- # Changelog URL: https://boardwalkai.com/docs/api/changelog/ Every change to the API contract, newest first. Entries are generated from `config/api-changelog.yaml` and are also available as JSON at `/api/changelog.json` for machine consumption. Read the [versioning policy](/docs/api/versioning/) for what each change type means for your integration. - **2026-08-13** (2026-08-13, added) — Enrichment-discovered contacts, registry-resolved companies, and freshness-aware confidence. - **2026-08-10** (2026-08-10, fixed) — Structured filters are rechecked against the exact record delivered, and filtered measurements are inspectable. - **2026-08-08** (2026-08-08, changed) — Filtering on a land use now finds projects that have it, not only projects headlined by it — and every record shows which of its uses matched. - **2026-08-07** (2026-08-07, removed) — The ingest timestamp is gone from the API: meetingDate is the only project date. - **2026-08-06** (2026-08-06, fixed) — Search outages are explicit, and AI-search requests and job results now fail closed. - **2026-08-06** (2026-08-06, fixed) — Quickstart billing, billable-route coverage, and rate-limit guidance now match enforcement. - **2026-08-06** (2026-08-06, fixed) — Public API router failures stay JSON, browser CORS supports write routes, and AI search filters legacy candidates earlier. - **2026-08-06** (2026-08-06, fixed) — Customer-facing API behavior, pagination, and reference catalogues now match the published contract. - **2026-08-05** (2026-08-05, fixed) — Response contracts now match the API: uniform errors, explicit pagination, and canonical filters. - **2026-08-02** (2026-08-02, changed) — One rate everywhere: 1 credit per record delivered, including AI search and including contact enrichment. - **2026-08-02** (2026-08-02, added) — Free cost preview: GET /projects/count returns a quote before you spend anything. - **2026-08-02** (2026-08-02, changed) — A paid request that costs more than your balance is refused, not partially delivered. - **2026-08-02** (2026-08-02, added) — Delivery state on every record, and no charge for an unchanged re-delivery. - **2026-08-02** (2026-08-02, added) — Incremental sync — GET /projects/sync walks by cursor in last-updated order, losslessly. - **2026-08-02** (2026-08-02, added) — Alerts over the API — saved searches on a cadence. Configuration free, delivery billed. - **2026-08-02** (2026-08-02, added) — A free analytics plane — counts, group-bys and rankings that never return record identity. - **2026-08-02** (2026-08-02, changed) — Responses are now an explicit allow-list, and project ids are integers. - **2026-08-02** (2026-08-02, changed) — Values are held to Boardwalk's current extraction standard, or the field is empty. - **2026-08-02** (2026-08-02, changed) — Strict input: an unknown query parameter is a 400 that costs nothing, rather than being silently ignored. - **2026-08-02** (2026-08-02, changed) — API keys: bwk_live_ and bwk_test_ prefixes, team ownership, and 24-hour rotation overlap. - **2026-08-02** (2026-08-02, added) — Contact enrichment over the API, inside the same credit, as a per-request toggle. - **2026-08-02** (2026-08-02, added) — Per-key rate limits with headers, ULID request ids, and idempotency on billable requests. - **2026-08-02** (2026-08-02, removed) — Removed from responses: image references, source-document file paths, and pipeline-version markers. --- # Client libraries URL: https://boardwalkai.com/docs/api/sdks/ We publish an OpenAPI document rather than maintaining SDKs. Here is how to generate a client from it. There is no hand-maintained Boardwalk SDK, and that is deliberate: a client library that lags the API is worse than no library, because it looks authoritative. We publish a generated OpenAPI 3.0 document instead, and it is produced from the same registries that serve the responses. ```bash # The canonical document curl https://api.boardwalkai.com/api/reference.json -o boardwalk.json ``` ### Generate a typed client ```bash # TypeScript types only — no runtime dependency npx openapi-typescript boardwalk.json -o boardwalk.d.ts # A full client, any of ~50 languages npx @openapitools/openapi-generator-cli generate \ -i boardwalk.json \ -g python \ -o ./boardwalk-client ``` > **A generated client is yours, not ours** — Generated code is not covered by our compatibility promise — the **contract** is. Regenerate when the schema version changes, and make sure your generator is configured to tolerate unknown properties, because we add fields without notice. ### For agents, the MCP server is the client library If you are building on an LLM, do not generate a client — [install the MCP server](/docs/mcp/install/). It carries the tool schemas, the credit costs and the budget guidance already. --- # Recipes URL: https://boardwalkai.com/docs/api/recipes/ Worked, executable examples for each of the jobs people hire the Boardwalk API to do. Every recipe below is a complete task: the request, the response we would send, what it costs, and how the cost moves as you scale it. They are ordered roughly by how early you are likely to need them. - [Count before you buy](https://boardwalkai.com/docs/api/recipes/count-before-you-buy/) — Price a query for free, then decide. The flagship flow. - [One shared credit pool](https://boardwalkai.com/docs/api/recipes/shared-credit-pool/) — CSV, API and MCP spend the same balance. - [Targeting a geography](https://boardwalkai.com/docs/api/recipes/geographic-targeting/) — A list of states, one county, one city. - [Reading a record](https://boardwalkai.com/docs/api/recipes/reading-a-record/) — Field by field, including the empty ones. - [The free sandbox](https://boardwalkai.com/docs/api/recipes/free-sandbox/) — What 100 trial credits buy, and what they do not. - [Alerts over the API](https://boardwalkai.com/docs/api/recipes/alerts-over-api/) — A saved search on a cadence, delivered and billed per record. - [AI search, asynchronously](https://boardwalkai.com/docs/api/recipes/ai-search-async/) — Find what no structured filter can express. - [Contact enrichment](https://boardwalkai.com/docs/api/recipes/contact-enrichment/) — Names and channels, inside the same 1 credit. - [Net-new versus updated](https://boardwalkai.com/docs/api/recipes/net-new-leads/) — Never pay twice for a record that did not change. - [Keeping an agent inside its budget](https://boardwalkai.com/docs/api/recipes/agent-budget-safety/) — The pattern that stops a prompt spending a month of credits. - [Analysis without buying records](https://boardwalkai.com/docs/api/recipes/council-vote-analysis/) — Where apartment development concentrates — for zero credits. --- # One shared credit pool URL: https://boardwalkai.com/docs/api/recipes/shared-credit-pool/ CSV exports, API pulls and MCP calls all spend the same monthly balance, and the API tells you where it went. ### Who this is for Anyone on a team plan who is about to be surprised by a balance. If two people use Boardwalk and one of them uses the API, this page is the one that prevents the surprise. ### The rule > **There is one balance, and everything spends it** — Your plan's monthly credits are a single pool held by the team, not a per-seat or per-surface allowance. A CSV export from the web app, a `GET /projects/search`, and an MCP tool call made by an agent all draw down the same number, at the same rate of **1 credit per record**. The worked case: your plan includes 500 credits. A teammate downloads a CSV of 200 projects. You then pull 300 records over the API. The pool is now at zero, and your next metered request is refused with a `402` and a quote — not truncated. | Action | Surface | Records | Credits | Pool after | | --- | --- | --- | --- | --- | | Month starts | — | — | — | 500 | | Teammate exports a CSV | Web app | 200 | 200 | 300 | | You pull a page of leads | API | 100 | 100 | 200 | | You pull two more pages | API | 200 | 200 | 0 | | Your next page | API | — | `402`, nothing delivered | 0 | ### Check the balance before you assume it **Read the shared balance** ```bash curl 'https://api.boardwalkai.com/api/v1/account/credits' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": { "plan": "single_state", "planDisplayName": "State", "creditsLimit": 500, "creditsUsed": 200, "creditsRemaining": 300, "periodStart": "2026-08-01", "periodEnd": "2026-08-31", "resetsAt": "2026-09-01T00:00:00+00:00", "rateLimit": 60, "rateLimitPer": "minute", "isTeamPool": true, "poolUserId": 8814 }, "meta": { "requestId": "req_01K1QF3M0000EXAMPLE0001" } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ `isTeamPool: true` is the field that matters here. It says the numbers you are reading are the team's, not yours, and that a colleague can move them while you are not looking. `poolUserId` identifies the pool so two seats can tell they are drawing on the same one. **Cost:** Free — 0 credits — Free. Your balance, your plan and your rate limit. ### What this means in practice - Do not build a client that assumes its own balance is the whole story. Read `X-Credits-Remaining` off each response, or re-read the balance, rather than decrementing a local counter. - Before a large pull, price it with the free [count endpoint](/docs/api/recipes/count-before-you-buy/) — it reads the same pool and reports the same remaining figure. - A `402` is not a failure of your credentials. Your key is fine; the pool is empty. > **What we do not break down yet** — The credits endpoint reports the pool, not a per-member ledger. If you need to know which seat spent what, that breakdown is not yet available over the API. We would rather say so than have you build a report on a field that does not exist. ### Next - [Count before you buy](https://boardwalkai.com/docs/api/recipes/count-before-you-buy/) - [The credit model](https://boardwalkai.com/docs/api/credits/) --- # Targeting a geography URL: https://boardwalkai.com/docs/api/recipes/geographic-targeting/ An exact list of states, a single county, a single city — and how to prove the API searched what you meant. ### Who this is for Homebuilders and lead-gen teams whose market is a specific list of places, not a region. "Utah, Colorado and Arizona" and "just West Valley City" are both first-class queries. ### A list of states Pass every state you want. Values inside one parameter are an **OR**; different parameters are **AND**ed. States accept numeric ids, USPS abbreviations, or full names — use whichever your data already holds. **An exact list of states** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/search?states=UT%2CCO%2CAZ&propertyTypes=residential&representation=compact&limit=1' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": [ { "id": 184203, "recordType": "project", "projectName": "Alta Ridge Phase II", "boardwalkLink": "https://boardwalkai.com/map/#project=184203", "mergedFrom": [ 184203, 184987 ], "lastUpdated": "2026-06-18T09:22:41Z", "meetingDate": "2026-06-17", "delivery": { "alreadyExported": false, "updatedSinceExport": null, "lastExportedAt": null, "billed": true }, "address": "3600 S Constitution Blvd, West Valley City, UT", "city": "West Valley City", "county": "Salt Lake", "state": "UT", "latitude": 40.6916, "longitude": -112.0011, "propertyType": "residential", "propertyTypeLabel": "Residential", "landUses": [ { "type": "residential", "typeLabel": "Residential", "subtype": "multifamily", "subtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "matchedOn": [] } ], "status": "approved_with_conditions", "statusLabel": "Approved with Conditions" } ], "meta": { "schemaVersion": "2026-08-13", "representation": "compact", "total": 1, "pageCount": 1, "hasMore": true, "limit": 1, "offset": 0, "creditsUsed": 1, "creditsRemaining": 298, "trialTruncation": null, "searchMethod": "redisearch", "appliedFilters": { "states": [ 1, 57, 54 ], "propertyTypes": [ "residential" ] }, "appliedDefaults": [ "dateRange=last_1_year" ], "resolvedFrom": { "states": [ { "input": "UT", "id": 1 }, { "input": "CO", "id": 57 }, { "input": "AZ", "id": 54 } ] }, "warnings": [ { "code": "default_date_window_applied", "message": "No date filter was given, so the last 12 months of meetings were searched \u2014 the same window the product shows. Pass dateRange=all to search everything.", "params": [ "meetingDateFrom" ] } ], "matchCount": null, "matchCountIsExact": false, "matchCountBasis": "not_computed", "duplicatesCollapsed": 0, "collapseApplied": true, "delivery": { "mode": "all", "notPreviouslyExported": 1, "previouslyExported": 0, "updatedSinceExport": 0, "billed": 1, "notRebilled": 0, "ledgerApplied": true } } } ``` _Record bodies on this page were produced by running Boardwalk's production response mapper over a documented sample project, so the field set, the labels and the empty fields are exactly what the API emits. The project itself is a sample, not a real filing._ Two things in that response are worth reading before the records. `meta.appliedFilters` is the receipt: it shows what actually ran after normalisation, so `"UT"` resolving to state id `44` is visible rather than assumed. `meta.resolvedFrom` shows the mapping it used. If you ever wonder whether the API searched what you meant, these two fields answer it without a support ticket. ### One city A city or county narrows **within** the states you named — it does not add to them. Names are resolved against the state when you give one, and an ambiguous name is an error rather than a silently wider search: there are Springfields in more than one state, and guessing would cost you credits on the wrong market. **One city, resolved by name** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/search?states=UT&cities=West%20Valley%20City&limit=1' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": [ { "id": 184203, "recordType": "project", "projectName": "Alta Ridge Phase II", "projectHeadline": "Planning Commission approved 240 apartments with conditions", "boardwalkLink": "https://boardwalkai.com/map/#project=184203", "mergedFrom": [ 184203, 184987 ], "lastUpdated": "2026-06-18T09:22:41Z", "meetingDate": "2026-06-17", "delivery": { "alreadyExported": false, "updatedSinceExport": null, "lastExportedAt": null, "billed": true }, "address": "3600 S Constitution Blvd, West Valley City, UT", "city": "West Valley City", "county": "Salt Lake", "state": "UT", "postalCode": "84119", "latitude": 40.6916, "longitude": -112.0011, "locationPrecision": "address", "locationPrecisionLabel": "Address", "isApproximate": false, "parcelApn": [ "15-27-301-004" ], "propertyType": "residential", "propertyTypeLabel": "Residential", "propertySubtype": "multifamily", "propertySubtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "landUses": [ { "type": "residential", "typeLabel": "Residential", "subtype": "multifamily", "subtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "matchedOn": [] } ], "status": "approved_with_conditions", "statusLabel": "Approved with Conditions", "statusDetail": "The commission approved the preliminary plat subject to eight conditions.", "requestType": "final_plat", "requestTypeLabel": "Final Plat", "decisionBody": "Planning Commission", "caseNumbers": [ "PLAT-2026-0142" ], "acreage": 9.8, "squareFootage": null, "unitCount": [ { "count": 240, "type": "apartment", "typeLabel": "Apartment" } ], "totalUnitCount": 240, "bedroomCount": null, "buildingStories": null, "floorCount": null, "buildingHeightFeet": null, "parkingSpaces": 372, "existingZoning": "A-1", "proposedZoning": "RM-16", "isRezone": true, "constructionType": "new_building", "constructionTypeLabel": "New Building", "ownerType": "private", "ownerTypeLabel": "Private", "constructionDescription": "A 240-unit garden-style apartment community on 9.8 acres.", "developerCompany": "Alta Ridge Development LLC", "developerCompanyRole": "developer", "developerCompanyRoleLabel": "Developer", "contacts": [ { "name": "Rachel Okafor", "title": "Director of Development", "role": "developer", "roleLabel": "Developer", "partyClass": "external", "partyClassLabel": "External Party", "entityType": "person", "company": "Alta Ridge Development LLC", "email": "r.okafor@example.com", "emailConfidence": "high", "emailSource": "filing", "phone": "+1-555-0142", "phoneConfidence": "high", "phoneSource": "filing", "linkedinUrl": null, "linkedinConfidence": null, "linkedinSource": null, "contactProvenance": "filing", "enrichmentSource": null, "enrichedAt": null, "enrichmentStatus": "not_requested", "contactsWithheldReason": null } ], "contactEnrichment": { "status": "not_requested", "statusLabel": "Not Requested", "attemptedAt": null, "contactsFound": 0, "source": "registry" }, "companyEnrichment": [], "publicOfficials": [ { "name": "Dana Whitfield", "title": "Senior Planner", "role": "city_planner", "roleLabel": "City Planner", "partyClass": "government", "organization": "West Valley City" } ], "contactSummary": { "externalCount": 1, "governmentCount": 1, "unclassifiedCount": 0, "withEmail": 1, "withPhone": 1, "withLinkedin": 0, "enrichedCount": 0, "lowConfidenceWithheld": 0, "withheldCount": 0, "withheldReason": null }, "keyFacts": [ { "fact": "240 apartment units across six buildings", "category": "unit_mix", "categoryLabel": "Unit Mix" } ], "evidence": "Commissioner Reyes moved to approve subject to conditions 1-8.", "voteSummary": { "yes": 2, "no": 1, "abstain": 0, "absent": 1, "total": 4, "isUnanimous": false, "meetingDate": "2026-06-17" } } ], "meta": { "schemaVersion": "2026-08-13", "representation": "standard", "total": 1, "pageCount": 1, "hasMore": true, "limit": 1, "offset": 0, "creditsUsed": 1, "creditsRemaining": 297, "trialTruncation": null, "searchMethod": "redisearch", "appliedFilters": { "states": [ 1 ], "cities": [ 31688 ] }, "appliedDefaults": [ "dateRange=last_1_year" ], "resolvedFrom": { "states": [ { "input": "UT", "id": 1 } ], "cities": [ { "input": "West Valley City", "id": 31688 } ] }, "warnings": [ { "code": "default_date_window_applied", "message": "No date filter was given, so the last 12 months of meetings were searched \u2014 the same window the product shows. Pass dateRange=all to search everything.", "params": [ "meetingDateFrom" ] } ], "matchCount": null, "matchCountIsExact": false, "matchCountBasis": "not_computed", "duplicatesCollapsed": 0, "collapseApplied": true, "delivery": { "mode": "all", "notPreviouslyExported": 1, "previouslyExported": 0, "updatedSinceExport": 0, "billed": 1, "notRebilled": 0, "ledgerApplied": true } } } ``` _Record bodies on this page were produced by running Boardwalk's production response mapper over a documented sample project, so the field set, the labels and the empty fields are exactly what the API emits. The project itself is a sample, not a real filing._ ### Finding the ids The whole location tree is free. Nothing on `/locations/*` costs a credit, so building a local mapping of ids is a zero-cost operation you can run on every deploy. **List the states you can filter by** ```bash curl 'https://api.boardwalkai.com/api/v1/locations/states' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": [ { "id": 54, "name": "Arizona", "abbreviation": "AZ", "latitude": 34.0489, "longitude": -111.0937, "entitled": false }, { "id": 57, "name": "Colorado", "abbreviation": "CO", "latitude": 39.5501, "longitude": -105.7821, "entitled": false }, { "id": 1, "name": "Utah", "abbreviation": "UT", "latitude": 39.321, "longitude": -111.0937, "entitled": true } ], "meta": { "geographyScope": "states", "entitledStateIds": [ 1 ], "countyRestricted": false } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ **Cost:** Free — 0 credits — Free. Resolve the ids you filter with. ### The default nobody expects > **A request with no date filter searches the last 12 months** — This matches what the web product shows, and the response says so in `meta.appliedDefaults` and in a `default_date_window_applied` warning. Pass `dateRange=all` to search the entire corpus. We would rather disclose a default than have you conclude a market is thin because you did not know a window was applied. ### What it costs Adding a geography filter does not add a surcharge. You pay for the records the filtered query delivers, at 1 credit each. Narrowing the geography is therefore the cheapest way to reduce a bill. | Records delivered | Credits | | --- | --- | | 100 | 100 | | 1,000 | 1,000 | | 10,000 | 10,000 | ### Next - [Every filter, with its aliases and values](https://boardwalkai.com/docs/api/filters/) - [Price a query before running it](https://boardwalkai.com/docs/api/recipes/count-before-you-buy/) --- # Reading a record URL: https://boardwalkai.com/docs/api/recipes/reading-a-record/ A field-by-field walkthrough of one project record — including the fields that come back empty, and why. ### Who this is for Anyone mapping Boardwalk records into their own schema. Read this once and the field dictionary becomes a lookup rather than a puzzle. ### One record, in full **Your first metered call** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/search?states=UT&assetClasses=Apartment%20Building&meetingDateFrom=2026-05-01&totalUnitCountMin=50&limit=1' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": [ { "id": 184203, "recordType": "project", "projectName": "Alta Ridge Phase II", "projectHeadline": "Planning Commission approved 240 apartments with conditions", "boardwalkLink": "https://boardwalkai.com/map/#project=184203", "mergedFrom": [ 184203, 184987 ], "lastUpdated": "2026-06-18T09:22:41Z", "meetingDate": "2026-06-17", "delivery": { "alreadyExported": false, "updatedSinceExport": null, "lastExportedAt": null, "billed": true }, "address": "3600 S Constitution Blvd, West Valley City, UT", "city": "West Valley City", "county": "Salt Lake", "state": "UT", "postalCode": "84119", "latitude": 40.6916, "longitude": -112.0011, "locationPrecision": "address", "locationPrecisionLabel": "Address", "isApproximate": false, "parcelApn": [ "15-27-301-004" ], "propertyType": "residential", "propertyTypeLabel": "Residential", "propertySubtype": "multifamily", "propertySubtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "landUses": [ { "type": "residential", "typeLabel": "Residential", "subtype": "multifamily", "subtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "matchedOn": [] } ], "status": "approved_with_conditions", "statusLabel": "Approved with Conditions", "statusDetail": "The commission approved the preliminary plat subject to eight conditions.", "requestType": "final_plat", "requestTypeLabel": "Final Plat", "decisionBody": "Planning Commission", "caseNumbers": [ "PLAT-2026-0142" ], "acreage": 9.8, "squareFootage": null, "unitCount": [ { "count": 240, "type": "apartment", "typeLabel": "Apartment" } ], "totalUnitCount": 240, "bedroomCount": null, "buildingStories": null, "floorCount": null, "buildingHeightFeet": null, "parkingSpaces": 372, "existingZoning": "A-1", "proposedZoning": "RM-16", "isRezone": true, "constructionType": "new_building", "constructionTypeLabel": "New Building", "ownerType": "private", "ownerTypeLabel": "Private", "constructionDescription": "A 240-unit garden-style apartment community on 9.8 acres.", "developerCompany": "Alta Ridge Development LLC", "developerCompanyRole": "developer", "developerCompanyRoleLabel": "Developer", "contacts": [ { "name": "Rachel Okafor", "title": "Director of Development", "role": "developer", "roleLabel": "Developer", "partyClass": "external", "partyClassLabel": "External Party", "entityType": "person", "company": "Alta Ridge Development LLC", "email": "r.okafor@example.com", "emailConfidence": "high", "emailSource": "filing", "phone": "+1-555-0142", "phoneConfidence": "high", "phoneSource": "filing", "linkedinUrl": null, "linkedinConfidence": null, "linkedinSource": null, "contactProvenance": "filing", "enrichmentSource": null, "enrichedAt": null, "enrichmentStatus": "not_requested", "contactsWithheldReason": null } ], "contactEnrichment": { "status": "not_requested", "statusLabel": "Not Requested", "attemptedAt": null, "contactsFound": 0, "source": "registry" }, "companyEnrichment": [], "publicOfficials": [ { "name": "Dana Whitfield", "title": "Senior Planner", "role": "city_planner", "roleLabel": "City Planner", "partyClass": "government", "organization": "West Valley City" } ], "contactSummary": { "externalCount": 1, "governmentCount": 1, "unclassifiedCount": 0, "withEmail": 1, "withPhone": 1, "withLinkedin": 0, "enrichedCount": 0, "lowConfidenceWithheld": 0, "withheldCount": 0, "withheldReason": null }, "keyFacts": [ { "fact": "240 apartment units across six buildings", "category": "unit_mix", "categoryLabel": "Unit Mix" } ], "evidence": "Commissioner Reyes moved to approve subject to conditions 1-8.", "voteSummary": { "yes": 2, "no": 1, "abstain": 0, "absent": 1, "total": 4, "isUnanimous": false, "meetingDate": "2026-06-17" } } ], "meta": { "schemaVersion": "2026-08-13", "representation": "standard", "total": 1, "pageCount": 1, "hasMore": true, "limit": 1, "offset": 0, "creditsUsed": 1, "creditsRemaining": 299, "trialTruncation": null, "searchMethod": "redisearch", "appliedFilters": { "states": [ 1 ], "assetClasses": [ "Apartment Building" ], "meetingDateFrom": "2026-05-01T00:00:00+00:00", "totalUnitCountMin": 50 }, "appliedDefaults": [], "resolvedFrom": { "states": [ { "input": "UT", "id": 1 } ] }, "warnings": [], "matchCount": null, "matchCountIsExact": false, "matchCountBasis": "not_computed", "duplicatesCollapsed": 0, "collapseApplied": true, "delivery": { "mode": "all", "notPreviouslyExported": 1, "previouslyExported": 0, "updatedSinceExport": 0, "billed": 1, "notRebilled": 0, "ledgerApplied": true } } } ``` _Record bodies on this page were produced by running Boardwalk's production response mapper over a documented sample project, so the field set, the labels and the empty fields are exactly what the API emits. The project itself is a sample, not a real filing._ ### The five conventions | Convention | What it looks like | Why | | --- | --- | --- | | Enums ship twice | `"status": "approved_with_conditions"` beside `"statusLabel": "Approved with Conditions"` | The token is frozen and safe to branch on. The label is display copy and may be reworded. Changing a label is then not a breaking change. | | Ids are integers | `"id": 184203` | Not a numeric string. Every generated client and every MCP schema expects an integer, and mixing the two is the classic source of failed joins. | | Timestamps are ISO-8601 | `"lastUpdated": "2026-06-18T09:22:41Z"` | One format across every date-time field. `meetingDate` is a plain calendar date because a meeting happened on a day, not at an instant. | | The vocabulary is the CSV's | `acreage`, `totalUnitCount`, `existingZoning` | A column in your CSV export and a field in the API are the same concept under the same name, so reconciling an export against an API pull is a join, not a mapping exercise. | | A merged record says so | `"mergedFrom": [184203, 184987]` | Two filings for one development are collapsed into one record and billed once. `mergedFrom` is how you reconcile against ids you held before the merge. | ### Now read one we could not fully establish This is the more important example of the two. Boardwalk extracts from meeting minutes, and minutes are not uniform: some record the acreage and the asset class, some do not. When our current extraction standard produced no value for an attribute, **the field is empty**. We do not fall back to an older or superseded reading of the same document to fill it. **A record we could not fully establish** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/search?states=UT&cities=Provo&limit=1' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": [ { "id": 191774, "recordType": "project", "projectName": "Foothill Commons", "projectHeadline": "Concept plan continued to a future meeting", "boardwalkLink": "https://boardwalkai.com/map/#project=191774", "mergedFrom": [ 191774 ], "lastUpdated": "2026-06-24T14:08:03Z", "meetingDate": "2026-06-23", "delivery": { "alreadyExported": false, "updatedSinceExport": null, "lastExportedAt": null, "billed": true }, "address": null, "city": "Provo", "county": "Utah", "state": "UT", "postalCode": "84606", "latitude": 40.2338, "longitude": -111.6585, "locationPrecision": "city_centroid", "locationPrecisionLabel": "City Centroid", "isApproximate": true, "parcelApn": [], "propertyType": "residential", "propertyTypeLabel": "Residential", "propertySubtype": "multifamily", "propertySubtypeLabel": "Multifamily", "assetClass": null, "assetClassLabel": null, "landUses": [ { "type": "residential", "typeLabel": "Residential", "subtype": "multifamily", "subtypeLabel": "Multifamily", "assetClass": null, "assetClassLabel": null, "matchedOn": [] } ], "status": "continued", "statusLabel": "Continued", "statusDetail": "The item was continued; no decision was recorded.", "requestType": "concept_plan", "requestTypeLabel": "Concept Plan", "decisionBody": "Planning Commission", "caseNumbers": [], "acreage": null, "squareFootage": null, "unitCount": [], "totalUnitCount": null, "bedroomCount": null, "buildingStories": null, "floorCount": null, "buildingHeightFeet": null, "parkingSpaces": null, "existingZoning": null, "proposedZoning": null, "isRezone": null, "constructionType": null, "constructionTypeLabel": null, "ownerType": null, "ownerTypeLabel": null, "constructionDescription": null, "developerCompany": null, "developerCompanyRole": null, "developerCompanyRoleLabel": null, "contacts": [], "contactEnrichment": { "status": "not_requested", "statusLabel": "Not Requested", "attemptedAt": null, "contactsFound": 0, "source": "registry" }, "companyEnrichment": [], "publicOfficials": [], "contactSummary": { "externalCount": 0, "governmentCount": 0, "unclassifiedCount": 0, "withEmail": 0, "withPhone": 0, "withLinkedin": 0, "enrichedCount": 0, "lowConfidenceWithheld": 0, "withheldCount": 0, "withheldReason": null }, "keyFacts": [], "evidence": "The chair continued the item to a date uncertain.", "voteSummary": null } ], "meta": { "schemaVersion": "2026-08-13", "representation": "standard", "total": 1, "pageCount": 1, "hasMore": true, "limit": 1, "offset": 0, "creditsUsed": 1, "creditsRemaining": 294, "trialTruncation": null, "searchMethod": "redisearch", "appliedFilters": { "states": [ 1 ], "cities": [ 31955 ] }, "appliedDefaults": [ "dateRange=last_1_year" ], "resolvedFrom": { "states": [ { "input": "UT", "id": 1 } ], "cities": [ { "input": "Provo", "id": 31955 } ] }, "warnings": [ { "code": "default_date_window_applied", "message": "No date filter was given, so the last 12 months of meetings were searched \u2014 the same window the product shows. Pass dateRange=all to search everything.", "params": [ "meetingDateFrom" ] } ], "matchCount": null, "matchCountIsExact": false, "matchCountBasis": "not_computed", "duplicatesCollapsed": 0, "collapseApplied": true, "delivery": { "mode": "all", "notPreviouslyExported": 1, "previouslyExported": 0, "updatedSinceExport": 0, "billed": 1, "notRebilled": 0, "ledgerApplied": true } } } ``` _Record bodies on this page were produced by running Boardwalk's production response mapper over a documented sample project, so the field set, the labels and the empty fields are exactly what the API emits. The project itself is a sample, not a real filing._ Note `assetClass: null` and `acreage: null` on a record that plainly has a property type and a subtype. That is the contract working. The alternative — inferring a plausible acreage — is how an API reports 1,500 acres for a 1,500 square-foot building, and once is enough to lose the account. > **Filters fail closed on missing evidence** — After search and merge collapse, Boardwalk rechecks the returned record. If the value needed to prove a filter is missing, that record is excluded before delivery and billing. [The full explanation is here.](/docs/api/data-availability/) ### What it costs **Cost:** 1 credit per record delivered — JSON pages: charged for records actually delivered after collapse, with unchanged re-delivery free. Zero results cost nothing. `format=csv` does not use this search charge — it reserves product export credits (1 per core CSV row) and returns HTTP 202 with poll/download URLs. The record above cost 1 credit. It would have cost 1 credit in `compact`, and 1 credit in `full` with the meeting transcript attached. You are billed for the record, not the bytes. ### Next - [The field dictionary](https://boardwalkai.com/docs/api/fields/) - [Why a field can be empty](https://boardwalkai.com/docs/api/data-availability/) - [Representations](https://boardwalkai.com/docs/api/representations/) --- # The free sandbox URL: https://boardwalkai.com/docs/api/recipes/free-sandbox/ What 100 trial credits buy, which endpoints cost nothing, and the two ways a trial key behaves differently. ### Who this is for You are evaluating. You want to know whether the data is any good before anyone signs anything. ### What you get A free Boardwalk trial includes API access and a balance of **100 trial credits**, metered exactly like a paid balance: **1 credit per record delivered**. Create your key under [Settings → API keys](/profile/api) after you [sign up](/auth/signup). Trial credits do not reset monthly — they are a one-off evaluation budget. | Method | Path | Cost | Notes | | --- | --- | --- | --- | | GET | `/alerts/{alertId}/matches` | 1 credit / record | Records already delivered and unchanged since a scheduled run are re-readable here without a second charge; anything new or changed bills once. | | POST | `/alerts/{alertId}/run` | 1 credit / record | A manual run is a real delivery: records new to you or changed since their last delivery bill at the standard rate. Runs are capped per alert per day. | | GET | `/documents/{id}` | 1 credit / record | One source document, one credit. | | POST | `/projects/ai-search` | 1 credit / record | The same rate as a structured search. You pay for confirmed matches that are new to you or changed since their last delivery; rejected candidates and unchanged re-deliveries are free. | | POST | `/projects/ai-search/jobs` | 1 credit / record | Credits are reserved up front against the requested limit and settled down to confirmed records that are new to you or changed since their last delivery. Unused reservation, including unchanged re-deliveries, is refunded. | | GET | `/projects/search` | 1 credit / record | JSON pages: charged for records actually delivered after collapse, with unchanged re-delivery free. Zero results cost nothing. `format=csv` does not use this search charge — it reserves product export credits (1 per core CSV row) and returns HTTP 202 with poll/download URLs. | | GET | `/projects/sync` | 1 credit / record | Only records that are new to you, or that changed since you last received them, are billable. A sync page that returns nothing but unchanged records is free. | | GET | `/projects/{id}` | 1 credit / record | One credit when the record is new to you or changed since your last delivery; an unchanged re-read is free, just like the same record inside a search page. | | GET | `/account/credits` | Free | Free. Your balance, your plan and your rate limit. | | GET | `/alerts` | Free | Free. Configuring alerts never costs credits; only delivered records do. | | POST | `/alerts` | Free | Free. | | GET | `/alerts/{alertId}` | Free | Free. | | PATCH | `/alerts/{alertId}` | Free | Free. | | DELETE | `/alerts/{alertId}` | Free | Free. | | GET | `/alerts/{alertId}/deliveries` | Free | Free. The delivery history and what each run charged. | | GET | `/alerts/{alertId}/deliveries/{deliveryId}` | Free | Free. | | POST | `/alerts/{alertId}/dismissals/{projectId}` | Free | Free. Dismissing a match stops it being re-offered. | | DELETE | `/alerts/{alertId}/dismissals/{projectId}` | Free | Free. | | POST, GET | `/alerts/{alertId}/preview` | Free | Free. Preview returns how many records a rule would deliver and what that would cost, not the records themselves. | | POST | `/analytics/aggregate` | Free | Free. POST only (GET returns 404/405). Returns counts and group keys, never a project id, name, address, contact or document. | | GET | `/analytics/datasets` | Free | Free. GET only. What the analytics plane can and cannot answer, self-described. | | POST | `/analytics/rankings` | Free | Free. POST only (GET returns 405). Same small-group suppression floor as /aggregate. | | GET | `/exports` | Free | Free. Lists export jobs for your team (including jobs queued with format=csv on search). Credits were reserved when the job was created. | | GET | `/exports/{id}` | Free | Free. Poll job status until ready. | | GET | `/exports/{id}/download` | Free | Free re-download of a ready product-core CSV. Creating the job reserved export credits (1 per row) when you queued it. | | GET | `/health` | Free | Free and unauthenticated. | | GET | `/locations/cities/{id}` | Free | Free. | | GET | `/locations/counties/{id}` | Free | Free. | | GET | `/locations/states` | Free | Free. Resolve the ids you filter with. | | GET | `/locations/states/{id}` | Free | Free. | | GET | `/locations/states/{id}/counties` | Free | Free. | | GET | `/locations/states/{stateId}/counties/{countyId}/cities` | Free | Free. | | GET | `/projects/ai-search/jobs/{id}` | Free | Polling is free. The records the job delivers were charged when the job settled, not when you read them. | | GET | `/projects/count` | Free | Free, always, including when your balance is zero. This is the endpoint that tells you what a query would cost before you buy it. | | GET | `/reference/decision-bodies` | Free | Free. Canonical values accepted by the decisionBodies filter. | | GET | `/reference/owner-types` | Free | Free. Owner-type values accepted by ownerTypes filters. | | GET | `/reference/request-types` | Free | Free. Canonical values accepted by the requestTypes filter. | | GET | `/reference/statuses` | Free | Free. Canonical status filter tokens plus display aliases (e.g. scheduled). | | GET | `/taxonomy` | Free | Free. The complete land use taxonomy tree. | | GET | `/taxonomy/action-categories` | Free | Free. | | GET | `/taxonomy/action-types` | Free | Free. | | GET | `/taxonomy/asset-classes` | Free | Free. | | GET | `/taxonomy/project-types` | Free | Free. | | GET | `/taxonomy/subtypes` | Free | Free. | ### Evaluate without spending The whole planning surface is free, on a trial key and a paid key alike. You can establish coverage, vocabulary and result sizes for your market before spending a single credit: - `GET /locations/*` — is your market covered, and what are its ids? - `GET /taxonomy/*` — what vocabulary do we use for the thing you care about? - `GET /projects/count` — how many records match your real query, and what would they cost? - `POST /analytics/aggregate` — what does the shape of the market look like, without buying any records at all? **Narrow the query until it becomes affordable** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/count?states=UT&assetClasses=Apartment%20Building&meetingDateFrom=2026-05-01&totalUnitCountMin=50' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": { "matchCount": 250, "matchCountIsExact": true, "matchCountBasis": "enumerated", "countedAs": "distinct_projects", "collapseApplied": true, "duplicatesCollapsed": 0, "scanCeiling": 10000, "billable": { "records": 250, "credits": 250, "creditRate": 1, "isUpperBound": true, "nonBillable": { "alreadyDeliveredUnchanged": 0, "ledgerApplied": true } }, "affordability": { "affordable": true, "blockReason": null, "shortfallCredits": 0, "maxAffordableRecords": 500, "boundBy": "period_balance", "unlimited": false }, "ordering": { "sort": "meetingDate", "order": "desc", "tiebreak": null, "meaning": "Most recent meeting evidence first \u2014 the date of the meeting the record was extracted from, not when Boardwalk ingested it and not when the project was created." }, "truncation": { "policy": "refuse", "wouldTruncate": false, "deliverableRecords": null, "explanation": "A metered request for this filter set fits inside the remaining balance, so it would deliver every matching record." } }, "meta": { "schemaVersion": "2026-08-13", "representation": "standard", "deliveryMode": "all", "credits": { "limit": 500, "used": 0, "remaining": 500, "charged": 0, "periodStart": "2026-08-01", "periodEnd": "2026-08-31", "isTeamPool": true }, "appliedFilters": { "states": [ 1 ], "assetClasses": [ "Apartment Building" ], "meetingDateFrom": "2026-05-01T00:00:00+00:00", "totalUnitCountMin": 50 }, "appliedDefaults": [], "resolvedFrom": { "states": [ { "input": "UT", "id": 1 } ] }, "warnings": [] }, "links": { "self": "https://api.boardwalkai.com/api/v1/projects/count?states=UT&assetClasses=Apartment%20Building&meetingDateFrom=2026-05-01&totalUnitCountMin=50", "search": "https://api.boardwalkai.com/api/v1/projects/search?states=UT&assetClasses=Apartment%20Building&meetingDateFrom=2026-05-01&totalUnitCountMin=50", "docs": "https://boardwalkai.com/docs/api/budgets/" } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ That call cost nothing and told you the query matches 250 records. Now you know exactly what 100 trial credits will and will not cover. ### The two differences you need to know about > **A trial key truncates where a paid key refuses** — This is the single most important behavioural difference. A **paid** plan is never partially delivered: a request costing more than the balance is refused with `402` and a quote, and nothing is charged. A **trial** key does the opposite — it delivers as many records as the remaining trial balance affords, in the stated sort order, and says so in `meta.trialTruncation`. So a short page on a trial key may be your budget rather than the end of the results. Check `meta.trialTruncation` before concluding a market is small. The second difference is contact data. On a trial key, external parties' contact channels — email, phone, LinkedIn — are withheld, and the record says so rather than pretending the filing had none: **The same record on a free-trial key** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/search?states=UT&limit=1' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": [ { "id": 184203, "recordType": "project", "projectName": "Alta Ridge Phase II", "projectHeadline": "Planning Commission approved 240 apartments with conditions", "boardwalkLink": "https://boardwalkai.com/map/#project=184203", "mergedFrom": [ 184203, 184987 ], "lastUpdated": "2026-06-18T09:22:41Z", "meetingDate": "2026-06-17", "delivery": { "alreadyExported": false, "updatedSinceExport": null, "lastExportedAt": null, "billed": true }, "address": "3600 S Constitution Blvd, West Valley City, UT", "city": "West Valley City", "county": "Salt Lake", "state": "UT", "postalCode": "84119", "latitude": 40.6916, "longitude": -112.0011, "locationPrecision": "address", "locationPrecisionLabel": "Address", "isApproximate": false, "parcelApn": [ "15-27-301-004" ], "propertyType": "residential", "propertyTypeLabel": "Residential", "propertySubtype": "multifamily", "propertySubtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "landUses": [ { "type": "residential", "typeLabel": "Residential", "subtype": "multifamily", "subtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "matchedOn": [] } ], "status": "approved_with_conditions", "statusLabel": "Approved with Conditions", "statusDetail": "The commission approved the preliminary plat subject to eight conditions.", "requestType": "final_plat", "requestTypeLabel": "Final Plat", "decisionBody": "Planning Commission", "caseNumbers": [ "PLAT-2026-0142" ], "acreage": 9.8, "squareFootage": null, "unitCount": [ { "count": 240, "type": "apartment", "typeLabel": "Apartment" } ], "totalUnitCount": 240, "bedroomCount": null, "buildingStories": null, "floorCount": null, "buildingHeightFeet": null, "parkingSpaces": 372, "existingZoning": "A-1", "proposedZoning": "RM-16", "isRezone": true, "constructionType": "new_building", "constructionTypeLabel": "New Building", "ownerType": "private", "ownerTypeLabel": "Private", "constructionDescription": "A 240-unit garden-style apartment community on 9.8 acres.", "developerCompany": "Alta Ridge Development LLC", "developerCompanyRole": "developer", "developerCompanyRoleLabel": "Developer", "contacts": [ { "name": "Rachel Okafor", "title": "Director of Development", "role": "developer", "roleLabel": "Developer", "partyClass": "external", "partyClassLabel": "External Party", "entityType": "person", "company": "Alta Ridge Development LLC", "email": null, "emailConfidence": null, "emailSource": null, "phone": null, "phoneConfidence": null, "phoneSource": null, "linkedinUrl": null, "linkedinConfidence": null, "linkedinSource": null, "contactProvenance": null, "enrichmentSource": null, "enrichedAt": null, "enrichmentStatus": "not_requested", "contactsWithheldReason": "free_tier" } ], "contactEnrichment": { "status": "not_requested", "statusLabel": "Not Requested", "attemptedAt": null, "contactsFound": 0, "source": "registry" }, "companyEnrichment": [], "publicOfficials": [ { "name": "Dana Whitfield", "title": "Senior Planner", "role": "city_planner", "roleLabel": "City Planner", "partyClass": "government", "organization": "West Valley City" } ], "contactSummary": { "externalCount": 1, "governmentCount": 1, "unclassifiedCount": 0, "withEmail": 0, "withPhone": 0, "withLinkedin": 0, "enrichedCount": 0, "lowConfidenceWithheld": 0, "withheldCount": 1, "withheldReason": "free_tier" }, "keyFacts": [ { "fact": "240 apartment units across six buildings", "category": "unit_mix", "categoryLabel": "Unit Mix" } ], "evidence": "Commissioner Reyes moved to approve subject to conditions 1-8.", "voteSummary": { "yes": 2, "no": 1, "abstain": 0, "absent": 1, "total": 4, "isUnanimous": false, "meetingDate": "2026-06-17" } } ], "meta": { "schemaVersion": "2026-08-13", "representation": "standard", "total": 1, "pageCount": 1, "hasMore": true, "limit": 1, "offset": 0, "creditsUsed": 1, "creditsRemaining": 99, "trialTruncation": null, "searchMethod": "redisearch", "appliedFilters": { "states": [ 1 ] }, "appliedDefaults": [ "dateRange=last_1_year" ], "resolvedFrom": { "states": [ { "input": "UT", "id": 1 } ] }, "warnings": [ { "code": "default_date_window_applied", "message": "No date filter was given, so the last 12 months of meetings were searched \u2014 the same window the product shows. Pass dateRange=all to search everything.", "params": [ "meetingDateFrom" ] } ], "matchCount": null, "matchCountIsExact": false, "matchCountBasis": "not_computed", "duplicatesCollapsed": 0, "collapseApplied": true, "delivery": { "mode": "all", "notPreviouslyExported": 1, "previouslyExported": 0, "updatedSinceExport": 0, "billed": 1, "notRebilled": 0, "ledgerApplied": true } } } ``` _Record bodies on this page were produced by running Boardwalk's production response mapper over a documented sample project, so the field set, the labels and the empty fields are exactly what the API emits. The project itself is a sample, not a real filing._ Look at `contactSummary.withheldCount` and `withheldReason`, and at `contactsWithheldReason` on the party itself. The names, titles, roles and companies are all there — enough to judge whether the contact data is worth paying for, which is the point of an evaluation. Government staff contact details are never returned on any tier, trial or paid. ### Next - [Getting started — first call in five minutes](https://boardwalkai.com/docs/api/quickstart/) - [What happens when the balance runs out](https://boardwalkai.com/docs/api/budgets/) --- # Count before you buy URL: https://boardwalkai.com/docs/api/recipes/count-before-you-buy/ Price any query for zero credits, and see exactly what a request you cannot afford returns. ### Who this is for Everyone. This is not an advanced technique — it is how the product works, and the first thing any client, human or agent, should do with a new filter set. ### The scenario You have 500 credits left. Your criteria match 5,000 projects. What happens? ### Step 1 — ask, for free **Price a broad query — before spending anything** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/count?states=UT&assetClasses=Apartment%20Building&dateRange=all' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": { "matchCount": 5000, "matchCountIsExact": true, "matchCountBasis": "enumerated", "countedAs": "distinct_projects", "collapseApplied": true, "duplicatesCollapsed": 0, "scanCeiling": 10000, "billable": { "records": 5000, "credits": 5000, "creditRate": 1, "isUpperBound": true, "nonBillable": { "alreadyDeliveredUnchanged": 0, "ledgerApplied": true } }, "affordability": { "affordable": false, "blockReason": "insufficient_credits", "shortfallCredits": 4500, "maxAffordableRecords": 500, "boundBy": "period_balance", "unlimited": false }, "ordering": { "sort": "meetingDate", "order": "desc", "tiebreak": null, "meaning": "Most recent meeting evidence first \u2014 the date of the meeting the record was extracted from, not when Boardwalk ingested it and not when the project was created." }, "truncation": { "policy": "refuse", "wouldTruncate": false, "deliverableRecords": null, "explanation": "Paid plans are never partially delivered. A request that costs more than the balance is refused with 402 and a quote; narrow the filters or top up." } }, "meta": { "schemaVersion": "2026-08-13", "representation": "standard", "deliveryMode": "all", "credits": { "limit": 500, "used": 0, "remaining": 500, "charged": 0, "periodStart": "2026-08-01", "periodEnd": "2026-08-31", "isTeamPool": true }, "appliedFilters": { "states": [ 1 ], "assetClasses": [ "Apartment Building" ], "dateRange": "all" }, "appliedDefaults": [], "resolvedFrom": { "states": [ { "input": "UT", "id": 1 } ] }, "warnings": [] }, "links": { "self": "https://api.boardwalkai.com/api/v1/projects/count?states=UT&assetClasses=Apartment%20Building&dateRange=all", "search": "https://api.boardwalkai.com/api/v1/projects/search?states=UT&assetClasses=Apartment%20Building&dateRange=all", "docs": "https://boardwalkai.com/docs/api/budgets/" } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ Read `data.affordability`. `affordable: false`, `shortfallCredits: 4500`, `maxAffordableRecords: 500`. And read `data.truncation.policy`: `refuse`. That is the answer to "what would happen if I just called search" — you would get a `402`, not 500 of the 5,000 records. > **`billable.isUpperBound` is a one-directional promise** — The quoted cost can only come in **lower** at delivery, never higher. Duplicate filings collapse into one record, and records you already hold unchanged ship again for free — both of those only ever remove charges. That is what makes a quote safe to act on. ### Step 2 — see what refusal actually looks like If you call the metered endpoint anyway, this is the entire outcome. No records, no partial page, no charge: **What a request you cannot afford looks like** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/search?states=UT&assetClasses=Apartment%20Building&dateRange=all&limit=100' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `402`: ```json { "error": { "code": "insufficient_credits", "message": "This request would deliver 5,000 records and cost 5,000 credits. You have 500.", "param": null, "details": { "quote": { "matchCount": 5000, "matchCountIsExact": true, "creditRate": 1, "creditsRequired": 5000, "creditsRemaining": 500, "affordableRecordCount": 500, "countUrl": "https://api.boardwalkai.com/api/v1/projects/count?states=UT&assetClasses=Apartment%20Building&dateRange=all" } }, "docsUrl": "https://boardwalkai.com/docs/api/errors#insufficient_credits", "requestId": "req_01K1QF3M0000EXAMPLE0001" } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ The `countUrl` in the quote is the free URL that prices this exact filter set. An agent that receives this response can recover on its own — it does not need a human to interpret a number. ### Step 3 — narrow, and check again **Narrow the query until it becomes affordable** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/count?states=UT&assetClasses=Apartment%20Building&meetingDateFrom=2026-05-01&totalUnitCountMin=50' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": { "matchCount": 250, "matchCountIsExact": true, "matchCountBasis": "enumerated", "countedAs": "distinct_projects", "collapseApplied": true, "duplicatesCollapsed": 0, "scanCeiling": 10000, "billable": { "records": 250, "credits": 250, "creditRate": 1, "isUpperBound": true, "nonBillable": { "alreadyDeliveredUnchanged": 0, "ledgerApplied": true } }, "affordability": { "affordable": true, "blockReason": null, "shortfallCredits": 0, "maxAffordableRecords": 500, "boundBy": "period_balance", "unlimited": false }, "ordering": { "sort": "meetingDate", "order": "desc", "tiebreak": null, "meaning": "Most recent meeting evidence first \u2014 the date of the meeting the record was extracted from, not when Boardwalk ingested it and not when the project was created." }, "truncation": { "policy": "refuse", "wouldTruncate": false, "deliverableRecords": null, "explanation": "A metered request for this filter set fits inside the remaining balance, so it would deliver every matching record." } }, "meta": { "schemaVersion": "2026-08-13", "representation": "standard", "deliveryMode": "all", "credits": { "limit": 500, "used": 0, "remaining": 500, "charged": 0, "periodStart": "2026-08-01", "periodEnd": "2026-08-31", "isTeamPool": true }, "appliedFilters": { "states": [ 1 ], "assetClasses": [ "Apartment Building" ], "meetingDateFrom": "2026-05-01T00:00:00+00:00", "totalUnitCountMin": 50 }, "appliedDefaults": [], "resolvedFrom": { "states": [ { "input": "UT", "id": 1 } ] }, "warnings": [] }, "links": { "self": "https://api.boardwalkai.com/api/v1/projects/count?states=UT&assetClasses=Apartment%20Building&meetingDateFrom=2026-05-01&totalUnitCountMin=50", "search": "https://api.boardwalkai.com/api/v1/projects/search?states=UT&assetClasses=Apartment%20Building&meetingDateFrom=2026-05-01&totalUnitCountMin=50", "docs": "https://boardwalkai.com/docs/api/budgets/" } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ 250 records, 250 credits, `affordable: true`, `shortfallCredits: 0`. Now the metered call will succeed and will cost exactly what the quote said, or less. ### What it costs **Cost:** Free — 0 credits — Free, always, including when your balance is zero. This is the endpoint that tells you what a query would cost before you buy it. Counting is free even when your balance is zero. Telling a customer who has run out that they may not ask what something would cost is the opposite of the point. ### The pattern to build 1. Build the filter set. 2. `GET /projects/count` with it. Zero credits. 3. If `affordability.affordable` is false, narrow — add a date bound, a smaller geography, a minimum unit count — and count again. 4. When it is affordable, send the identical filter set to `GET /projects/search`. The `links.search` field in the count response is that exact URL, already built. ### Next - [Budgets and quotes in detail](https://boardwalkai.com/docs/api/budgets/) - [Keeping an agent inside its budget](https://boardwalkai.com/docs/api/recipes/agent-budget-safety/) --- # Alerts over the API URL: https://boardwalkai.com/docs/api/recipes/alerts-over-api/ Save a search, put it on a cadence, and receive only what is new — configuration free, delivery billed per record. ### Who this is for Lead-gen and BD teams who want a standing query rather than a polling loop, and builders tracking a competitive set. ### The model An alert is a saved filter set plus a cadence. On each run it evaluates the filters over the window since the last run and produces a **delivery** — a numbered batch of records with its own charge. You can then read that delivery's records. > **Configuration is free; delivered records are billed** — Creating, listing, updating, previewing and deleting alerts costs nothing, and so does reading the delivery history. The only thing that spends credits is a record being delivered to you, at the standard 1 credit each — and a record you have already received and which has not changed is not billed again. ### Create one **Create a daily alert — configuration is free** ```bash curl -X POST 'https://api.boardwalkai.com/api/v1/alerts' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' \ -H 'Content-Type: application/json' \ -d '{ "name": "Utah apartments, 50+ units", "filters": { "states": [ "UT" ], "assetClasses": [ "Apartment Building" ], "totalUnitCountMin": 50 }, "cadence": { "frequency": "daily", "timezone": "America/Denver", "hourLocal": 7 }, "budget": { "maxRecordsPerRun": 100 }, "notify": { "email": true, "emailTo": [ "leads@example.com" ] } }' ``` Response `201`: ```json { "data": { "id": "alr_01K1QF3M0000ALERT000001", "name": "Utah apartments, 50+ units", "status": "active", "filters": { "states": [ "UT" ], "assetClasses": [ "Apartment Building" ], "totalUnitCountMin": 50 }, "deliveryMode": "new", "cadence": { "frequency": "daily", "timezone": "America/Denver", "hourLocal": 7, "backfillDays": 7, "nextRunAt": "2026-08-03T07:00:00-06:00", "lastWindowTo": null }, "budget": { "maxRecordsPerRun": 100 }, "notify": { "email": true, "emailTo": [ "leads@example.com" ], "webhookUrl": null }, "consecutiveFailures": 0, "createdAt": "2026-08-02T14:12:00+00:00", "updatedAt": "2026-08-02T14:12:00+00:00" }, "meta": { "creditsUsed": 0 } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ **Cost:** Free — 0 credits — Free. ### See what it has delivered **What each run delivered, and what it charged** ```bash curl 'https://api.boardwalkai.com/api/v1/alerts/alr_01K1QF3M0000ALERT000001/deliveries?limit=2' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": [ { "id": "dlv_01K1QF3M0000DELIVERY002", "alertId": "alr_01K1QF3M0000ALERT000001", "sequence": 2, "status": "delivered", "window": { "from": "2026-08-01T07:00:00-06:00", "to": "2026-08-02T07:00:00-06:00" }, "counts": { "matched": 9, "delivered": 9, "billable": 7 }, "credits": { "unit": "record", "rate": 1, "charged": 7, "remaining": 286 }, "matchesUrl": "/api/v1/alerts/alr_01K1QF3M0000ALERT000001/matches?deliveryId=dlv_01K1QF3M0000DELIVERY002" } ], "meta": { "limit": 2, "offset": 0, "creditsUsed": 0 } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ Read `counts` beside `credits`: nine records matched, nine were delivered, seven were billable. The other two were records this pool already held, unchanged. The `matchesUrl` fetches the records themselves. **Cost:** Free — 0 credits — Free. The delivery history and what each run charged. **Cost:** 1 credit per record delivered — Records already delivered and unchanged since a scheduled run are re-readable here without a second charge; anything new or changed bills once. ### Cadence and budget | Setting | What it does | | --- | --- | | `cadence.frequency` | How often the alert runs. `manual`, `daily`, `weekly` or `monthly`. | | `cadence.timezone` / `hourLocal` | When in the customer's day it runs, so a "daily" alert lands before the standup rather than at 3am. | | `cadence.backfillDays` | How far back the first run looks. Useful when you want the standing query to start with recent history. | | `budget.maxRecordsPerRun` | A hard ceiling on how many records one run can deliver — and therefore on what one run can cost. | | `notify.email` / `emailTo` | Who gets told. The email is a **notification**, not the payload; the records come from the API. | Use `maxRecordsPerRun` as a spend control. It is the difference between a daily alert with a predictable ceiling and one that can empty a pool the week a large city publishes a backlog. ### Test it before you trust it `POST /alerts/{alertId}/preview` tells you how many records a rule would deliver and what that would cost, without delivering them. It is free. `POST /alerts/{alertId}/run` triggers a real run — that one delivers and bills, and is capped per alert per day. **Cost:** Free — 0 credits — Free. Preview returns how many records a rule would deliver and what that would cost, not the records themselves. **Cost:** 1 credit per record delivered — A manual run is a real delivery: records new to you or changed since their last delivery bill at the standard rate. Runs are capped per alert per day. > **Why web alerts and API alerts are priced differently** — Alert delivery inside the Boardwalk web product is included in the subscription price. Over the API, delivered records are metered at the standard rate. That is a deliberate difference between two ways of buying, not an oversight: the web product costs more and includes delivery, the API is metered and cheaper to start. Choose whichever fits how you work. ### Next - [Alerts reference](https://boardwalkai.com/docs/api/alerts/) - [Never pay twice for the same record](https://boardwalkai.com/docs/api/delivery-state/) --- # AI search, asynchronously URL: https://boardwalkai.com/docs/api/recipes/ai-search-async/ Find projects no structured filter can express — "unresolved traffic concerns" — and pay only for confirmed matches. ### Who this is for You are looking for something the taxonomy does not have a field for. "Projects where neighbours raised unresolved traffic concerns" is not a filter and never will be; it is a question about what the minutes say. ### How it works 1. You submit a natural-language query, optionally with structured filters to bound it. 2. We retrieve candidates and rank them by how well they match. 3. We read the candidates in ranked order and confirm or reject each one against your query, working through them in batches. 4. We stop as soon as a batch confirms nothing — matches concentrate at the top of the ranking, so continuing past that point spends time and money to find nothing. 5. You receive the confirmed matches, and you are charged 1 credit for each one delivered. > **You pay for confirmed matches, not for the reading** — Rejected candidates cost you nothing. We may read several thousand records to find twenty-three that genuinely match, and you are charged for twenty-three. ### Submit **Submit an AI search** ```bash curl -X POST 'https://api.boardwalkai.com/api/v1/projects/ai-search/jobs' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' \ -H 'Idempotency-Key: traffic-concerns-2026-08-02' \ -H 'Content-Type: application/json' \ -d '{ "query": "projects where neighbours raised unresolved traffic concerns", "stateIds": [ 1, 57, 54 ], "limit": 100 }' ``` Response `202`: ```json { "requestId": 918442, "status": "queued", "pollUrl": "https://api.boardwalkai.com/api/v1/projects/ai-search/jobs/918442", "meta": { "creditsReserved": 100, "limit": 100, "trialTruncation": null, "requestId": "req_01K1QF3M0000EXAMPLE0001" } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ Credits are **reserved** up front against your requested limit, and settled down to what is actually delivered. The `Idempotency-Key` matters more here than anywhere else on the surface: a duplicated submit reserves the budget twice, and two identical submits can hold the whole pool while both run. With a key, a retry replays the same job. ### Poll **Poll it — and note the charge is smaller than the reservation** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/ai-search/jobs/918442' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "requestId": 918442, "status": "completed", "creditsReserved": 100, "creditsCharged": 1, "createdAt": "2026-08-02T14:31:02+00:00", "startedAt": "2026-08-02T14:31:04+00:00", "completedAt": "2026-08-02T14:33:47+00:00", "expiresAt": "2026-08-05T14:33:47+00:00", "data": [ { "id": 184203, "recordType": "project", "projectName": "Alta Ridge Phase II", "projectHeadline": "Planning Commission approved 240 apartments with conditions", "boardwalkLink": "https://boardwalkai.com/map/#project=184203", "mergedFrom": [ 184203, 184987 ], "lastUpdated": "2026-06-18T09:22:41Z", "meetingDate": "2026-06-17", "delivery": { "alreadyExported": false, "updatedSinceExport": null, "lastExportedAt": null, "billed": true }, "address": "3600 S Constitution Blvd, West Valley City, UT", "city": "West Valley City", "county": "Salt Lake", "state": "UT", "postalCode": "84119", "latitude": 40.6916, "longitude": -112.0011, "locationPrecision": "address", "locationPrecisionLabel": "Address", "isApproximate": false, "parcelApn": [ "15-27-301-004" ], "propertyType": "residential", "propertyTypeLabel": "Residential", "propertySubtype": "multifamily", "propertySubtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "landUses": [ { "type": "residential", "typeLabel": "Residential", "subtype": "multifamily", "subtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "matchedOn": [] } ], "status": "approved_with_conditions", "statusLabel": "Approved with Conditions", "statusDetail": "The commission approved the preliminary plat subject to eight conditions.", "requestType": "final_plat", "requestTypeLabel": "Final Plat", "decisionBody": "Planning Commission", "caseNumbers": [ "PLAT-2026-0142" ], "acreage": 9.8, "squareFootage": null, "unitCount": [ { "count": 240, "type": "apartment", "typeLabel": "Apartment" } ], "totalUnitCount": 240, "bedroomCount": null, "buildingStories": null, "floorCount": null, "buildingHeightFeet": null, "parkingSpaces": 372, "existingZoning": "A-1", "proposedZoning": "RM-16", "isRezone": true, "constructionType": "new_building", "constructionTypeLabel": "New Building", "ownerType": "private", "ownerTypeLabel": "Private", "constructionDescription": "A 240-unit garden-style apartment community on 9.8 acres.", "developerCompany": "Alta Ridge Development LLC", "developerCompanyRole": "developer", "developerCompanyRoleLabel": "Developer", "contacts": [ { "name": "Rachel Okafor", "title": "Director of Development", "role": "developer", "roleLabel": "Developer", "partyClass": "external", "partyClassLabel": "External Party", "entityType": "person", "company": "Alta Ridge Development LLC", "email": "r.okafor@example.com", "emailConfidence": "high", "emailSource": "filing", "phone": "+1-555-0142", "phoneConfidence": "high", "phoneSource": "filing", "linkedinUrl": null, "linkedinConfidence": null, "linkedinSource": null, "contactProvenance": "filing", "enrichmentSource": null, "enrichedAt": null, "enrichmentStatus": "not_requested", "contactsWithheldReason": null } ], "contactEnrichment": { "status": "not_requested", "statusLabel": "Not Requested", "attemptedAt": null, "contactsFound": 0, "source": "registry" }, "companyEnrichment": [], "publicOfficials": [ { "name": "Dana Whitfield", "title": "Senior Planner", "role": "city_planner", "roleLabel": "City Planner", "partyClass": "government", "organization": "West Valley City" } ], "contactSummary": { "externalCount": 1, "governmentCount": 1, "unclassifiedCount": 0, "withEmail": 1, "withPhone": 1, "withLinkedin": 0, "enrichedCount": 0, "lowConfidenceWithheld": 0, "withheldCount": 0, "withheldReason": null }, "keyFacts": [ { "fact": "240 apartment units across six buildings", "category": "unit_mix", "categoryLabel": "Unit Mix" } ], "evidence": "Commissioner Reyes moved to approve subject to conditions 1-8.", "voteSummary": { "yes": 2, "no": 1, "abstain": 0, "absent": 1, "total": 4, "isUnanimous": false, "meetingDate": "2026-06-17" }, "aiMatch": { "score": 10, "summary": "The minutes record unresolved traffic and circulation concerns raised by nearby residents.", "channels": [ "semantic", "keyword" ] } } ], "meta": { "schemaVersion": "2026-08-13", "representation": "standard", "total": 3000, "limit": 100, "offset": 0, "creditsUsed": 1, "creditsReserved": 100, "trialTruncation": null, "parsedQuery": { "structuredFilters": { "stateIds": [ 1, 57, 54 ] }, "searchKeywords": "traffic concerns neighbours", "semanticQuery": "projects where neighbours raised unresolved traffic concerns", "explanation": "Looks for public-comment evidence of unresolved traffic concerns.", "confidence": 0.92, "query_type": "evidence_search" }, "searchMethod": "ai_evidence_verified", "matchesByProjectId": { "184203": { "confirmed": true, "evidenceWhy": "The minutes record unresolved traffic and circulation concerns raised by nearby residents.", "evidenceQuotes": [ "Residents raised concerns about traffic, access, and circulation." ], "retrievalScore": 0.08125, "channels": [ "semantic", "keyword" ], "channelCount": 2 } }, "lowConfidence": false, "candidatesRetrieved": 3000, "candidatesScreened": 2000, "batchesRun": 2, "screeningCeiling": 10000, "screeningCeilingSource": "request_cap", "batchSizeYields": [ 1, 0 ], "stopReason": "unproductive_batch", "partialReason": null, "matchCount": 1, "matchCountIsExact": false, "reusedVerdictCount": 0, "verifierErrorCount": 0, "delivery": { "mode": "all", "notPreviouslyExported": 1, "previouslyExported": 0, "updatedSinceExport": 0, "billed": 1, "notRebilled": 0, "ledgerApplied": true }, "requestId": "req_01K1QF3M0000EXAMPLE0001" }, "stopReason": "unproductive_batch", "partialReason": null } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ Reserved 100, charged 1. **This is the normal case, not an exception.** The quote you get before an AI search is a ceiling, not a prediction: early stop means most requests confirm far fewer records than the limit they reserved against, and you are refunded the difference. **Cost:** Free — 0 credits — Polling is free. The records the job delivers were charged when the job settled, not when you read them. ### The ceilings | Ceiling | Value | What it means for you | | --- | --- | --- | | Candidates reviewed | Up to 10,000 per request | A hard stop on how much reading one request can do. Reaching it is reported, not hidden. | | Confirmed matches returned | Up to 100 per request | The largest result set one AI search can deliver — and, at 1 credit each, its largest possible cost. | | Per-account daily budget | Enforced, value not published | There is a per-account daily ceiling on screening work. A job may finish `partial` with `partialReason: daily_ai_budget_exhausted`; confirmed matches still ship and unused reserved credits are refunded. | > **100 records is 100 credits** — A single AI search returning the maximum result set can use 20% of a State plan's 500-record monthly allowance. Set `limit` to what you will actually use, and price the structured part of the query with `/projects/count` first. ### What it costs **Cost:** 1 credit per record delivered — Credits are reserved up front against the requested limit and settled down to confirmed records that are new to you or changed since their last delivery. Unused reservation, including unchanged re-deliveries, is refunded. | Confirmed matches delivered | Credits | | --- | --- | | 0 | 0 | | 23 | 23 | | 100 | 100 | ### What can go wrong - `402 insufficient_credits` — the reservation does not fit in the balance. Lower `limit`, or top up. Nothing was reserved. - A job that ends `partial` delivered real, billed, verified records and stopped early for a stated reason in `stopReason` / `partialReason`. It is not a failure and the records are yours. - A job that ends `expired` means the result was not collected in time. Submit a new search. ### Next - [Adding contact enrichment to the same job](https://boardwalkai.com/docs/api/recipes/contact-enrichment/) - [Idempotency](https://boardwalkai.com/docs/api/idempotency/) --- # Contact enrichment URL: https://boardwalkai.com/docs/api/recipes/contact-enrichment/ Ask for contacts alongside your records. Same 1 credit per record, whether we found a contact or not. ### Who this is for Lead-gen teams who need a person to contact, not just a project to read about. ### The pricing rule, first, because it is the question everyone asks > **Enrichment is inside the 1 credit** — A record costs 1 credit whether you asked for contacts or not, whether we enriched it just now, whether we already knew the contact from an earlier lookup, and whether we found nothing at all. If you request 50 records with enrichment on and we find contacts for 10 of them, you receive all 50 and you pay 50 credits. There is no enrichment surcharge and no per-contact price. ### How to ask Enrichment is a **per-request toggle**, not an account setting. Add `include=contacts` to a request that can run asynchronously. It calls out to third-party sources and cannot complete inside a synchronous request, so a synchronous route returns `400 enrichment_requires_async` and names the endpoint to use. **The same search, with contact enrichment switched on** ```bash curl -X POST 'https://api.boardwalkai.com/api/v1/projects/ai-search/jobs' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' \ -H 'Content-Type: application/json' \ -d '{ "query": "multifamily projects that were continued for parking concerns", "stateIds": [ 1 ], "limit": 50, "include": "contacts", "contactOptions": { "minConfidence": "high" } }' ``` Response `202`: ```json { "requestId": 918509, "status": "queued", "pollUrl": "https://api.boardwalkai.com/api/v1/projects/ai-search/jobs/918509", "meta": { "creditsReserved": 50, "limit": 50, "trialTruncation": null, "requestId": "req_01K1QF3M0000EXAMPLE0001" } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ | Option | Values | Effect | | --- | --- | --- | | `contactOptions.minConfidence` | `high`, `medium` | The floor for a contact to ship. Defaults to `medium`. There is no `low` — a contact we are not reasonably confident about is withheld rather than labelled, and `contactSummary.lowConfidenceWithheld` counts them. | | `contactOptions.cachedOnly` | boolean | Only return contacts we already hold. No new lookups. | | `contactOptions.companyOnly` | boolean | Company-level contacts only. | | `contactOptions.maxProjects` | positive integer | Cap how many of the returned projects are enriched. | An unknown key or an unknown value is a `400` that costs nothing, before anything runs. Sending `cachedonly` instead of `cachedOnly` fails loudly rather than quietly running the most expensive path on the platform. ### Reading the result Every record carries a `contactEnrichment` block whether or not you asked for enrichment, so clients branch on its value rather than its existence: | `contactEnrichment.status` | Meaning | | --- | --- | | `not_requested` | You did not ask. The `contacts` array still carries anyone named on the filing itself. | | `pending` | Requested, not finished. | | `completed` | We looked. `contactsFound` says what we found — including zero. | | `failed` | The lookup could not be completed. You were still charged 1 credit for the record, which you received. | Contacts carry their own provenance. `contactProvenance: "filing"` means the person was named in the public record itself; an enriched channel names its source and the time it was established, and each channel carries its own confidence. ### The rules that do not bend - **Government staff contact details are never returned, on any tier.** Planners, clerks and commissioners appear in `publicOfficials` with their name, title and organisation, and no contact channel. This is not a plan limitation and there is no tier that unlocks it. - **Free-trial keys do not receive external contact channels.** Names, titles, roles and companies still ship, with `contactsWithheldReason` stating why the channel is absent. - **Suppressed contacts are removed everywhere.** Anyone can ask us to stop distributing their contact details, and once suppressed they do not ship to anyone. > **What we will not promise about enrichment** — We do not promise nationwide contact coverage — enrichment coverage is narrower than our project coverage and we would rather you found that out here than after a purchase. We also make **no freshness guarantee**: a contact we established earlier is served at the same price and carries the age of that original lookup, which we do not currently expose. If recency is critical to your workflow, verify before you use it. ### Next - [Contacts reference](https://boardwalkai.com/docs/api/contacts/) - [Ask us to remove your details](https://boardwalkai.com/privacy/contact-opt-out/) --- # Net-new versus updated records URL: https://boardwalkai.com/docs/api/recipes/net-new-leads/ Pull only what you have not seen, and never pay twice for a record that has not changed. ### Who this is for Anyone running Boardwalk on a schedule. The question is always the same: which of these did I already have, and am I paying for them again? ### The answer is on every record Delivery state is not a mode you switch on — it is a block on every record we send you, on the API and in the CSV export alike: | Field | Meaning | | --- | --- | | `delivery.alreadyExported` | Has this pool received this record before, through any surface? | | `delivery.updatedSinceExport` | Has it changed since then? `null` on a record you have never received. | | `delivery.lastExportedAt` | When you last received it. | | `delivery.billed` | Whether **this** delivery charged you for it. | > **An unchanged record you already hold is delivered again for free** — You are never charged twice for the same unchanged record, on any endpoint. That is why re-running a query is cheap and why the count endpoint reports `billable.records` separately from `matchCount` — the two are different numbers and the smaller one is what you pay. ### The scheduled pull For a mirror you keep in sync, use the sync feed rather than paging search. It walks by cursor in last-updated order, which is what makes it lossless: records that change while you are walking cannot slip past you the way they can with offset paging. **One net-new record and one that changed since you last saw it** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/sync?states=UT&representation=compact&limit=2' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": [ { "id": 184203, "recordType": "project", "projectName": "Alta Ridge Phase II", "boardwalkLink": "https://boardwalkai.com/map/#project=184203", "mergedFrom": [ 184203, 184987 ], "lastUpdated": "2026-06-18T09:22:41Z", "meetingDate": "2026-06-17", "delivery": { "alreadyExported": false, "updatedSinceExport": null, "lastExportedAt": null, "billed": true }, "address": "3600 S Constitution Blvd, West Valley City, UT", "city": "West Valley City", "county": "Salt Lake", "state": "UT", "latitude": 40.6916, "longitude": -112.0011, "propertyType": "residential", "propertyTypeLabel": "Residential", "landUses": [ { "type": "residential", "typeLabel": "Residential", "subtype": "multifamily", "subtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "matchedOn": [] } ], "status": "approved_with_conditions", "statusLabel": "Approved with Conditions" }, { "id": 191774, "recordType": "project", "projectName": "Foothill Commons", "boardwalkLink": "https://boardwalkai.com/map/#project=191774", "mergedFrom": [ 191774 ], "lastUpdated": "2026-06-24T14:08:03Z", "meetingDate": "2026-06-23", "delivery": { "alreadyExported": true, "updatedSinceExport": true, "lastExportedAt": "2026-07-19T02:14:55Z", "billed": true }, "address": null, "city": "Provo", "county": "Utah", "state": "UT", "latitude": 40.2338, "longitude": -111.6585, "propertyType": "residential", "propertyTypeLabel": "Residential", "landUses": [ { "type": "residential", "typeLabel": "Residential", "subtype": "multifamily", "subtypeLabel": "Multifamily", "assetClass": null, "assetClassLabel": null, "matchedOn": [] } ], "status": "continued", "statusLabel": "Continued" } ], "meta": { "schemaVersion": "2026-08-13", "representation": "compact", "limit": 2, "creditsUsed": 2, "creditsRemaining": 284, "delivery": { "mode": "all", "notPreviouslyExported": 1, "previouslyExported": 1, "updatedSinceExport": 1, "billed": 2, "notRebilled": 0, "ledgerApplied": true }, "cursor": "eyJ1cGRhdGVkQXQiOiIyMDI2LTA4LTAyVDE0OjA5OjIyWiIsImlkIjoxOTE3NzR9", "hasMore": true } } ``` _Record bodies on this page were produced by running Boardwalk's production response mapper over a documented sample project, so the field set, the labels and the empty fields are exactly what the API emits. The project itself is a sample, not a real filing._ Two records here tell the whole story. The first has never been delivered to you — `alreadyExported: false`, `billed: true`. The second you received on 19 July and it has changed since — `alreadyExported: true`, `updatedSinceExport: true`, `billed: true`. A third record that you held and which had **not** changed would come back with `billed: false` and cost nothing. `meta.delivery` totals it for the page: how many were new to you, how many you had seen, how many changed, how many were billed, and how many were delivered without a charge. **Cost:** 1 credit per record delivered — Only records that are new to you, or that changed since you last received them, are billable. A sync page that returns nothing but unchanged records is free. ### How it scales | Run | Records returned | Billable | Credits | | --- | --- | --- | --- | | First full walk of your market | 2,000 | 2,000 | 2,000 | | Next morning | 2,000 matched, 34 changed or new | 34 | 34 | | Morning after that, nothing moved | 2,000 matched, 0 changed | 0 | 0 | This is the shape of a steady-state integration: an expensive first pull and a near-free daily delta. Budget for the first walk, not for the cadence. ### What can go wrong - `400 invalid_cursor` — the cursor was minted for a different filter set or a different entitlement scope. Repeat the request without the cursor; restarting is cheap because records you already hold are not re-billed. - `sort`, `order`, `offset` and `page` are refused on the sync feed. A caller-chosen ordering would break the losslessness guarantee while appearing to work, which is the worst way for a guarantee to fail. Use `/projects/search` when you want your own ordering. ### Next - [Delivery state in detail](https://boardwalkai.com/docs/api/delivery-state/) - [Or put it on a cadence with alerts](https://boardwalkai.com/docs/api/recipes/alerts-over-api/) --- # Keeping an agent inside its budget URL: https://boardwalkai.com/docs/api/recipes/agent-budget-safety/ The pattern that stops one ambitious prompt spending a month of credits. ### The failure this prevents A user asks an agent for "every apartment project in the thousand largest cities". A naive agent turns that into a thousand metered calls. The API will refuse before it overspends — a paid plan is never partially delivered, so the agent gets a `402` rather than a surprise invoice — but a thousand `402`s is a bad afternoon for everyone. ### The pattern 1. The agent's **first** call for any new task is `GET /projects/count`. It is free, it works at a zero balance, and it returns everything needed to decide: `matchCount`, `billable.credits`, `affordability.affordable` and `affordability.maxAffordableRecords`. 2. If `affordable` is false, the agent narrows and counts again rather than attempting the call. Narrowing is free; attempting is a rate-limit slot. 3. When the agent does spend, it reads `X-Credits-Remaining` off the response rather than maintaining its own counter, because a teammate or another agent may be spending the same pool. 4. On a `402`, the agent follows `error.details.quote.countUrl` — a free URL that prices the exact filter set that just failed — instead of retrying blindly. 5. For questions about shape rather than records, the agent uses the analytics plane, which is free. **Price a broad query — before spending anything** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/count?states=UT&assetClasses=Apartment%20Building&dateRange=all' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": { "matchCount": 5000, "matchCountIsExact": true, "matchCountBasis": "enumerated", "countedAs": "distinct_projects", "collapseApplied": true, "duplicatesCollapsed": 0, "scanCeiling": 10000, "billable": { "records": 5000, "credits": 5000, "creditRate": 1, "isUpperBound": true, "nonBillable": { "alreadyDeliveredUnchanged": 0, "ledgerApplied": true } }, "affordability": { "affordable": false, "blockReason": "insufficient_credits", "shortfallCredits": 4500, "maxAffordableRecords": 500, "boundBy": "period_balance", "unlimited": false }, "ordering": { "sort": "meetingDate", "order": "desc", "tiebreak": null, "meaning": "Most recent meeting evidence first \u2014 the date of the meeting the record was extracted from, not when Boardwalk ingested it and not when the project was created." }, "truncation": { "policy": "refuse", "wouldTruncate": false, "deliverableRecords": null, "explanation": "Paid plans are never partially delivered. A request that costs more than the balance is refused with 402 and a quote; narrow the filters or top up." } }, "meta": { "schemaVersion": "2026-08-13", "representation": "standard", "deliveryMode": "all", "credits": { "limit": 500, "used": 0, "remaining": 500, "charged": 0, "periodStart": "2026-08-01", "periodEnd": "2026-08-31", "isTeamPool": true }, "appliedFilters": { "states": [ 1 ], "assetClasses": [ "Apartment Building" ], "dateRange": "all" }, "appliedDefaults": [], "resolvedFrom": { "states": [ { "input": "UT", "id": 1 } ] }, "warnings": [] }, "links": { "self": "https://api.boardwalkai.com/api/v1/projects/count?states=UT&assetClasses=Apartment%20Building&dateRange=all", "search": "https://api.boardwalkai.com/api/v1/projects/search?states=UT&assetClasses=Apartment%20Building&dateRange=all", "docs": "https://boardwalkai.com/docs/api/budgets/" } } ``` _The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus._ ### Rules of thumb for tool descriptions - Tell the model that counting is free and searching is not. Models respect a stated cost asymmetry. - Give the model `representation: "compact"` as its default. It is the same price and roughly a fifth of the fields, which matters for context, not for cost. - Never let a model choose a `limit` above what one task needs. The page ceiling is 100 records; a loop over pages should be a deliberate decision, not a default. - Surface `meta.warnings` to the model. They disclose applied defaults, trial truncation and other conditions that can change how a result should be read. ### Costs at a glance | Method | Path | Cost | Notes | | --- | --- | --- | --- | | GET | `/alerts/{alertId}/matches` | 1 credit / record | Records already delivered and unchanged since a scheduled run are re-readable here without a second charge; anything new or changed bills once. | | POST | `/alerts/{alertId}/run` | 1 credit / record | A manual run is a real delivery: records new to you or changed since their last delivery bill at the standard rate. Runs are capped per alert per day. | | GET | `/documents/{id}` | 1 credit / record | One source document, one credit. | | POST | `/projects/ai-search` | 1 credit / record | The same rate as a structured search. You pay for confirmed matches that are new to you or changed since their last delivery; rejected candidates and unchanged re-deliveries are free. | | POST | `/projects/ai-search/jobs` | 1 credit / record | Credits are reserved up front against the requested limit and settled down to confirmed records that are new to you or changed since their last delivery. Unused reservation, including unchanged re-deliveries, is refunded. | | GET | `/projects/search` | 1 credit / record | JSON pages: charged for records actually delivered after collapse, with unchanged re-delivery free. Zero results cost nothing. `format=csv` does not use this search charge — it reserves product export credits (1 per core CSV row) and returns HTTP 202 with poll/download URLs. | | GET | `/projects/sync` | 1 credit / record | Only records that are new to you, or that changed since you last received them, are billable. A sync page that returns nothing but unchanged records is free. | | GET | `/projects/{id}` | 1 credit / record | One credit when the record is new to you or changed since your last delivery; an unchanged re-read is free, just like the same record inside a search page. | | GET | `/account/credits` | Free | Free. Your balance, your plan and your rate limit. | | GET | `/alerts` | Free | Free. Configuring alerts never costs credits; only delivered records do. | | POST | `/alerts` | Free | Free. | | GET | `/alerts/{alertId}` | Free | Free. | | PATCH | `/alerts/{alertId}` | Free | Free. | | DELETE | `/alerts/{alertId}` | Free | Free. | | GET | `/alerts/{alertId}/deliveries` | Free | Free. The delivery history and what each run charged. | | GET | `/alerts/{alertId}/deliveries/{deliveryId}` | Free | Free. | | POST | `/alerts/{alertId}/dismissals/{projectId}` | Free | Free. Dismissing a match stops it being re-offered. | | DELETE | `/alerts/{alertId}/dismissals/{projectId}` | Free | Free. | | POST, GET | `/alerts/{alertId}/preview` | Free | Free. Preview returns how many records a rule would deliver and what that would cost, not the records themselves. | | POST | `/analytics/aggregate` | Free | Free. POST only (GET returns 404/405). Returns counts and group keys, never a project id, name, address, contact or document. | | GET | `/analytics/datasets` | Free | Free. GET only. What the analytics plane can and cannot answer, self-described. | | POST | `/analytics/rankings` | Free | Free. POST only (GET returns 405). Same small-group suppression floor as /aggregate. | | GET | `/exports` | Free | Free. Lists export jobs for your team (including jobs queued with format=csv on search). Credits were reserved when the job was created. | | GET | `/exports/{id}` | Free | Free. Poll job status until ready. | | GET | `/exports/{id}/download` | Free | Free re-download of a ready product-core CSV. Creating the job reserved export credits (1 per row) when you queued it. | | GET | `/health` | Free | Free and unauthenticated. | | GET | `/locations/cities/{id}` | Free | Free. | | GET | `/locations/counties/{id}` | Free | Free. | | GET | `/locations/states` | Free | Free. Resolve the ids you filter with. | | GET | `/locations/states/{id}` | Free | Free. | | GET | `/locations/states/{id}/counties` | Free | Free. | | GET | `/locations/states/{stateId}/counties/{countyId}/cities` | Free | Free. | | GET | `/projects/ai-search/jobs/{id}` | Free | Polling is free. The records the job delivers were charged when the job settled, not when you read them. | | GET | `/projects/count` | Free | Free, always, including when your balance is zero. This is the endpoint that tells you what a query would cost before you buy it. | | GET | `/reference/decision-bodies` | Free | Free. Canonical values accepted by the decisionBodies filter. | | GET | `/reference/owner-types` | Free | Free. Owner-type values accepted by ownerTypes filters. | | GET | `/reference/request-types` | Free | Free. Canonical values accepted by the requestTypes filter. | | GET | `/reference/statuses` | Free | Free. Canonical status filter tokens plus display aliases (e.g. scheduled). | | GET | `/taxonomy` | Free | Free. The complete land use taxonomy tree. | | GET | `/taxonomy/action-categories` | Free | Free. | | GET | `/taxonomy/action-types` | Free | Free. | | GET | `/taxonomy/asset-classes` | Free | Free. | | GET | `/taxonomy/project-types` | Free | Free. | | GET | `/taxonomy/subtypes` | Free | Free. | ### Next - [Install the MCP server](https://boardwalkai.com/docs/mcp/install/) - [Rate limits and backoff](https://boardwalkai.com/docs/api/rate-limits/) --- # Analysis without buying records URL: https://boardwalkai.com/docs/api/recipes/council-vote-analysis/ Where is apartment development concentrated? Answer it with aggregates, for zero credits, and buy only the records you act on. ### Who this is for Analysts and agents answering questions about a market rather than assembling a list from it. The boundary is simple: **insight is free, records cost credits.** ### Ask the question **Ask where apartment development concentrates — for zero credits** ```bash curl -X POST 'https://api.boardwalkai.com/api/v1/analytics/aggregate' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' \ -H 'Content-Type: application/json' \ -d '{ "dataset": "projects", "groupBy": [ "city" ], "metrics": [ "count", "unitCountSum" ], "filters": { "states": [ "UT" ], "assetClasses": [ "Apartment Building" ] } }' ``` Response `200`: ```json { "data": { "dataset": "projects", "groupBy": [ "city" ], "groups": [ { "keys": { "city": "Salt Lake City" }, "metrics": { "count": 142, "unitCountSum": 18240 }, "coverage": { "count": 1, "unitCountSum": 0.9718 } }, { "keys": { "city": "Provo" }, "metrics": { "count": 64, "unitCountSum": 7112 }, "coverage": { "count": 1, "unitCountSum": 0.9531 } } ], "totals": { "count": 412, "unitCountSum": 51230 }, "suppressed": { "groups": 3, "records": 9, "floorK": 5 } }, "meta": { "schemaVersion": "2026-08-13", "suppressionFloorK": 5, "credits": { "charged": 0 }, "requestId": "req_01K1QF3M0000EXAMPLE0001" } } ``` _We were not able to execute this call while writing the page, so treat the body as indicative of the shape rather than as a captured response. If it disagrees with what you receive, what you receive is correct — please tell us._ **Cost:** Free — 0 credits — Free. POST only (GET returns 404/405). Returns counts and group keys, never a project id, name, address, contact or document. The analytics plane returns counts and group keys. It never returns a project id, name, address, contact or document — that is what makes it safe to give away, and it is enforced at runtime rather than intended. ### Start by asking what it can answer `GET /analytics/datasets` describes itself: which fields you can group by, which metrics exist, which ones can be ranked, the ceilings, and — usefully — a `notAnswerable` list, so you do not spend a rate-limit slot rediscovering a question we cannot answer. **Cost:** Free — 0 credits — Free. GET only. What the analytics plane can and cannot answer, self-described. ### Small groups are suppressed > **Groups below the floor are counted, not listed** — A group with fewer members than the suppression floor is not returned as its own row; `suppressedGroups` tells you how many were withheld. This stops the aggregate plane being used to reconstruct individual records for free, and it stops a leaderboard whose top row has a denominator of one. For rankings specifically, use `/analytics/rankings` rather than assembling one from `/aggregate`. A ranking carries a minimum denominator and a comparison baseline; a hand-rolled leaderboard has neither, and the single most common way a leaderboard lies is a top row with one observation behind it. **Cost:** Free — 0 credits — Free. POST only (GET returns 405). Same small-group suppression floor as /aggregate. ### Then buy only what you act on 1. Aggregate to find the three cities worth attention. Free. 2. Count the records in those three cities. Free. 3. Search — and pay for — only those. 1 credit each. For a market-shape question this is often the difference between zero credits and ten thousand. ### Named votes When you do buy records, `voteSummary` gives you the tally on the most recent decision, and the `full` representation carries the individual named votes under `decisions`. That is where "who voted against this" is answered. **One record in `full`, including decisions, documents and `rawText`** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/184203?representation=full' \ -H 'Authorization: Bearer bwk_live_YOUR_KEY' ``` Response `200`: ```json { "data": { "id": 184203, "recordType": "project", "projectName": "Alta Ridge Phase II", "projectHeadline": "Planning Commission approved 240 apartments with conditions", "boardwalkLink": "https://boardwalkai.com/map/#project=184203", "mergedFrom": [ 184203, 184987 ], "lastUpdated": "2026-06-18T09:22:41Z", "meetingDate": "2026-06-17", "delivery": { "alreadyExported": false, "updatedSinceExport": null, "lastExportedAt": null, "billed": true }, "address": "3600 S Constitution Blvd, West Valley City, UT", "city": "West Valley City", "county": "Salt Lake", "state": "UT", "postalCode": "84119", "latitude": 40.6916, "longitude": -112.0011, "locationPrecision": "address", "locationPrecisionLabel": "Address", "isApproximate": false, "parcelApn": [ "15-27-301-004" ], "stateId": 1, "countyId": 2917, "cityId": 31688, "additionalAddresses": [], "propertyType": "residential", "propertyTypeLabel": "Residential", "propertySubtype": "multifamily", "propertySubtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "landUses": [ { "type": "residential", "typeLabel": "Residential", "subtype": "multifamily", "subtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "matchedOn": [] } ], "classifications": [ { "role": "primary", "type": "residential", "typeLabel": "Residential", "subtype": "multifamily", "subtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building" } ], "tags": [ "multifamily", "phase-ii" ], "status": "approved_with_conditions", "statusLabel": "Approved with Conditions", "statusDetail": "The commission approved the preliminary plat subject to eight conditions.", "requestType": "final_plat", "requestTypeLabel": "Final Plat", "decisionBody": "Planning Commission", "caseNumbers": [ "PLAT-2026-0142" ], "isAppeal": false, "nextHearingDate": "2026-07-15", "acreage": 9.8, "squareFootage": null, "unitCount": [ { "count": 240, "type": "apartment", "typeLabel": "Apartment" } ], "totalUnitCount": 240, "affordableUnits": null, "bedroomCount": null, "lotCount": null, "buildingCount": 6, "buildingStories": null, "floorCount": null, "buildingHeightFeet": null, "parkingSpaces": 372, "densityPerAcre": 24.5, "amenities": [ "Pool", "Clubhouse", "Dog run" ], "existingZoning": "A-1", "proposedZoning": "RM-16", "isRezone": true, "variancesRequested": [ "setback" ], "constructionType": "new_building", "constructionTypeLabel": "New Building", "ownerType": "private", "ownerTypeLabel": "Private", "constructionDescription": "A 240-unit garden-style apartment community on 9.8 acres.", "likelyTrades": [ { "trade": "site_work", "tradeLabel": "Site Work", "basis": "Grading plan referenced" } ], "estimateEligible": true, "developerCompany": "Alta Ridge Development LLC", "developerCompanyRole": "developer", "developerCompanyRoleLabel": "Developer", "contacts": [ { "name": "Rachel Okafor", "title": "Director of Development", "role": "developer", "roleLabel": "Developer", "partyClass": "external", "partyClassLabel": "External Party", "entityType": "person", "company": "Alta Ridge Development LLC", "email": "r.okafor@example.com", "emailConfidence": "high", "emailSource": "filing", "phone": "+1-555-0142", "phoneConfidence": "high", "phoneSource": "filing", "linkedinUrl": null, "linkedinConfidence": null, "linkedinSource": null, "contactProvenance": "filing", "enrichmentSource": null, "enrichedAt": null, "enrichmentStatus": "not_requested", "contactsWithheldReason": null } ], "contactEnrichment": { "status": "not_requested", "statusLabel": "Not Requested", "attemptedAt": null, "contactsFound": 0, "source": "registry" }, "companyEnrichment": [], "publicOfficials": [ { "name": "Dana Whitfield", "title": "Senior Planner", "role": "city_planner", "roleLabel": "City Planner", "partyClass": "government", "organization": "West Valley City" } ], "contactSummary": { "externalCount": 1, "governmentCount": 1, "unclassifiedCount": 0, "withEmail": 1, "withPhone": 1, "withLinkedin": 0, "enrichedCount": 0, "lowConfidenceWithheld": 0, "withheldCount": 0, "withheldReason": null }, "keyFacts": [ { "fact": "240 apartment units across six buildings", "category": "unit_mix", "categoryLabel": "Unit Mix" } ], "evidence": "Commissioner Reyes moved to approve subject to conditions 1-8.", "conditionsOfApproval": [ "1. Landscaping per the approved plan." ], "summary": "A 240-unit garden-style apartment community on 9.8 acres, approved with conditions.", "extendedSummary": "The applicant sought preliminary plat approval for a 240-unit garden-style apartment community across six four-storey buildings, with 372 parking stalls.", "decisions": [ { "meetingDate": "2026-06-17", "decisionBody": "Planning Commission", "actionType": "preliminary_plat", "actionTypeLabel": "Preliminary Plat", "requestType": "final_plat", "requestTypeLabel": "Final Plat", "outcome": "approved_with_conditions", "outcomeLabel": "Approved with Conditions", "motionType": "approve_with_conditions", "motionOutcome": "passed", "evidenceBasis": null, "evidenceBasisLabel": null, "isConsentAgenda": null, "caseNumber": "PLAT-2026-0142", "evidence": "Commissioner Reyes moved to approve subject to conditions 1-8.", "conditions": "Landscaping per the approved plan.", "voteTally": { "yes": 2, "no": 1, "abstain": 0, "absent": 1, "total": 4, "isUnanimous": false }, "votes": [ { "name": "Maria Reyes", "role": "Commissioner", "vote": "yes", "voteLabel": "Yes" }, { "name": "Tom Alder", "role": "Commissioner", "vote": "yes", "voteLabel": "Yes" }, { "name": "Priya Raman", "role": "Commissioner", "vote": "no", "voteLabel": "No" }, { "name": "Ben Cole", "role": "Commissioner", "vote": "absent", "voteLabel": "Absent" } ] } ], "voteSummary": { "yes": 2, "no": 1, "abstain": 0, "absent": 1, "total": 4, "isUnanimous": false, "meetingDate": "2026-06-17" }, "documents": [ { "meetingDocumentId": 902551, "title": "Planning Commission Minutes", "meetingDate": "2026-06-17", "documentType": "meeting_minutes", "documentTypeLabel": "Meeting Minutes", "rawText": "PLANNING COMMISSION MINUTES \u2014 June 17, 2026\n\nITEM 4. PLAT-2026-0142 \u2014 Alta Ridge Phase II, preliminary plat, 3600 S Constitution Blvd.\nStaff presented the report and recommended approval subject to eight conditions. Commissioner Reyes moved to approve subject to conditions 1-8. Motion carried 2-1, Commissioner Cole absent.\n\n[This excerpt is shortened for the documentation. A live response carries the complete document text.]", "rawTextTruncated": false } ] }, "meta": { "schemaVersion": "2026-08-13", "representation": "full", "creditsUsed": 1, "creditsRemaining": 295 } } ``` _Record bodies on this page were produced by running Boardwalk's production response mapper over a documented sample project, so the field set, the labels and the empty fields are exactly what the API emits. The project itself is a sample, not a real filing._ ### Next - [The analytics plane](https://boardwalkai.com/docs/api/analytics/) - [Keeping an agent inside its budget](https://boardwalkai.com/docs/api/recipes/agent-budget-safety/) --- # Boardwalk over MCP URL: https://boardwalkai.com/docs/mcp/ Query Boardwalk from Claude, Cursor, or any MCP client — with a free planning surface an agent can use to bound its own spend. The Boardwalk MCP server puts the same data behind the same meter into any MCP client. Every tool calls the public API, so entitlement, geography and the credit meter behave identically whether a human or a model made the call. - [Install it](https://boardwalkai.com/docs/mcp/install/) - [Every tool and what it costs](https://boardwalkai.com/docs/mcp/tools/) - [Keeping an agent inside its budget](https://boardwalkai.com/docs/api/recipes/agent-budget-safety/) ### The design, in one idea > **Eight of the twelve tools are free** — Describing the dataset, listing locations, reading the taxonomy, counting, aggregating, ranking, checking the balance and collecting a finished search all cost nothing. Only four tools spend credits, and each of them names a free tool that prices it first. An agent can do almost all of its thinking before it spends anything. Record-returning tools are also capped well below the API's own page ceiling, and a call that would spend more than a small floor of credits has to say so explicitly. There is no human at the confirm step over MCP, so the guardrails are in the tool schemas rather than in advice. ### What it costs The same as everything else: **1 credit per record delivered**, from the same shared pool your CSV exports and API calls spend. An MCP call is not a separate SKU and is not metered differently. --- # Install the MCP server URL: https://boardwalkai.com/docs/mcp/install/ Three ways to connect Boardwalk to your MCP client, including a manual path that does not depend on any directory listing. You need a Boardwalk API key first — mint one under [Settings → API keys](/profile/api). A free-trial key works. ### Option A — one-click, from Boardwalk The [connect page](/connect/claude) walks Claude Desktop and Claude Code through the connection and hands the key over for you. This is the fastest route if you are using a Claude client. ### Option B — from your client's connector directory Where your client has a connector or MCP directory, search it for **Boardwalk**. Directory availability varies by client and by review queue, so if you do not see us there yet, use option C — it is the same server. ### Option C — manual configuration, which always works This path depends on nothing but the server URL and your key. Add the block below to your client's MCP configuration file and restart it. ```json { "mcpServers": { "boardwalk": { "command": "npx", "args": [ "-y", "mcp-remote", "https://mcp.boardwalkai.com/mcp" ], "env": { "BOARDWALK_API_KEY": "bwk_live_YOUR_KEY" } } } } ``` | Client | Where the file lives | | --- | --- | | Claude Desktop (macOS) | `~/Library/Application Support/Claude/claude_desktop_config.json` | | Claude Desktop (Windows) | `%APPDATA%\Claude\claude_desktop_config.json` | | Claude Code | `claude mcp add` — or the same JSON in your project's `.mcp.json` | | Cursor | `.cursor/mcp.json` in the project, or the global equivalent | > **Treat the key like a password** — A configuration file with a `bwk_live_` key in it can spend your team's credits. Keep the file out of version control, and mint a separate named key per machine under [Settings → API keys](/profile/api) so you can revoke one without disturbing the others. Do not use a `bwk_test_` key here — that prefix is not serving data yet. ### For ChatGPT and other clients without MCP Where a client cannot speak MCP, use the REST API directly with the same key. [Getting started](/docs/api/quickstart/) is four calls, and [the whole documentation tree as one markdown file](/docs/llms-full.txt) gives a model everything it needs in a single fetch. ### Check it worked Ask your client: *"Using Boardwalk, describe what data you can see, then count the apartment projects in Utah in the last three months."* The first is a free reference call and the second is a free count, so a successful setup costs nothing to verify. --- # MCP tools URL: https://boardwalkai.com/docs/mcp/tools/ Every tool the Boardwalk MCP server exposes, which plane it belongs to, and what it costs. Tools are grouped into planes. The plane is the sentence a model needs most and is least likely to infer: whether a call spends money. | Tool | Plane | Cost | What it is for | | --- | --- | --- | --- | | `describe_data` | reference | Free | What the dataset can and cannot answer. The right first call for a model that has never used Boardwalk. | | `list_locations` | reference | Free | States, counties and cities, with the ids the filters take. | | `get_taxonomy` | reference | Free | The land-use vocabulary — types, subtypes, asset classes, action types. | | `count_projects` | analytics | Free | How many records match, and what delivering them would cost. Takes the same filters as `get_projects`, so it is a true preflight rather than an approximation. | | `analyze_projects` | analytics | Free | Group and measure. Answers "how many / what mix / which is biggest" without returning any record identity. | | `rank_jurisdictions` | analytics | Free | Leaderboards over cities, counties or states, with a minimum denominator. | | `get_ai_search_result` | leads | Free | Collect a finished asynchronous AI search. The records were charged when the job settled; reading them is free. | | `get_credit_balance` | account | Free | Remaining credits for the period. Free, and worth calling before any metered tool. | | `get_projects` | leads | 1 credit per record | The records themselves. 1 credit each. | | `get_project` | leads | 1 credit per record | One record in detail. 1 credit. | | `get_document` | leads | 1 credit per record | A source meeting document. 1 credit. | | `ai_search_projects` | leads | 1 credit per record | Natural-language search. 1 credit per confirmed record delivered. | ### Guardrails built into the tools - Every record-returning tool names a **free** tool that prices it first, and the tool description points at it. - Record-returning tools cap `limit` well below the API's own page ceiling, and `limit` is required rather than optional — an omitted limit used to mean the maximum. - A call that would spend more than a small floor of credits requires an explicit `maxCredits` acknowledgement in the arguments. - Every tool calls the public API. None of them reaches an internal endpoint, so the plan gate, the geographic ceiling and the usage log apply identically to a model and to a human. > **Filtered values are inspectable** — Record filters are rechecked after merge collapse against the same values the response serializes. A missing value is a non-match, not a wildcard, so a model does not need a client-side null-value workaround.