Skip to content

ZoneWire API

Read access to the council-meeting zoning record: which counties we cover, what was on the agenda, how the board ruled, and what changed since your last sync.

Base URL https://www.zonewire.co/api/v1. MCP server https://mcp.zonewire.co.

What this API is

ZoneWire monitors public city council, county commission and planning commission meetings, transcribes them, and turns the land-use items into a structured record: the project, the case, the vote, and the conditions attached to an approval. This API is read-only access to that record. There is nothing to write and no way to file anything with a jurisdiction through it.

Two of the six endpoints are reference indexes. Search and the changes feed hand back ids, names, counties and dates so you can find the record you want; you then pay for the detail endpoint that carries the full card. That split is deliberate: it keeps a cheap lookup cheap and keeps the paid corpus behind the tier that buys it.

Every response is private, no-store. Nothing here is a CDN surface, and none of it is meant to be cached by a shared proxy.

Authentication

Every request carries an API key in the Authorization header. Only ZoneWire keys are accepted; a Supabase session token is refused with a 401.

Authorization: Bearer zw_live_...

Mint a key on your account page. An account can hold 5 active keys at a time; revoke one to mint another. The raw secret is shown exactly once, at mint time, and is never retrievable afterwards. We store a hash, so a lost key has to be revoked and replaced.

The account page mints a plain key. To narrow a key to a subset of your counties, call POST /api/v1/keys directly and pass restrict_counties in the JSON body, an array of active county ids. In this release that field is available on the mint API only, not in the account UI. A restriction always intersects the scope your subscription already grants, and never expands it: restricting a key to a county you do not pay for leaves that county unreachable.

curl -X POST https://www.zonewire.co/api/v1/keys \
  -H "Content-Type: application/json" \
  --cookie "your browser session" \
  -d '{"name": "sync worker", "restrict_counties": [1, 8]}'

{
  "id": "0f2c...",
  "key": "zw_live_...",
  "prefix": "zw_live_AbC3"
}

The key field is the raw secret. Store it now. The mint and revoke routes are session authenticated (they are the account UI talking to itself), so they take your browser session rather than a bearer key.

Endpoints

Six endpoints. All are GET, and all take county as a slug where a county filter applies. Search and decisions also report a real total alongside whatever rows your tier is served. Counties returns a plain list with no total, and the changes feed never counts: a count over the whole window is work no caller reads.

GET /api/v1/me

Who this key is: its tier, the counties it can reach, and the key's own metadata. Every tier gets a 200, so this is the call to make when validating a key.

curl -H "Authorization: Bearer zw_live_..." \
  https://www.zonewire.co/api/v1/me

GET /api/v1/counties

The active market catalog: id, name, state and slug for every county ZoneWire covers. Every tier gets the whole list, because this is how you turn a county name into the slug the other endpoints take.

curl -H "Authorization: Bearer zw_live_..." \
  https://www.zonewire.co/api/v1/counties

GET /api/v1/search?q=rezoning

One keyword lookup across three sources: the development projects catalog, the decision record, and meeting agendas. Params: q (required), kind (project, decision or agenda), county, limit (1 to 50), cursor.

  • Results come back in ascending id order, and are not ranked by relevance. That order is not chronological either: project and decision ids sort as strings, and agenda ids follow the order we ingested the meeting. The first row is simply the lowest id, not the best or the oldest match. Narrow with kind and county rather than expecting the top of the list to be the answer, and sort on meeting_date yourself if you need time order.
  • q is truncated to 80 characters rather than rejected, and filter metacharacters are stripped from it before the search runs. The 2 character minimum is applied after that stripping, so a short query made mostly of punctuation can come back as a 400 even though what you sent was longer.
  • Agenda matching is stemmed full-text search, not substring. A quoted phrase works; a word fragment does not.
  • The searchable agenda set is wider than the public website. It can include meetings whose public page does not resolve, because the site publishes a narrower set of meeting statuses than the index covers. Treat a search hit as a pointer to a meeting id, not as a promise that a public URL exists for it.
  • With no kind, you get a summary: the head of each source plus honest per-source totals, and no cursor.
