# 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, filtering, taxonomy, locations, 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. Sign up and mint an API key. No subscription is needed to create one. 2. Call a free endpoint to prove the key works, spending nothing. 3. Count what your real query would cost, then run it. - [Quickstart — 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 filtering is not yet held to the same evidence standard as the values we return. [Both are written up in full.](/docs/api/limitations/) --- # Quickstart URL: https://boardwalkai.com/docs/api/quickstart/ Sign up, mint a key, and make your first successful call — including a metered one — in about five minutes. Four steps. 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) — a free trial includes API access — then mint a key on [your API settings page](/profile/api). **You do not need a paid subscription to create a key**; entitlement is checked when you call, not when you mint. Keys carry their environment in the prefix. `bwk_test_` is a sandbox key; `bwk_live_` reads the production corpus and spends credits. Keys issued before the Boardwalk rename begin with `csk_` and keep working indefinitely — there is nothing you need to do. ### 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": 44, "name": "Utah", "abbreviation": "UT", "projectCount": 41822 }, { "id": 6, "name": "Colorado", "abbreviation": "CO", "projectCount": 18304 }, { "id": 3, "name": "Arizona", "abbreviation": "AZ", "projectCount": 15977 } ], "meta": { "count": 3 } } ``` _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. 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 **Add one filter and the same query 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", "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": "This request fits inside the remaining balance, so every matching record is delivered." } }, "meta": { "schemaVersion": "2026-08-02", "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": [ 44 ], "assetClasses": [ "Apartment Building" ], "meetingDateFrom": "2026-05-01T00:00:00+00:00", "totalUnitCountMin": 50 }, "appliedDefaults": [], "resolvedFrom": { "states": { "UT": 44 } }, "warnings": [ { "code": "filter_evidence_unconfirmed", "message": "These filters currently match our full index. A returned record with a null value for the attribute you filtered on is a record we could not confirm.", "params": [ "assetClasses", "totalUnitCountMin" ] } ] }, "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. A request that costs more than your balance is **refused**, not truncated, so counting first is how you avoid a `402`. Read `affordability.affordable` and `billable.credits`. ### 4. Make the metered call **Your first metered call** ```bash curl 'https://api.boardwalkai.com/api/v1/projects/search?states=UT&assetClasses=Apartment%20Building&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 ], "createdAt": "2026-05-04T00:00:00Z", "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", "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, "unitCount": [ { "count": 240, "type": "apartment", "typeLabel": "Apartment" } ], "totalUnitCount": 240, "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" }, "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-02", "representation": "standard", "total": 1, "limit": 1, "offset": 0, "creditsUsed": 1, "creditsRemaining": 299, "trialTruncation": null, "searchMethod": "redisearch", "appliedFilters": { "states": [ 44 ], "assetClasses": [ "Apartment Building" ] }, "appliedDefaults": [ "dateRange=last_1_year" ], "resolvedFrom": { "states": { "UT": 44 } }, "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" ] }, { "code": "filter_evidence_unconfirmed", "message": "These filters currently match our full index. A returned record with a null value for the attribute you filtered on is a record we could not confirm.", "params": [ "assetClasses" ] } ], "matchCount": 250, "matchCountIsExact": true, "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 — Charged for the records actually delivered on the page, after duplicate projects are collapsed and after records you already hold unchanged are excluded. Zero results cost nothing. 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. ### What you just spent | Step | Endpoint | Credits | | --- | --- | --- | | 1 | Minting a key | 0 | | 2 | `GET /locations/states` | 0 | | 3 | `GET /projects/count` | 0 | | 4 | `GET /projects/search?limit=1` | 1 | | | **Total** | **1** | ### Where to go next - [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 billed by a scheduled run are re-readable here without a second charge; anything new to you bills once. | | POST | `/alerts/{alertId}/run` | 1 credit / record | A manual run is a real delivery: records new to you 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 delivered, not for the candidates that were reviewed and rejected. | | POST | `/projects/ai-search/jobs` | 1 credit / record | Credits are reserved up front against the requested limit and settled down to the number of confirmed records actually delivered. | | GET | `/projects/search` | 1 credit / record | Charged for the records actually delivered on the page, after duplicate projects are collapsed and after records you already hold unchanged are excluded. Zero results cost nothing. | | 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 record, one credit — the same rate as a 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. Returns counts and group keys, never a project id, name, address, contact or document. | | GET | `/analytics/datasets` | Free | Free. What the analytics plane can and cannot answer, self-described. | | POST | `/analytics/rankings` | Free | Free, and subject to the same small-group suppression floor as /aggregate. | | 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 | `/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": "state", "planDisplayName": "State", "creditsLimit": 500, "creditsUsed": 200, "creditsRemaining": 300, "periodStart": "2026-08-01", "periodEnd": "2026-08-31", "rateLimit": 60, "rateLimitPer": "minute", "isTeamPool": true, "poolUserId": 8814 } } ``` _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", "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-02", "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": [ 44 ], "assetClasses": [ "Apartment Building" ], "dateRange": "all" }, "appliedDefaults": [], "resolvedFrom": { "states": { "UT": 44 } }, "warnings": [ { "code": "filter_evidence_unconfirmed", "message": "These filters currently match our full index. A returned record with a null value for the attribute you filtered on is a record we could not confirm.", "params": [ "assetClasses" ] } ] }, "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 scan ceiling was reached and the true total is higher. | | `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` 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", "status": "approved_with_conditions", "statusLabel": "Approved with Conditions" } ], "meta": { "schemaVersion": "2026-08-02", "representation": "compact", "total": 1, "limit": 1, "offset": 0, "creditsUsed": 1, "creditsRemaining": 296, "trialTruncation": null, "searchMethod": "redisearch", "appliedFilters": { "states": [ 44 ] }, "appliedDefaults": [ "dateRange=last_1_year" ], "resolvedFrom": { "states": { "UT": 44 } }, "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": 8842, "matchCountIsExact": true, "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 ], "createdAt": "2026-05-04T00:00:00Z", "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": 44, "countyId": 2917, "cityId": 31688, "additionalAddresses": [], "propertyType": "residential", "propertyTypeLabel": "Residential", "propertySubtype": "multifamily", "propertySubtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "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, "unitCount": [ { "count": 240, "type": "apartment", "typeLabel": "Apartment" } ], "totalUnitCount": 240, "affordableUnits": null, "bedroomCount": null, "lotCount": null, "buildingCount": 6, "buildingStories": 4, "buildingHeightFeet": 48, "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" }, "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-02", "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. | | `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` is in acres. `lotSizeSqft` and `squareFootage` are in square feet. `buildingHeightFeet` is 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. | | `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. | | `createdAt` | string (nullable) | When Boardwalk first recorded this project, RFC 3339 UTC. | | `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, e.g. apartments. Blank means we do not have that level of detail — 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. | | `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. | | `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. | | `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. | | `bedroomCount` | integer (nullable) | Total bedrooms across the project. | | `lotCount` | integer (nullable) | Number of lots in the subdivision or plat. | | `buildingCount` | integer (nullable) | Number of buildings proposed. | | `buildingStories` | integer (nullable) | Number of storeys. | | `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. | | `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 does not yet enforce the evidence standard the response does** — Filters on property type, subtype, asset class, the measurement ranges and status currently match against our full search index, which still holds values from earlier extraction runs. The **response** is held to the current standard and will show you an empty field rather than a superseded value — so a record can match a filter on an attribute the response then returns as `null`. Responses that used one of those filters carry a `filter_evidence_unconfirmed` warning naming the parameters affected, and you can check it yourself: a returned record with a null value for the attribute you filtered on is a record we could not confirm. [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 classification must be the project's primary one. Defaults to primary_or_secondary when assetClasses is set, primary_only otherwise — the product's own rule. 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. | | `createdAtFrom` | date | createdFrom | Earliest first-seen date. Accepts YYYY, YYYY-MM, YYYY-MM-DD or RFC3339. Inclusive: a "To" bound snaps to the END of the period named. | | `createdAtTo` | date | createdTo | Latest first-seen 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. Values: meetingDate, createdAt, lastUpdated, dataRichness, totalUnitCount, acreage. | | `order` | string | — | asc or desc. 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, what it will not guess, and the one place filtering does not yet match that standard. > **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 ], "createdAt": "2026-06-02T00:00:00Z", "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, "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, "unitCount": [], "totalUnitCount": 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" }, "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-02", "representation": "standard", "total": 1, "limit": 1, "offset": 0, "creditsUsed": 1, "creditsRemaining": 294, "trialTruncation": null, "searchMethod": "redisearch", "appliedFilters": { "states": [ 44 ], "cities": [ 31955 ] }, "appliedDefaults": [ "dateRange=last_1_year" ], "resolvedFrom": { "states": { "UT": 44 }, "cities": { "Provo": 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": 61, "matchCountIsExact": true, "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 record matched a filter on an attribute the response returns as `null` | Filtering and output are held to different standards today. See below — this is the one gap we are asking you to work around. | ### The gap, stated plainly > **Filters match our full index; responses are held to the current standard** — Filters run against our search index, which still contains values from earlier extraction runs. Responses are shaped at serialisation time and do not. So `assetClasses=Apartment%20Building` can return a project whose apartment evidence we no longer stand behind — and the response will show you `assetClass: null` for that record. Three things follow, and we would rather you knew all three: - **You will not miss records because of this.** The effect makes a filtered result set broader, not narrower. - **You may receive some you did not want**, and you paid a credit for each. If that matters to you, filter your side on the returned attribute as well: a record with `assetClass: null` after you filtered on `assetClass` is one we could not confirm. - **The response tells you when this applies.** A `filter_evidence_unconfirmed` warning in `meta.warnings` names the parameters affected, so an integration can assert on it rather than remembering this page. We are migrating the index so that filtering is held to the same standard as output. When that lands, a record with no confirmed value for an attribute will stop matching a filter on it, result sets for those filters will get smaller, and this notice will be removed. It is a change to ***which records come back**, not to their shape — no field is removed, no type changes, no enum narrows — so nothing will break at your parsing layer. [We treat it as a correctness change; the versioning policy says so in advance.](/docs/api/versioning/) ### 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", "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", "status": "continued", "statusLabel": "Continued" } ], "meta": { "schemaVersion": "2026-08-02", "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 capped at 100 records per page; asking for more is clamped and the response says so in `meta.warnings` rather than failing. ### 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", "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", "status": "continued", "statusLabel": "Continued" } ], "meta": { "schemaVersion": "2026-08-02", "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/ Per-key request limits, the headers that let you avoid ever hitting them, and the AI-search daily ceiling. 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. | Key | Requests per minute | | --- | --- | | Any paid plan | 60 | | Free-trial key | 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. 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. When you reach it you get a clear error naming the limit, not a silent truncation. --- # 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","states":["UT"],"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 against your requested limit, so two identical submits can hold the whole pool while both run. With an idempotency 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 | | --- | --- | --- | --- | | `alert_limit_reached` | 403 | The account already holds the maximum number of active alerts. | Pause or delete one. `error.details.maxActiveAlerts` states the ceiling. | | `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. | | `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. | | `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 your per-key allowance in a trailing 60 seconds. | 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, and a customer narrowing against a false zero would conclude their market was empty. | Retry. Nothing was charged. | | `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", "states": [ "UT" ], "limit": 50, "include": "contacts", "contactOptions": { "minConfidence": "high" } }' ``` Response `202`: ```json { "requestId": 918509, "status": "pending", "pollUrl": "/api/v1/projects/ai-search/jobs/918509", "meta": { "creditsReserved": 50, "limit": 50, "trialTruncation": 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._ 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. | Endpoint | What it does | | --- | --- | | `GET /analytics/datasets` | Self-description: what you can group by, which metrics exist, which are rankable, the ceilings, and a `notAnswerable` list. | | `POST /analytics/aggregate` | Group-by with metrics over the published dimensions. | | `POST /analytics/rankings` | 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-02", "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._ ### Small groups are suppressed A group with fewer members than the suppression floor is not returned as its own row; `suppressedGroups` reports how many were withheld. 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. | Method | Path | Cost | Notes | | --- | --- | --- | --- | | GET | `/alerts/{alertId}/matches` | 1 credit / record | Records already delivered and billed by a scheduled run are re-readable here without a second charge; anything new to you bills once. | | POST | `/alerts/{alertId}/run` | 1 credit / record | A manual run is a real delivery: records new to you 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 delivered, not for the candidates that were reviewed and rejected. | | POST | `/projects/ai-search/jobs` | 1 credit / record | Credits are reserved up front against the requested limit and settled down to the number of confirmed records actually delivered. | | GET | `/projects/search` | 1 credit / record | Charged for the records actually delivered on the page, after duplicate projects are collapsed and after records you already hold unchanged are excluded. Zero results cost nothing. | | 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 record, one credit — the same rate as a 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. Returns counts and group keys, never a project id, name, address, contact or document. | | GET | `/analytics/datasets` | Free | Free. What the analytics plane can and cannot answer, self-described. | | POST | `/analytics/rankings` | Free | Free, and subject to the same small-group suppression floor as /aggregate. | | 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 | `/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 is not yet held to the response's evidence standard > **A record can match a filter on an attribute the response returns empty** — Filters run against our search index, which still holds values from earlier extraction runs; responses are held to our current extraction standard. So a filter on an attribute can return records whose value for that attribute comes back `null`. This makes result sets broader, never narrower — you will not miss records — and every affected response carries a `filter_evidence_unconfirmed` warning. [Full explanation and the workaround.](/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/) ### 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, echoed on every response as `meta.schemaVersion` and in the `X-Boardwalk-Schema-Version` header. 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 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 > **One change is coming that will make some result sets smaller** — We are migrating the search index so that filters are held to the same evidence standard as the values we return ([see why](/docs/api/data-availability/)). When it lands, a saved query can return **fewer** records than the day before, and a `/projects/count` quote for the same filters can come back lower. We are telling you in advance rather than explaining it afterwards. We classify that flip as a **correctness change, not a breaking change**, and it is therefore exempt from the 90-day notice period. The reasoning, stated now rather than on the day: result sets get **smaller, not different in shape**. No field is removed, no type changes, no enum narrows, so nothing breaks at your parsing layer. It moves the product toward the guarantee already published on every relevant page. When it happens it will ship with a changelog entry, a schema-version bump, and the disappearance of the limitation notice from these docs — those are the signals to watch. ### 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-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": "state", "planDisplayName": "State", "creditsLimit": 500, "creditsUsed": 200, "creditsRemaining": 300, "periodStart": "2026-08-01", "periodEnd": "2026-08-31", "rateLimit": 60, "rateLimitPer": "minute", "isTeamPool": true, "poolUserId": 8814 } } ``` _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", "status": "approved_with_conditions", "statusLabel": "Approved with Conditions" } ], "meta": { "schemaVersion": "2026-08-02", "representation": "compact", "total": 1, "limit": 1, "offset": 0, "creditsUsed": 1, "creditsRemaining": 298, "trialTruncation": null, "searchMethod": "redisearch", "appliedFilters": { "states": [ 44, 6, 3 ], "propertyTypes": [ "residential" ] }, "appliedDefaults": [ "dateRange=last_1_year" ], "resolvedFrom": { "states": { "UT": 44, "CO": 6, "AZ": 3 } }, "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" ] }, { "code": "filter_evidence_unconfirmed", "message": "These filters currently match our full index. A returned record with a null value for the attribute you filtered on is a record we could not confirm.", "params": [ "propertyTypes" ] } ], "matchCount": 3140, "matchCountIsExact": true, "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 ], "createdAt": "2026-05-04T00:00:00Z", "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", "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, "unitCount": [ { "count": 240, "type": "apartment", "typeLabel": "Apartment" } ], "totalUnitCount": 240, "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" }, "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-02", "representation": "standard", "total": 1, "limit": 1, "offset": 0, "creditsUsed": 1, "creditsRemaining": 297, "trialTruncation": null, "searchMethod": "redisearch", "appliedFilters": { "states": [ 44 ], "cities": [ 31688 ] }, "appliedDefaults": [ "dateRange=last_1_year" ], "resolvedFrom": { "states": { "UT": 44 }, "cities": { "West Valley City": 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": 96, "matchCountIsExact": true, "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": 44, "name": "Utah", "abbreviation": "UT", "projectCount": 41822 }, { "id": 6, "name": "Colorado", "abbreviation": "CO", "projectCount": 18304 }, { "id": 3, "name": "Arizona", "abbreviation": "AZ", "projectCount": 15977 } ], "meta": { "count": 3 } } ``` _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. 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 Geography is a filter, and filters are free. 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&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 ], "createdAt": "2026-05-04T00:00:00Z", "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", "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, "unitCount": [ { "count": 240, "type": "apartment", "typeLabel": "Apartment" } ], "totalUnitCount": 240, "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" }, "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-02", "representation": "standard", "total": 1, "limit": 1, "offset": 0, "creditsUsed": 1, "creditsRemaining": 299, "trialTruncation": null, "searchMethod": "redisearch", "appliedFilters": { "states": [ 44 ], "assetClasses": [ "Apartment Building" ] }, "appliedDefaults": [ "dateRange=last_1_year" ], "resolvedFrom": { "states": { "UT": 44 } }, "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" ] }, { "code": "filter_evidence_unconfirmed", "message": "These filters currently match our full index. A returned record with a null value for the attribute you filtered on is a record we could not confirm.", "params": [ "assetClasses" ] } ], "matchCount": 250, "matchCountIsExact": true, "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 ], "createdAt": "2026-06-02T00:00:00Z", "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, "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, "unitCount": [], "totalUnitCount": 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" }, "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-02", "representation": "standard", "total": 1, "limit": 1, "offset": 0, "creditsUsed": 1, "creditsRemaining": 294, "trialTruncation": null, "searchMethod": "redisearch", "appliedFilters": { "states": [ 44 ], "cities": [ 31955 ] }, "appliedDefaults": [ "dateRange=last_1_year" ], "resolvedFrom": { "states": { "UT": 44 }, "cities": { "Provo": 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": 61, "matchCountIsExact": true, "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. > **A record can match a filter on an attribute the response returns empty** — Filtering currently runs against our full search index, which still holds values from earlier extraction runs, while the response is held to the current standard. So a filter on `assetClass` can return a record whose `assetClass` comes back `null`. Responses that used one of those filters carry a `filter_evidence_unconfirmed` warning naming the parameters affected. [The full explanation is here.](/docs/api/data-availability/) ### What it costs **Cost:** 1 credit per record delivered — Charged for the records actually delivered on the page, after duplicate projects are collapsed and after records you already hold unchanged are excluded. Zero results cost nothing. 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 with a `bwk_test_` key and a balance of trial credits, metered exactly like a paid balance: **1 credit per record delivered**. 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 billed by a scheduled run are re-readable here without a second charge; anything new to you bills once. | | POST | `/alerts/{alertId}/run` | 1 credit / record | A manual run is a real delivery: records new to you 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 delivered, not for the candidates that were reviewed and rejected. | | POST | `/projects/ai-search/jobs` | 1 credit / record | Credits are reserved up front against the requested limit and settled down to the number of confirmed records actually delivered. | | GET | `/projects/search` | 1 credit / record | Charged for the records actually delivered on the page, after duplicate projects are collapsed and after records you already hold unchanged are excluded. Zero results cost nothing. | | 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 record, one credit — the same rate as a 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. Returns counts and group keys, never a project id, name, address, contact or document. | | GET | `/analytics/datasets` | Free | Free. What the analytics plane can and cannot answer, self-described. | | POST | `/analytics/rankings` | Free | Free, and subject to the same small-group suppression floor as /aggregate. | | 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 | `/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? **Add one filter and the same query 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", "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": "This request fits inside the remaining balance, so every matching record is delivered." } }, "meta": { "schemaVersion": "2026-08-02", "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": [ 44 ], "assetClasses": [ "Apartment Building" ], "meetingDateFrom": "2026-05-01T00:00:00+00:00", "totalUnitCountMin": 50 }, "appliedDefaults": [], "resolvedFrom": { "states": { "UT": 44 } }, "warnings": [ { "code": "filter_evidence_unconfirmed", "message": "These filters currently match our full index. A returned record with a null value for the attribute you filtered on is a record we could not confirm.", "params": [ "assetClasses", "totalUnitCountMin" ] } ] }, "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 ], "createdAt": "2026-05-04T00:00:00Z", "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", "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, "unitCount": [ { "count": 240, "type": "apartment", "typeLabel": "Apartment" } ], "totalUnitCount": 240, "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" }, "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-02", "representation": "standard", "total": 1, "limit": 1, "offset": 0, "creditsUsed": 1, "creditsRemaining": 99, "trialTruncation": null, "searchMethod": "redisearch", "appliedFilters": { "states": [ 44 ] }, "appliedDefaults": [ "dateRange=last_1_year" ], "resolvedFrom": { "states": { "UT": 44 } }, "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": 8842, "matchCountIsExact": true, "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 - [Quickstart — 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", "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-02", "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": [ 44 ], "assetClasses": [ "Apartment Building" ], "dateRange": "all" }, "appliedDefaults": [], "resolvedFrom": { "states": { "UT": 44 } }, "warnings": [ { "code": "filter_evidence_unconfirmed", "message": "These filters currently match our full index. A returned record with a null value for the attribute you filtered on is a record we could not confirm.", "params": [ "assetClasses" ] } ] }, "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 **Add one filter and the same query 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", "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": "This request fits inside the remaining balance, so every matching record is delivered." } }, "meta": { "schemaVersion": "2026-08-02", "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": [ 44 ], "assetClasses": [ "Apartment Building" ], "meetingDateFrom": "2026-05-01T00:00:00+00:00", "totalUnitCountMin": 50 }, "appliedDefaults": [], "resolvedFrom": { "states": { "UT": 44 } }, "warnings": [ { "code": "filter_evidence_unconfirmed", "message": "These filters currently match our full index. A returned record with a null value for the attribute you filtered on is a record we could not confirm.", "params": [ "assetClasses", "totalUnitCountMin" ] } ] }, "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 billed by a scheduled run are re-readable here without a second charge; anything new to you 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 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", "states": [ "UT", "CO", "AZ" ], "limit": 100 }' ``` Response `202`: ```json { "requestId": 918442, "status": "pending", "pollUrl": "/api/v1/projects/ai-search/jobs/918442", "meta": { "creditsReserved": 100, "limit": 100, "trialTruncation": 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._ 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": 23, "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-09T14: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 ], "createdAt": "2026-05-04T00:00:00Z", "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", "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, "unitCount": [ { "count": 240, "type": "apartment", "typeLabel": "Apartment" } ], "totalUnitCount": 240, "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" }, "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-02", "representation": "standard", "creditsUsed": 23, "candidatesReviewed": 3000, "confirmedMatches": 23 }, "stopReason": null, "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 23. **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 1,000 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 AI-search work. We have not measured the right number yet, so we are not publishing one. When you hit it you get a clear error rather than a silent truncation. | > **1,000 records is 1,000 credits** — A single AI search returning the maximum result set costs twice a State plan's entire 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 the number of confirmed records actually delivered. | Confirmed matches delivered | Credits | | --- | --- | | 0 | 0 | | 23 | 23 | | 100 | 100 | | 1,000 | 1,000 | ### 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`. 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", "states": [ "UT" ], "limit": 50, "include": "contacts", "contactOptions": { "minConfidence": "high" } }' ``` Response `202`: ```json { "requestId": 918509, "status": "pending", "pollUrl": "/api/v1/projects/ai-search/jobs/918509", "meta": { "creditsReserved": 50, "limit": 50, "trialTruncation": 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._ | 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", "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", "status": "continued", "statusLabel": "Continued" } ], "meta": { "schemaVersion": "2026-08-02", "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", "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-02", "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": [ 44 ], "assetClasses": [ "Apartment Building" ], "dateRange": "all" }, "appliedDefaults": [], "resolvedFrom": { "states": { "UT": 44 } }, "warnings": [ { "code": "filter_evidence_unconfirmed", "message": "These filters currently match our full index. A returned record with a null value for the attribute you filtered on is a record we could not confirm.", "params": [ "assetClasses" ] } ] }, "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. A `filter_evidence_unconfirmed` warning is why an attribute it filtered on came back empty, and without it the model will report a data bug to its user. ### Costs at a glance | Method | Path | Cost | Notes | | --- | --- | --- | --- | | GET | `/alerts/{alertId}/matches` | 1 credit / record | Records already delivered and billed by a scheduled run are re-readable here without a second charge; anything new to you bills once. | | POST | `/alerts/{alertId}/run` | 1 credit / record | A manual run is a real delivery: records new to you 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 delivered, not for the candidates that were reviewed and rejected. | | POST | `/projects/ai-search/jobs` | 1 credit / record | Credits are reserved up front against the requested limit and settled down to the number of confirmed records actually delivered. | | GET | `/projects/search` | 1 credit / record | Charged for the records actually delivered on the page, after duplicate projects are collapsed and after records you already hold unchanged are excluded. Zero results cost nothing. | | 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 record, one credit — the same rate as a 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. Returns counts and group keys, never a project id, name, address, contact or document. | | GET | `/analytics/datasets` | Free | Free. What the analytics plane can and cannot answer, self-described. | | POST | `/analytics/rankings` | Free | Free, and subject to the same small-group suppression floor as /aggregate. | | 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 | `/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-02", "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. 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. 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, and subject to the 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 ], "createdAt": "2026-05-04T00:00:00Z", "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": 44, "countyId": 2917, "cityId": 31688, "additionalAddresses": [], "propertyType": "residential", "propertyTypeLabel": "Residential", "propertySubtype": "multifamily", "propertySubtypeLabel": "Multifamily", "assetClass": "Apartment Building", "assetClassLabel": "Apartment Building", "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, "unitCount": [ { "count": 240, "type": "apartment", "typeLabel": "Apartment" } ], "totalUnitCount": 240, "affordableUnits": null, "bedroomCount": null, "lotCount": null, "buildingCount": 6, "buildingStories": 4, "buildingHeightFeet": 48, "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" }, "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-02", "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 here](/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. Use a `bwk_test_` key while you are setting up, keep the file out of version control, and mint a separate named key per machine so you can revoke one without disturbing the others. ### For ChatGPT and other clients without MCP Where a client cannot speak MCP, use the REST API directly with the same key. The [quickstart](/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. > **Tell your model about the filter-evidence warning** — Responses carry a `filter_evidence_unconfirmed` warning when a filter was used whose matching is not yet held to the same evidence standard as the values we return. Surface it to the model. Without it, a model that filters on `assetClass` and receives a record with `assetClass: null` will report a data bug to its user. [Why this happens.](/docs/api/data-availability/)