Using the API
Pagination and filters
Cursor paging, page sizes, and the filters every list shares.
The list envelope
Every list answers in the same envelope:
| Field | What it holds |
|---|---|
query | The filters as the API read them, defaults included. |
as_of | When the API answered, an ISO 8601 instant in UTC. |
results | The page of records. |
next_cursor | Pass it as cursor for the next page. It is null on the last page. |
environment | sandbox or live. Sandbox answers carry fictional towns. |
Page size
| Where | Default | Smallest | Largest |
|---|---|---|---|
REST, limit | 25 | 1 | 100 |
MCP tools, limit | 10 | 1 | 50 |
Ask for a page size with limit. A value outside the range is refused with 400 invalid_request. Each page is one request, and is billed as one.
Reading every page
Pass next_cursor back as cursor, with the same filters, until it is null. A cursor is opaque: do not build or change one.
const base = 'https://data.heytownhall.com/v1/meetings';
const params = new URLSearchParams({ jurisdiction: 'port-ellery', limit: '100' });
const meetings = [];
for (;;) {
const res = await fetch(`${base}?${params}`, { headers: { Authorization: `Bearer ${process.env.HTD_KEY}` } });
const page = await res.json();
if (!res.ok) throw new Error(`${page.error.code}: ${page.error.message}`);
meetings.push(...page.results);
if (page.next_cursor === null) break;
params.set('cursor', page.next_cursor);
}import os
import requests
params = {"jurisdiction": "port-ellery", "limit": 100}
headers = {"Authorization": f"Bearer {os.environ['HTD_KEY']}"}
meetings = []
while True:
page = requests.get("https://data.heytownhall.com/v1/meetings", params=params, headers=headers, timeout=30).json()
if "error" in page:
raise RuntimeError(page["error"]["message"])
meetings.extend(page["results"])
if page["next_cursor"] is None:
break
params["cursor"] = page["next_cursor"]Filters
Each list takes its own set of filters, and each record page lists them. These are the ones lists share:
| Filter | What it takes |
|---|---|
jurisdiction | A jurisdiction id from GET /v1/jurisdictions, such as victoria. |
region | A two-letter province or state code, such as BC. |
country | CA or US. |
kind | The record's kind. Each endpoint says which kinds it holds. |
topic | A business topic id, such as short_term_rentals. |
from | On or after this date, YYYY-MM-DD, on the family's own date. |
to | On or before this date, YYYY-MM-DD. It must not be before from. |
since | Records read at or after this ISO 8601 instant, such as 2026-10-01T00:00:00Z. What is new since your last read. |
meeting | A meeting id, for the records that hang off a meeting. |
A few lists take one more: motion on votes, report on financial lines, parent and name on jurisdictions, and after on the change feed.
What a list refuses
A list never quietly ignores what you send, because an answer with a filter dropped would look like an answer to the question you asked.
- A parameter the endpoint does not take is refused with
400 invalid_request, andparamnames it. - A parameter sent twice is refused the same way.
- A value in the wrong form is refused, and the message says the form it wants.
- A blank value counts as not sent:
?kind=asks for nothing.
The default page size is 25, so a request with no limit returns at most 25 records.