curl -H "Authorization: Bearer zw_live_..." \
  "https://www.zonewire.co/api/v1/search?q=data+center&kind=agenda&limit=20"

GET /api/v1/meetings/{id}

One meeting: title, date, county, the computed insights, and the zoning mentions found in the transcript. Paid endpoint; a free key gets a 403 before the meeting is even read. Transcript text and raw audio payloads are never served here.

curl -H "Authorization: Bearer zw_live_..." \
  https://www.zonewire.co/api/v1/meetings/7287

GET /api/v1/decisions

The decision catalog, newest hearing first. Params: county, since (ISO date, filters on meeting_date), limit (1 to 100), cursor. This is catalog browse, not sync: since is when the hearing happened, not when we learned about it.

curl -H "Authorization: Bearer zw_live_..." \
  "https://www.zonewire.co/api/v1/decisions?county=clark-county&since=2026-07-01&limit=25"

GET /api/v1/changes

Incremental sync. One row per record that changed, with kind, id, county_slug, op, as_of_date and content_hash. Params: since (ISO date, filters on as_of_date), kind (sellable_project, sellable_decision or council_meeting), county, limit (1 to 500), cursor. Paid endpoint. Poll this, then fetch detail for the ids that moved.

  • Both paid tiers see a rolling 90 day window. An older since is clamped to the floor rather than rejected. Full history is an Enterprise conversation.
  • For a Pro key this feed is upserts only. Delete tombstones are written without a county on them and a deleted record is gone from the source table, so a deletion cannot be attributed to a county after the fact. Serving an unattributable row to a county-scoped key would leak ids outside its scope, so Pro keys never receive them. Elite keys, which filter nothing, do.
  • The practical consequence for a Pro mirror: a record that stops appearing is possibly deleted, and you cannot tell from this feed alone. Re-check the detail endpoint for records that have gone quiet, or use an Elite key if deletions have to be exact.
curl -H "Authorization: Bearer zw_live_..." \
  "https://www.zonewire.co/api/v1/changes?since=2026-08-01&kind=sellable_decision"

Tiers

Your key inherits the tier of the account that minted it. A lapsed subscription does not break the key; it is served as free.

EndpointFree (and the demo key)ProElite
GET /meFull. Returns tier free plus the counties granted to the account.Full.Full.
GET /countiesFull active catalog. This list is public, so no tier is sampled here.Full active catalog.Full active catalog.
GET /search3 rows, plus truncated: true, the real total, and upgrade_url. No cursor.Full results across the counties on your plan, cursor paged.Full results across every active county, cursor paged.
GET /meetings/{id}403 tier_required. There is no sampled shape for meeting detail.Full detail for meetings in the counties on your plan.Full detail for any meeting in any active county.
GET /decisions5 rows whose cards are run through the free field gate, plus truncated: true, the real total, and upgrade_url. No cursor.Full cards for the counties on your plan, cursor paged.Full cards for every active county, cursor paged.
GET /changes403 tier_required. There is no sampled shape for the changes feed.Last 90 days, upserts only, for the counties on your plan.Last 90 days, upserts and deletes, for every active county.

A sampled response is honestly truncated, not quietly trimmed. It keeps the full schema, sets truncated: true, and reports the real total plus an upgrade_url, so you always know how much you are not seeing. On the decisions endpoint the free rows keep every field name and null out the paid values, rather than dropping the fields.

Pro covers the counties on your plan. Elite covers every active county, currently about 161 markets, and is the only tier that receives delete rows on the changes feed. Enterprise is a contract, not a checkout: it is where full history beyond the 90 day window and redistribution rights live. Email support@zonewire.co to start that conversation, or see pricing.

Errors

Two authentication failures, both 401, told apart by code:

  • missing_api_key: no Authorization header, or it was not a Bearer header.
  • invalid_api_key: the token was not a zw_live_ key, or the key is unknown, or it has been revoked.

