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 for what each change type means for your integration.
added2026-09-16schema 2026-09-16
Lead types: leadType and stage on standard and full records, and a leadTypes filter to widen the view.
Every project record in the standard and full representations now carries leadType (private, public, private_minor or public_minor) with leadTypeLabel, and stage (where the project is in its life: concept_or_planning, entitlement, design, bidding, awarded, under_construction or complete) with stageLabel. Compact omits both. Both are null while a record is awaiting review. CSV exports gain the matching Lead Type and Project Stage columns in the Construction Signals group. Search, count, sync, analytics, exports and alerts accept leadTypes (alias leadType; comma-separated or repeated) to widen what is returned — private is always included, so the parameter can only add public, private_minor or public_minor. Ground-up vs renovation is constructionTypes, not a lead-type switch. meta.appliedFilters.leadTypes echoes the view that actually ran. Analytics accepts leadTypes as a filter but cannot groupBy it. The new fields are not part of a record's content fingerprint: a regrade never bills an owned record as changed and never appears in changedGroups.
Affects: GET /projects/search · GET /projects/count · GET /projects/sync · GET /projects/{id} · POST /analytics/aggregate · POST /exports · POST /alerts · MCP · CSV export
changed2026-09-16schema 2026-09-16
Search, count, sync, analytics, exports and alerts return private leads by default; widen with leadTypes.
A request that does not send leadTypes searches private development leads only — the same view as the map — and says so with leadTypes=private in meta.appliedDefaults. Public projects, small private jobs and small public jobs are returned only when leadTypes names them. Records that are not development opportunities are no longer returned by any list endpoint. Alerts created before this release, over the API or on the web, narrow to the default view at their next run; add leadTypes to an alert's filters to keep the wider set. GET /projects/{id} is unchanged: a public or minor lead fetched by id is still served. A projectIds search still applies the default view, so a public or minor id you already hold is hidden unless you also send leadTypes, or you fetch it by id. A default request's engine filters are unchanged, so existing sync cursors keep working; a cursor started with leadTypes carries it, and changing leadTypes mid-walk is refused as an invalid cursor rather than silently gapping the walk. includeGovernmentDecisions is deprecated in favour of leadTypes: it is still accepted and echoed, but it no longer has any effect — a Govt. Decision that is a real opportunity is a public lead and comes back when leadTypes names public, and one that is paperwork is not served at all. It will be retired with notice here.
Affects: GET /projects/search · GET /projects/count · GET /projects/sync · POST /analytics/aggregate · POST /exports · POST /alerts · MCP
changed2026-08-30schema 2026-08-27
Building Type and Govt. Decisions now match the map labels on the API, MCP, and docs.
The map Type filter has seven Building Types (Residential, Commercial, Industrial, Institutional/Public, Mixed Use, Infrastructure, Agricultural). GET /taxonomy, GET /taxonomy/project-types, and MCP get_project_types return only those seven. Non-real-estate government items are Govt. Decisions: they are not a Building Type. Turn them on with includeGovernmentDecisions=true (the map's Show Non-Real Estate Govt. Decisions toggle). Records and CSV that used to publish a different type spelling for that cohort now return propertyType, propertyTypeLabel, and classifications[].type as "Govt. Decisions". The previous spelling remains accepted on input, as do "Govt. Decisions", "Govt Decisions", and "Government Decisions"; meta.appliedFilters echoes the public name. MCP tool copy, agent skills, OpenAPI filter descriptions, and the public LLM corpus use the same map labels (Building Type, Project Size, Acres, Govt. Decisions) so hosts do not invent a parallel vocabulary. Engine tokens and stored alert criteria are unchanged.
Affects: GET /taxonomy · GET /taxonomy/project-types · GET /projects/search · GET /projects/{id} · POST /analytics/aggregate · MCP · CSV export
changed2026-08-28schema 2026-08-27
MCP matches the launch REST surface: alert matches are free, plus enrichment and AI-search cancel tools.
get_alert_matches is free over MCP, matching GET /alerts/{id}/matches (the run already billed). New tools: create_enrichment and get_enrichment (POST/GET /enrichments, free, Idempotency-Key on submit) and cancel_ai_search (DELETE /projects/ai-search/jobs/{id}, free, refunds reserved credits). Structured search/count now accept projectIds (alias ids), deliveryMode, and dateRange; AI-search submit accepts notify.email. Manual run_alert is documented as 3/day on the America/Denver service clock. Cursor setup copy no longer recommends a bwk_test_ key.
Affects: MCP · GET /alerts/{alertId}/matches · POST /enrichments · GET /enrichments/{id} · DELETE /projects/ai-search/jobs/{id}
added2026-08-27schema 2026-08-27
AI-search jobs: cancellation, a settlement email, and pool-scoped polling.
DELETE /projects/ai-search/jobs/{id} cancels a job, free, and only ever gives credits back: a queued job is cancelled outright with its full reservation refunded, and a running job has its cancel flag set and stops at the next batch boundary, settling partial with partialReason cancelled_by_user — confirmed billable records are charged and delivered on the poll, and the unused remainder of the reservation is refunded. Cancelling a job already in a terminal state is an idempotent no-op. When a job settles — completed, partial, failed, or cancelled — a pointer-only email now goes to the submitter's account address: counts, credits charged and refunded, and the poll URL, never record data; failure and cancellation emails state the refund. It is on by default — send notify {"email": false} on the submit body to opt out (async route only; unknown keys inside notify return 400). Finally, polling and cancelling are now scoped to the credit pool rather than the submitter alone: a teammate on the same pool can poll or cancel a colleague's job before its 72-hour result window closes, and anyone else still receives 404.
Affects: POST /projects/ai-search/jobs · GET /projects/ai-search/jobs/{id} · DELETE /projects/ai-search/jobs/{id}
added2026-08-27schema 2026-08-27
Read purchased records back by id: the projectIds filter on search, count and sync.
projectIds (alias ids) accepts up to 100 distinct positive integer record ids per request — duplicates are deduped silently, non-integers and oversize lists return 400 invalid_filter. No default date window applies to an id selection: an explicit id list is an explicit selection, so a purchased record whose meeting date has aged past twelve months still reads back (send dateRange or meetingDate bounds to window an id set deliberately). On search and sync it ANDs with every other filter and with your licensed geography, and unknown or foreign ids simply do not match; on the free GET /projects/count it must be the only filter — the count prices an id set as-is, and combining it with other filters there returns 400 pointing at the metered search. Records you own, unchanged, re-read at 0 credits, so projectIds with include=contacts and contactOptions cachedOnly true is the canonical free read-back of an enriched purchase — no more per-id detail loops or search re-runs that bill for set drift. Because it is parsed by the shared filter parser, projectIds is equally accepted in alert criteria (POST /alerts, PATCH /alerts/{alertId}) and on the free analytics plane (POST /analytics/aggregate, POST /analytics/rankings), where it is echoed back in appliedFilters. Note the scope of the single-filter guard: it is enforced on GET /projects/count only. The analytics plane does not carry it, so an id list may be combined with other filters there.
Affects: GET /projects/search · GET /projects/count · GET /projects/sync · POST /alerts · PATCH /alerts/{alertId} · POST /analytics/aggregate · POST /analytics/rankings
changed2026-08-27schema 2026-08-27
Pages and records that bill 0 credits are served even at 0 balance; credits_exhausted fires only when the response would bill.
402 credits_exhausted no longer fires before the search runs. It fires only when the response would bill at least one record, so at exactly 0 balance the requests that bill nothing keep working: re-reads of owned unchanged records, including include=contacts with contactOptions.cachedOnly=true read-backs, are served rather than refused. A pool that spent its last credit on a purchase can still read back — and enrich — everything it bought. The free planning surface was never gated and is unchanged.
Affects: GET /projects/search · GET /projects/{id} · GET /projects/sync · GET /alerts/{alertId}/matches · GET /lists/{listId}/results
changed2026-08-27schema 2026-08-27
POST /enrichments requires an Idempotency-Key, states an honest completion estimate, and fails honestly when enrichment is switched off mid-queue.
The submit creates a job and debits the monthly enrichment allowance, and a key-less proxy retry is indistinguishable from a second submission — so Idempotency-Key is now required (400 invalid_request, param Idempotency-Key, without it). Re-sending the same key answers with the job the key already created: the same enrichmentJobId and the original accepted/skipped disposition, with status re-read live from the job, and no additional allowance consumed. estimatedCompletion now reflects the measured enrichment rate — roughly two leads per project at about a minute per lead, so about two minutes per project; previously it could undershoot by half. It remains an estimate for polling cadence, not a deadline. A job accepted while enrichment was enabled but not run because enrichment was disabled afterwards now completes as status failed with an explanatory error on the poll body, and its attempts are refunded to the monthly allowance — previously it read completed with found 0 and the allowance consumed. The monthly allowance itself now settles to the work a finished job actually attempted: a queued or running job holds its full accepted set, and a job the per-request ceilings stopped early refunds its unattempted remainder automatically. When that happens the poll body says so — notAttempted counts the projects never tried and a note explains that resubmitting exactly those ids is free to try. The processing deadline for a queued job now scales with the size of the accepted batch, so a large job is no longer cut off at a fixed hour. GET /enrichments/{id} also now declares the nullable error field and the real status vocabulary (queued, running, completed, failed — processing was never a value).
Affects: POST /enrichments · GET /enrichments/{id}
changed2026-08-27schema 2026-08-27
Manual alert runs: the published 3-per-day cap is now enforced, and a replayed run returns the original delivery.
POST /alerts/{alertId}/run is limited to 3 manual runs per alert per calendar day of the service clock — previously published and unenforced. The next attempt returns 429 manual_runs_exhausted with a Retry-After header and error.details carrying limit, used and resetsAt (that clock's next midnight); nothing runs, nothing is charged, and the alert's cadence is untouched. Every claimed manual run spends an attempt whatever its outcome; replays and already_running collisions do not, and scheduled cadence runs are never counted. Separately, replaying an Idempotency-Key that already ran now returns the original delivery envelope with meta.creditsUsed 0 — previously it claimed a fresh window ending at the retry and could re-run and re-bill. Key lookup is per-alert.
Affects: POST /alerts/{alertId}/run
changed2026-08-27schema 2026-08-27
For merged developments, meetingDate is now reconciled across the whole merge family.
meetingDate on a merged development now reports the newest member hearing of the family — never later than today, and never later than an explicit meetingDateTo — matching what the web map shows. Explicit meetingDateFrom/meetingDateTo windows no longer silently omit merged families whose canonical record happened to carry an older date; GET /projects/count and GET /projects/search agree on the reconciled window; and sort=meetingDate orders by the reconciled date. Applies to search, count, sync, AI-search delivery, the single-record GET /projects/{id}, and the meeting_date column of format=csv exports. Two delivery read-backs are NOT reconciled: GET /alerts/{alertId}/matches and GET /lists/{listId}/results re-hydrate stored ids straight from the cache and still report the canonical record's own date, as does the idempotency replay of a search page — use GET /projects/search or GET /projects/{id} for the reconciled value. On a re-dated record served as JSON, status, statusLabel and decisionBody describe that same newest hearing (matching the map's status pill), not the canonical's own older one; format=csv carries the reconciled meeting_date but its status column is still built from the canonical record's own primary action, so a CSV row can pair the new date with the older status. A reconciled date is not a change to the record: it never re-bills an owned record — lastUpdated is untouched, unchanged re-delivery stays free, and the sync feed's contentRevision (and the from/to values in delivery.changes[]) are computed over the raw record before reconciliation, so a caller's date window can never move a revision or trigger a content_changed re-bill. The reconciliation runs inside the shared candidate source, which is also what alert runs and the free analytics plane read through: a date-windowed alert can now match merged families it previously skipped, so a run may deliver — and bill, at 1 credit per record — more records than before, and free analytics counts move for the same filters.
Affects: GET /projects/search · GET /projects/count · GET /projects/sync · GET /projects/{id} · POST /projects/ai-search/jobs · POST /alerts · POST /alerts/{alertId}/run · POST /alerts/{alertId}/preview · POST /analytics/aggregate · POST /analytics/rankings
fixed2026-08-27schema 2026-08-27
CSV fails closed on a ledger outage, the deliveryMode refusal points at the right route, and alert links use the API host.
GET /projects/search?format=csv now fails closed when your delivery history cannot be read: 503 delivery_ledger_unavailable, nothing queued, no credits reserved — previously a page of records you already owned could be reserved at full price. format=json keeps serving during such an outage and discloses the state in meta.delivery.ledgerApplied so any over-charge is found and retro-credited. The deliveryMode=new refusal on AI search and /projects/sync now names GET /projects/search as where new is supported, and the search route's OpenAPI deliveryMode parameter is corrected to enum [all, new] — it had still claimed only all, so a generated client rejected the net-new mode client-side. Alert email and webhook matchesUrl links now target https://api.boardwalkai.com/api/v1/..., and the insufficient-credits refusal email's Review credits button now points at /account/credits instead of a matches URL for a delivery that carries no records.
Affects: GET /projects/search · GET /projects/sync · POST /projects/ai-search · POST /alerts
changed2026-08-27schema 2026-08-27
AI search accepts state abbreviations and names, matching structured search.
POST /projects/ai-search/jobs now resolves `states`, `stateIds`, `state`, and `stateId` the same way GET /projects/search does: integer IDs, USPS abbreviations, or names. County and city filters stay integer IDs. GET /taxonomy/subtypes (and the rest of the land-use taxonomy lookup) now publishes the same v2 labels the search filters accept. Alert create examples send top-level `maxRecordsPerRun`; nested `budget.maxRecordsPerRun` is still accepted as an alias.
Affects: POST /projects/ai-search/jobs · GET /taxonomy · GET /taxonomy/subtypes · POST /alerts
fixed2026-08-27schema 2026-08-27
Billing copy now says what the meter always did: affordability is decided per page, never for the whole set.
No billing behavior changed. Affordability has always been page-scoped: a request delivers at most one page, and a page is delivered whole or refused whole — never trimmed — so a caller whose balance is short of a large match set still pages from the top, collects every page that fits, and is refused on the first page that does not. Several docs passages described a set-level pre-flight refusal that no metered route implements; they now describe the page rule. GET /projects/count is described as what it is: a free price on the whole match set, where affordability.affordable false means the remaining set exceeds the balance — not that the next search call fails — and maxAffordableRecords is what the balance can still buy. The 402 example in the search docs now walks a refusal the route can actually produce: a 40-credit balance refusing a 100-record page, with quote.affordableRecordCount as the limit to retry with and quote.countUrl re-pricing the set for free. credits_exhausted is now emitted from the controller with the standard error envelope; the wire shape is unchanged because the response subscriber already normalized it. The sample request id across the docs is unified to a single req_-prefixed value — client-supplied X-Request-Id values are still echoed back verbatim.
Affects: GET /projects/search · GET /projects/count · MCP
added2026-08-27schema 2026-08-27
Contact enrichment on purchased projects, net-new delivery mode, natural-language alerts, and new filters.
POST /enrichments accepts a list of project IDs you have already purchased and finds contact info for them — free, drawing on a monthly enrichment allowance rather than the credit pool; GET /enrichments/{id} reports live progress (processed / found / pending), and the enriched contacts are read back through the search routes with include=contacts, where re-reading records you own costs nothing. deliveryMode=new on GET /projects/search returns only records never delivered to your account — the net-new-leads mode — with the withheld count disclosed as meta.delivery.excludedPreviouslyDelivered and never billed. Alerts gain the same natural-language criteria the product's alerts run: specificRequirement (up to 500 characters) on POST/PATCH /alerts routes matching through the evidence-verified pipeline, with whyThisMatched and verbatim matchEvidence on each NL-matched record from /alerts/{alertId}/matches; accounts whose plan grants nationwide geography may now create nationwide alerts. New filters: projectName and developerName (the product's scoped name searches), requiresZoningChange (true keeps only records whose request changes the site's zoning), and projectSizes (large / medium / small — the product's Project Size vocabulary; the old constructionMagnitudes name and tokens are still accepted as aliases). New response fields: summary (see the changed entry), projectSize/projectSizeLabel, sourceDocumentUrl (the original public meeting document, standard representation and up), and originalRawText with originalRawTextTruncated (the project's own slice of the public record, full representation only).
Affects: POST /enrichments · GET /enrichments/{id} · GET /projects/search · GET /projects/count · POST /alerts · PATCH /alerts/{alertId} · GET /alerts/{alertId}/matches · GET /account/credits
changed2026-08-27schema 2026-08-27
summary now carries the real project description; statusSummary, unitMix and inFemaFloodZone replace their old names.
The summary field previously duplicated the one-sentence status narrative; it now carries the project description the product shows at the top of a project page (what is being built, by whom, and where the approval stands), falls back to the status sentence when no description was extracted, and moves from the full representation to standard. Renames, with the old names retired from responses: statusDetail is now statusSummary, unitCount (the per-type array) is now unitMix, and the inSfha response field is now inFemaFloodZone — the filter accepts both names, with inFemaFloodZone canonical. lotCount now sums every buildable lot group instead of reporting only the first, and countyId resolves through the city-to-county mapping instead of shipping null beside a county name. Because these are content changes, previously delivered records will classify as changed on their next delivery. Synchronous POST /projects/ai-search now refuses API keys and points at POST /projects/ai-search/jobs: the async job is the only AI search the public surface sells, because it is the evidence-verified pipeline — every delivered record carries the verbatim quote that proves the match.
Affects: GET /projects/search · GET /projects/{id} · GET /projects/sync · POST /projects/ai-search
removed2026-08-27schema 2026-08-27
documents[], constructionDescription, extendedSummary, ownerType, densityPerAcre, estimateEligible and the ownerTypes filter are gone.
The per-meeting documents[] array is replaced by sourceDocumentUrl and originalRawText — one link and one text for the record's latest appearance, which is also the appearance the headline describes. constructionDescription's content now ships as summary, so the duplicate field is retired. extendedSummary never carried a value on the served corpus and is removed rather than left as a dead promise. ownerType/ownerTypeLabel and the ownerTypes filter are withdrawn: the value is not reliable enough to sell against. GET /reference/owner-types stays, but as a labelling vocabulary only — the values it lists are no longer accepted as a search filter, and sending ownerTypes= now returns 400 unknown_parameter. densityPerAcre and the deprecated matchRole/matchedClassification annotations are removed — use landUses[].matchedOn. The civic filter vocabulary is renamed to the product's wording: includeGovernmentDecisions, governmentDecisionSubtypes and governmentDecisionAssetClasses, with every civic* name still accepted as an alias. These removals ship without the 90-day notice at /docs/api/versioning/ as an explicit owner decision recorded 2026-08-26, ahead of the plan's first external integration.
Affects: GET /projects/search · GET /projects/{id} · GET /reference/owner-types
removed2026-08-24schema 2026-08-18
dateRange=last_1_month is no longer accepted. Use last_3_months.
last_1_month has been removed from the dateRange vocabulary on every filtered route, and from the published enum. Sending it now returns 400 invalid_filter with last_3_months named in didYouMean; nothing is charged for the refused request. The reason is that a one-month meeting window is narrower than the ingest lag on slower jurisdictions, so the filter routinely returned an empty page that read as "no development here" when the truth was "the minutes for those meetings are not published yet" — a filter that answers a question the data cannot answer is worse than one that is absent. last_3_months returns a superset of what last_1_month returned, so widening to it can only add records. ALERTS are deliberately exempted from the refusal, in both directions: an alert that already stores last_1_month keeps running, and one created with it is still accepted. In both cases the alert runs with last_3_months instead; POST /alerts and PATCH /alerts/{alertId} echo the substitution as meta.appliedFilters.dateRange=last_3_months, so it is readable rather than inferred. A standing instruction that starts returning 400 on a schedule costs a customer the leads it exists to catch, which is a worse outcome than a wider window. Edit the alert's criteria to record the new value. This is a breaking change to the request vocabulary under the policy at /docs/api/versioning/ ("Removing or renaming an enum token"); it ships without the 90-day Deprecation/Sunset notice that policy describes, as an explicit owner decision recorded on 2026-08-24.
Affects: GET /projects/search · GET /projects/count · GET /projects/sync · POST /analytics/aggregate · POST /analytics/rankings · POST /alerts · PATCH /alerts/{alertId} · GET /alerts/{alertId}/preview · POST /alerts/{alertId}/run
changed2026-08-22schema 2026-08-18
Alerts are now scoped to their membership; pool-wide visibility is retired.
Every alert route now resolves the key's user against the alert's share membership — the creator, an explicit custom member, or the team roster minus opt-outs. A key that could previously read any alert on the same credit pool now receives 404 resource_not_found for alerts its user holds no membership on, on every route. Existing alerts were backfilled to share.mode 'private' (creator only): pass share.mode 'team' to restore team-wide access to an alert. Every member holds the full surface symmetrically — read, edit, run, dismiss and delete (delete archives for every member) — and dismissals are one shared state with actor attribution rather than per-user. Delivery history stays readable to members after an alert is deleted. Billing is unchanged: one run, one charge, to the same credit pool. An API key carries the membership of the user who minted it, so teams should mint one key per member. This is a breaking change for integrations that relied on pool-wide visibility.
Affects: GET /alerts · POST /alerts · GET /alerts/{alertId} · PATCH /alerts/{alertId} · DELETE /alerts/{alertId} · GET /alerts/{alertId}/preview · POST /alerts/{alertId}/run · GET /alerts/{alertId}/deliveries · GET /alerts/{alertId}/matches · POST /alerts/{alertId}/dismissals/{projectId} · DELETE /alerts/{alertId}/dismissals/{projectId}
added2026-08-22schema 2026-08-18
Alerts can be shared with your team: a share block on create and update.
POST /alerts and PATCH /alerts/{alertId} accept share {mode, userIds, optOutUserIds}. mode is 'private' (default), 'team' (the current team roster, dynamically — future members included, minus optOutUserIds), or 'custom' (exactly userIds, plus you). userIds must come from your own team roster — sharing across accounts is impossible. Sharing requires a team on a paid plan; otherwise the request is refused whole with a new 403 code, sharing_requires_paid_plan. Every alert response now carries a share block with the live member list, and create/update responses add meta.addedMembers when a transition added people — each of them receives a "you've been added" email. This is an additive response-shape change.
Affects: POST /alerts · PATCH /alerts/{alertId} · GET /alerts · GET /alerts/{alertId}
added2026-08-22schema 2026-08-18
Saved lists are readable over the API: /lists, /lists/{listId}, /lists/{listId}/results.
Three new membership-scoped routes, addressed by opaque lst_ ids. Listing and reading a list's configuration are free, and GET /lists/{listId} carries results.billable.credits — the free preflight that prices a pull. GET /lists/{listId}/results delivers the records at 1 credit per record, charged at read time (a list has no run that already billed its records), with records your account already holds unchanged free, a quote-carrying 402, and Idempotency-Key honoured. Filter and polygon lists return their stored criteria plus a search link instead of records, free.
Affects: GET /lists · GET /lists/{listId} · GET /lists/{listId}/results
added2026-08-22schema 2026-08-18
The MCP server regains alert and list tools — 26 tools, 20 of them free.
Restored and new tools: list_alerts, get_alert, create_alert, update_alert, delete_alert, preview_alert, run_alert, get_alert_deliveries, get_alert_matches, dismiss_alert_project, undismiss_alert_project, list_user_lists, get_list and get_list_results. Alert and list configuration tools are free, and so is get_alert_matches — the run that materialised those records already charged for them (this entry originally listed it as metered, which the route has never been); run_alert and get_list_results are metered and each names its free preflight. run_alert requires an explicit maxCredits budget on every call, and delete_alert instructs the model to obtain explicit user confirmation — it deletes the alert for every member. Tools follow the key user's membership, same as the REST routes.
Affects: MCP
changed2026-08-22schema 2026-08-18
notify.emailTo is now additive — member digests always send.
Previously, setting notify.emailTo replaced the alert owner's digest. Under shared alerts every member always receives their own digest, with links resolving on their own account; emailTo addresses are notified in addition, with non-personalised pointers. Remove an address from emailTo to stop notifying it; leaving the alert (or an opt-out in team mode) is how a member stops their digest.
Affects: POST /alerts · PATCH /alerts/{alertId}
removed2026-08-18schema 2026-08-18
Per-field contact confidence is no longer published on CSV or JSON.
contacts[].emailConfidence, phoneConfidence and linkedinConfidence, and companyEnrichment[].phoneConfidence, are no longer on the record. The same keys are gone from the product CSV (Primary Contact Email Confidence and the matching columns on Contacts 2–4). Values still only ship when they clear the internal floor — pattern-inferred and uncorroborated channels stay blank, and contactSummary.lowConfidenceWithheld still counts them. Provenance (emailSource, phoneSource, linkedinSource) is unchanged. contactOptions.minConfidence still raises that floor on a request. This is a breaking response-shape change.
Affects: GET /projects/search · GET /projects/{id} · GET /projects/sync · POST /projects/ai-search · POST /projects/ai-search/jobs · GET /exports/{id}
changed2026-08-16schema 2026-08-13
Find Contact Info is a paid, per-request toggle on CSV and API.
include=contacts is the same Find Contact Info switch as the website CSV export. It is off unless you send it, it only looks up the projects in that request, and it requires a paid plan. Free trial and unpaid keys now receive 402 enrichment_requires_paid_plan instead of names with channels withheld. Live lookup still cannot run inside a JSON GET — use POST /projects/ai-search/jobs, or GET /projects/search?format=csv&include=contacts, which queues the same export job as the map. cachedOnly remains the way to read contacts we already hold on a GET. There is no extra Boardwalk credit.
Affects: GET /projects/search · GET /projects/{id} · POST /projects/ai-search · POST /projects/ai-search/jobs · GET /exports/{id}
added2026-08-13schema 2026-08-13
Enrichment-discovered contacts, registry-resolved companies, and freshness-aware confidence.
contacts[] now also lists external people discovered during enrichment that the filing itself never named — rescued from a document re-read or resolved from a company's registered principals. They are appended after the filing-ranked contacts, so contacts[0] remains the CSV export's Primary Contact, and they carry the same per-channel confidence, provenance and plan-tier rules as every other contact. Records also carry a new companyEnrichment array with the registry's resolved companies for the project — official name, website, LinkedIn page, archetype and main phone. Company names and websites are included on every plan; the remaining company keys are null outside a paid plan. Finally, contact confidence is now freshness-aware: an email confirmed more than 12 months ago, or a phone more than 24 months ago, is served at medium rather than high confidence. The value still ships — only the label changes. These are additive response changes.
Affects: GET /projects/search · GET /projects/{id} · GET /projects/sync · POST /projects/ai-search · POST /projects/ai-search/jobs
added2026-08-12schema 2026-08-12
Overlay-membership filters and fields: Opportunity Zones and FEMA flood zones.
Two filters — inOpportunityZone (the 2018 federal designations) and inSfha (alias inFemaFloodZone; the FEMA Special Flood Hazard Area, the 1%-annual-chance regulatory flood zone) — accept inside or outside and narrow results to projects by their site's membership in the designation, precomputed from the project's addresses and served from the search index; query time never calls a live GIS service. Two nullable boolean response fields with the same names ship in compact, standard and full records so a filtered page is verifiable per record: true is inside, false is a computed outside, and null means membership has not been computed — null never means false. Projects with uncomputed membership match neither inside nor outside. The filters compose with every other dimension, and the recheck-after-collapse guarantee applies: an inside page never delivers a record whose own flag contradicts it. Additive response change; no existing field, default or price changes.
Affects: GET /projects/search · GET /projects/count · GET /projects/sync · GET /projects/{id}
fixed2026-08-10schema 2026-08-10
Structured filters are rechecked against the exact record delivered, and filtered measurements are inspectable.
Search-index matches are now rechecked after merge collapse against the same shaped record and V2 source snapshot used to build the response. Different geography parameters remain ANDed, so state + county + city can no longer return a record that satisfies only one locality. Status, lastUpdated and measurement bounds fail closed when the delivered record contradicts the indexed candidate or lacks the required value. The status and acreage rechecks use the exact source snapshot handed to the response mapper, preventing a concurrent read from making the returned value differ from the value that passed billing. Delivered-value sorts are restored after collapse. Taxonomy's positive type, subtype and asset-class selections retain their documented cross-level OR semantics during the recheck. squareFootage and floorCount are now returned in standard/full records, and building measurements are sourced from explicit V2 building rows; filterable measurement fields are present in the default standard shape. Story and bedroom bounds no longer depend on sparsely populated legacy search-index columns: they scan candidates and are proven from explicit V2 building and unit rows. buildingStories and bedroomCount now serialize those same exact facts. These are correctness and additive response changes. Requests that previously returned stale, contradictory or null-valued matches can return fewer records and charge fewer credits.
Affects: GET /projects/search · GET /projects/count · GET /projects/sync · POST /analytics/aggregate · POST /analytics/rankings · POST /alerts
changed2026-08-08schema 2026-08-08
Filtering on a land use now finds projects that have it, not only projects headlined by it — and every record shows which of its uses matched.
Records carry a new landUses array listing every land use on a project, each to the deepest level we know for it. A mixed-use development lists all of them, and an entry whose record only established a type and a subtype carries assetClass null rather than a parent value copied down, so a search for Single Family shows you which results are detached homes and which are townhomes. A use is listed once: where our source describes the same use twice and one description stops short of the other, only the fuller entry is listed, so a project with Residential/Multifamily and Residential/Multifamily/Apartment Building shows one land use rather than two. Uses that differ at any level are all listed. Procedural items such as rezones and development agreements are not land uses and continue to appear as requestType and isRezone rather than in this list. Each entry carries matchedOn, naming which taxonomy levels of that use satisfied your filter; it is an empty list on uses that did not match and on every use when you filtered on no taxonomy. Because of that annotation, matchRole and matchedClassification are deprecated — they described asset-class matches only, and they still ship unchanged for now. Taxonomy filters also match consistently across all three levels now. Previously only assetClasses broadened to primary-or-secondary while propertyTypes and propertySubtypes required the use to be the project's primary one, so the same corpus answered "does this project have apartments?" and "does this project have single family?" by different standards. Any taxonomy filter now defaults to primary_or_secondary at every level and meta.appliedDefaults says so. Expect more results than before for propertyTypes and propertySubtypes filters, each billed as usual; send taxonomyMatchMode=primary_only for the previous, narrower behaviour. Result order no longer promotes primary-classification matches above the rest, so a page sorted by meetingDate is now genuinely in meetingDate order and pagination for "the most recent N" returns what it says. Finally, assetClass is no longer null on a project that has an asset class recorded somewhere other than its primary classification: it now reports the first one we know, and is blank only when the project has none at all.
Affects: GET /projects/search · GET /projects/count · GET /projects/{id} · GET /projects/sync · POST /projects/ai-search
removed2026-08-07schema 2026-08-07
The ingest timestamp is gone from the API: meetingDate is the only project date.
createdAt described when Boardwalk first recorded a project, not anything about the project itself, and it is no longer on the wire in any form. The createdAtFrom and createdAtTo filters (and their createdFrom and createdTo aliases) are removed and now return 400 unknown_parameter wherever filters are accepted, including alert criteria and the analytics plane. The createdAt field is removed from every representation and from the CSV column set, and sort=createdAt and its created_at alias are no longer accepted. Filter on meetingDateFrom and meetingDateTo instead — they bound the date of the meeting a record was extracted from, which is the project's own history rather than ours. This also removes a trap: because an ingest bound was not a meeting bound, a request carrying only createdAtFrom silently collected the default last-12-months meeting window on top of it, so the same filters could answer very differently depending on which date parameter was used. lastUpdated is unaffected and still filterable via lastUpdatedFrom and updatedSince, which the sync cursor is built from.
Affects: GET /projects/search · GET /projects/count · GET /projects/sync · POST /analytics/aggregate · POST /analytics/rankings · POST /alerts
fixed2026-08-06schema 2026-08-06
Search outages are explicit, and AI-search requests and job results now fail closed.
Structured search and free count now return 503 search_unavailable when RediSearch, the project cache, or the eligibility gate cannot distinguish an outage from an empty market; neither route reports a false empty result or charges for the failed request. AI-search POST bodies now reject non-string prompts, unknown geography IDs, malformed or reversed dates, and incorrectly shaped taxonomy filters instead of coercing or dropping them into a wider search. Each raw geography array is limited to 100 items before deduplication, and result windows are bounded before any search or queue work: offset plus limit cannot exceed 100 synchronously or 1,000 asynchronously, while each job still returns at most 100 records. Async poll payloads include match evidence only for records on the requested page. AI-search cache and eligibility outages now return an explicit 503 on the synchronous route or fail and fully refund an async job instead of reporting a false empty success. Async jobs publish the complete terminal-status set and representation-specific record schemas. Daily screening capacity remains atomically enforced by the worker: a job that reaches it settles partial with partialReason daily_ai_budget_exhausted, confirmed results preserved, and unused reserved credits refunded; a genuinely exhausted corpus still completes with an exact count.
Affects: GET /projects/search · GET /projects/count · POST /projects/ai-search · POST /projects/ai-search/jobs · GET /projects/ai-search/jobs/{id}
fixed2026-08-06schema 2026-08-06
Quickstart billing, billable-route coverage, and rate-limit guidance now match enforcement.
The quickstart now searches the exact filter set it just counted, and count affordability copy describes the future metered call rather than implying that count returns records. The OpenAPI overview now enumerates every billable route from the canonical cost registry instead of describing unlisted delivery routes as free. API-key Bearer capitalization and whitespace now retain the authenticated key's rate-limit bucket, while the documented pre-authentication source-IP and credential-digest safeguards explain which allowance a 401 consumes. The trial truncation policy token is documented as trial_truncate.
Affects: All endpoints · GET /projects/count · GET /projects/search · GET /projects/{id} · GET /projects/sync · GET /alerts/{alertId}/matches · POST /alerts/{alertId}/run
fixed2026-08-06schema 2026-08-06
Public API router failures stay JSON, browser CORS supports write routes, and AI search filters legacy candidates earlier.
Unknown routes and method errors now use the canonical JSON error envelope for wildcard, browser, RFC 7807, and JSON Accept headers while preserving operational headers such as Allow and Retry-After. Browser clients can now preflight every published v1 method, send required Idempotency-Key and optional X-Request-Id headers, and read customer-facing operational response headers. AI search now applies the index-expressible public eligibility rules in all four retrieval channels, reducing the chance that legacy candidates consume the bounded ranked pool before the final record-level checks. The verified job path can use valid structured filters without also requiring a keyword match. The schema version header is documented on every response and exposed to browser clients; meta.schemaVersion remains a convenience echo on selected successful JSON envelopes.
Affects: All endpoints · POST /projects/ai-search · POST /projects/ai-search/jobs
fixed2026-08-06schema 2026-08-06
Customer-facing API behavior, pagination, and reference catalogues now match the published contract.
Framework router and missing-key failures now use the canonical error envelope without legacy top-level fields. County city catalogues are county-scoped; every advertised decision-body and request-type values endpoint exists; account balances include resetsAt; and synchronous AI search backfills a page from its bounded ranked pool after the serving gate rather than letting rejected candidates consume result slots. Search and count publish the same sort vocabulary, direction-bearing aliases report their effective order, the count policy link resolves, and AI search documents both query and q.
Affects: All endpoints · GET /projects/search · GET /projects/count · POST /projects/ai-search · GET /locations/states/{stateId}/counties/{countyId}/cities · GET /reference/decision-bodies · GET /reference/request-types · GET /account/credits
fixed2026-08-05schema 2026-08-05
Response contracts now match the API: uniform errors, explicit pagination, and canonical filters.
Every JSON error now uses the documented error object with code, message, parameter, details, docs URL, and request ID, including authentication and not-found failures. Search responses add pageCount and hasMore, disclose why a match count is exact or partial, and clarify that the deprecated total field is page progress rather than a match total. Every v1 response now carries X-Boardwalk-Schema-Version. The OpenAPI filter list is generated from the same canonical registry the request parser uses, including states, counties, cities, propertyTypes, propertySubtypes, and assetClasses; accepted legacy spellings remain aliases.
Affects: All endpoints · GET /projects/search · GET /projects/count · GET /projects/sync
changed2026-08-02schema 2026-08-02
One rate everywhere: 1 credit per record delivered, including AI search and including contact enrichment.
AI search previously charged a different rate from structured search. It no longer does — a record confirmed by a natural-language query costs the same as one matched by a filter, and candidates that were reviewed and rejected cost nothing. Contact enrichment is inside the same credit: a record costs 1 credit whether or not you asked for contacts and whether or not we found any. No representation affects the price.
Affects: GET /projects/search · GET /projects/{id} · POST /projects/ai-search · POST /projects/ai-search/jobs
added2026-08-02schema 2026-08-02
Free cost preview: GET /projects/count returns a quote before you spend anything.
Takes the same filters as the metered search route and returns matchCount, the billable record count, the credits required, and whether your balance covers it. Free on every plan, and it answers even when your balance is zero. A 402 now carries the same quote object in error.details.quote, along with a countUrl that prices the exact filter set that was refused.
Affects: GET /projects/count · GET /projects/search
changed2026-08-02schema 2026-08-02
A paid request that costs more than your balance is refused, not partially delivered.
Requests that would exceed the balance return 402 with a quote and deliver nothing. Nothing is charged. Free-trial keys are the documented exception — they deliver what the trial balance affords and report it in meta.trialTruncation.
Affects: GET /projects/search · POST /projects/ai-search/jobs
added2026-08-02schema 2026-08-02
Delivery state on every record, and no charge for an unchanged re-delivery.
Every record carries delivery.alreadyExported, delivery.updatedSinceExport, delivery.lastExportedAt and delivery.billed, on the API and in the CSV export alike. A record this pool already holds, which has not changed since, is delivered again without a charge. meta.delivery totals it per response.
Affects: GET /projects/search · GET /projects/sync · GET /alerts/{alertId}/matches · CSV export
added2026-08-02schema 2026-08-02
Incremental sync — GET /projects/sync walks by cursor in last-updated order, losslessly.
Offset paging over a changing dataset can skip records. The sync feed cannot, and it refuses sort, order, offset and page because a caller-chosen ordering would break that guarantee while appearing to work.
Affects: GET /projects/sync
added2026-08-02schema 2026-08-02
Alerts over the API — saved searches on a cadence. Configuration free, delivery billed.
Create, list, update, preview, run and delete alert rules; read delivery history and the records each run delivered. Everything except delivery is free.
Affects: /alerts
added2026-08-02schema 2026-08-02
A free analytics plane — counts, group-bys and rankings that never return record identity.
/analytics/datasets describes what can and cannot be answered; /analytics/aggregate and /analytics/rankings answer it. Free, with a small-group suppression floor.
Affects: /analytics
changed2026-08-02schema 2026-08-02
Responses are now an explicit allow-list, and project ids are integers.
The record payload is a declared contract rather than whatever the serving cache happened to hold. Every field is declared with a type, a unit and a description; a field that is not declared cannot be emitted. Project ids are JSON integers, not numeric strings. Timestamps are ISO-8601. Enums ship as a frozen machine token plus a human label.
Affects: GET /projects/search · GET /projects/{id} · GET /projects/sync
changed2026-08-02schema 2026-08-02
Values are held to Boardwalk's current extraction standard, or the field is empty.
A value produced by a superseded extraction no longer appears in any served field. Where the current standard produced nothing, the field is empty rather than backfilled. Some records therefore carry fewer values than they did. Filtering is not yet held to the same standard — see /docs/api/data-availability/, which will be updated when that lands.
Affects: GET /projects/search · GET /projects/{id} · GET /projects/sync
changed2026-08-02schema 2026-08-02
Strict input: an unknown query parameter is a 400 that costs nothing, rather than being silently ignored.
Dropping an unrecognised filter silently would run a broader query than was asked for and bill the difference. Unknown parameters, out-of-enum values and unresolvable place names are now refused with the offending parameter named.
Affects: GET /projects/search · GET /projects/count · GET /projects/sync
changed2026-08-02schema 2026-08-02
API keys: bwk_live_ and bwk_test_ prefixes, team ownership, and 24-hour rotation overlap.
New keys carry their environment in the prefix. Keys issued before the Boardwalk rename begin with csk_ and are accepted indefinitely. Keys are held by the team and attributed to their creator, several may be held at once, and a rotated key keeps working for 24 hours while its responses carry X-Boardwalk-Key-Rotating and a Warning header. A lapsed subscription keeps its keys and receives 402 subscription_required.
Affects: All endpoints
added2026-08-02schema 2026-08-02
Contact enrichment over the API, inside the same credit, as a per-request toggle.
include=contacts on an asynchronous request. Government staff contact details are never returned on any tier; free-trial keys receive names and titles with contact channels withheld and a stated reason. Coverage is narrower than our project coverage and there is no freshness guarantee — both are documented rather than implied.
Affects: POST /projects/ai-search/jobs
added2026-08-02schema 2026-08-02
Per-key rate limits with headers, ULID request ids, and idempotency on billable requests.
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a 429 also carries Retry-After. Every response carries X-Request-Id, which is echoed if you supply your own, and which appears in our logs and on your usage row. An Idempotency-Key on a billable request replays the original result instead of charging twice.
Affects: All endpoints
removed2026-08-02schema 2026-08-02
Removed from responses: image references, source-document file paths, and pipeline-version markers.
These were never part of a declared contract and are no longer emitted. If you were reading one, it was reading an internal implementation detail out of a cache blob.
Affects: GET /projects/search · GET /projects/{id} · GET /documents/{id}
Ready to make a call?
A free Boardwalk trial includes API access, 50 record-export credits (search, CSV, MCP), and 5 Find Contact Info lookups. Counting, taxonomy, location lookups and the analytics plane cost nothing, so you can evaluate the data before you spend a credit.