Both carry code and message. The 400, 404 and 500 bodies use error instead of code, with values bad_request, not_found and internal_error. Parse both field names.

There is exactly one 403 shape, served as application/problem+json. It covers both paywall branches and tells you what would unlock the call:

{
  "code": "tier_required",
  "required_tier": "pro",
  "upgrade_url": "https://www.zonewire.co/pricing?src=api_402",
  "message": "Meeting detail is a paid endpoint. Pro covers the counties on your plan; Elite covers every active county."
}

The same shape with code set to forbidden_county and required_tier set to elite means your key is paid but the county you asked for is not on it. The body never reports counts or ids you have not paid for, so it cannot be used as an oracle for the size of what is behind the gate.

Paging with cursors

Search, decisions and changes page with an opaque cursor. When a response carries a non-null next_cursor, pass it back verbatim as the cursor param on the next request. A null next_cursor means you have reached the end.

curl -H "Authorization: Bearer zw_live_..." \
  "https://www.zonewire.co/api/v1/decisions?county=clark-county&cursor=eyJtZWV0aW5nRGF0ZSI6..."
  • Treat the cursor as opaque. Do not decode it, edit it, or build one. Its contents are an implementation detail and will change.
  • Free keys and the shared demo key cannot page. Sending a cursor on a free key returns 400 bad_request rather than a page. The sample is the whole of what the free tier serves, and paging past it is what a paid tier buys.
  • On search, a cursor applies to a single source. Send kind with it, and send the same kind the cursor was issued for.
  • On search and decisions the cursor carries the total from the request that issued it, so a count stays stable across a paging run instead of shrinking page by page. The changes cursor carries only a scan position; that endpoint reports no total at all.
  • On the changes feed a page can come back empty while the cursor still advances: the scan moves forward and the page is then filtered to the counties your key covers. Keep following the cursor until next_cursor is null.

MCP server

The same six reads are exposed as MCP tools for agent clients. Connect to https://mcp.zonewire.co. The transport is served at the root, and /mcp is an alias for the same endpoint. It runs stateless: no session ids and no state between requests.

Bring your zw_live_ key as an Authorization: Bearer header. The server holds no credentials of its own and proxies your key straight through, so tier and county scope behave exactly as they do on the REST API.

Clients must send Accept: application/json, text/event-stream. The streamable HTTP transport requires both media types, and a request missing either one is rejected with a 406 before it reaches a tool.

{
  "mcpServers": {
    "zonewire": {
      "url": "https://mcp.zonewire.co",
      "headers": { "Authorization": "Bearer zw_live_..." }
    }
  }
}

The six tools:

  • whoami: tier, county scope and key metadata.
  • list_counties: the active market catalog.
  • search: keyword lookup across projects, decisions and agendas.
  • get_meeting: one meeting's insights and zoning mentions.
  • list_decisions: the decision catalog.
  • list_changes: the incremental changes feed.

Failures do not come back as protocol errors. Every failed tools/call returns a successful tool result with isError: true, carrying the REST API's own error body as text. A 403 therefore arrives as readable content your agent can act on, upgrade link included, instead of a transport-level failure it has to guess about.

Try it without an account

A demo key is open to everyone. It is forced to the free tier, so responses are sampled: 3 rows on search, 5 gated rows on decisions, a 403 on meeting detail and on the changes feed. It is shared with everyone else trying it and carries no per-caller quota, so treat it as a way to see the response shapes rather than as a dependency. It rotates without notice, and it is baked into this page at build time, so a rotation only reaches you after a redeploy rather than instantly. Mint your own key for anything you intend to keep running.

curl -H "Authorization: Bearer zw_live_8wvtwatQql221i1PjIPOx66NAC17w9q3kH5Jmh5Tft0" \
  "https://www.zonewire.co/api/v1/search?q=rezoning"

Get a key

Mint one on your account page. Questions, or an Enterprise use case, go to support@zonewire.co.