Skip to Content
Hey Town Hall Data docs

Using the API

Pagination and filters

Cursor paging, page sizes, and the filters every list shares.

View as Markdown

The list envelope

Every list answers in the same envelope:

FieldWhat it holds
queryThe filters as the API read them, defaults included.
as_ofWhen the API answered, an ISO 8601 instant in UTC.
resultsThe page of records.
next_cursorPass it as cursor for the next page. It is null on the last page.
environmentsandbox or live. Sandbox answers carry fictional towns.

Page size

WhereDefaultSmallestLargest
REST, limit251100
MCP tools, limit10150

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.

JavaScript
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);
}
Python
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:

FilterWhat it takes
jurisdictionA jurisdiction id from GET /v1/jurisdictions, such as victoria.
regionA two-letter province or state code, such as BC.
countryCA or US.
kindThe record's kind. Each endpoint says which kinds it holds.
topicA business topic id, such as short_term_rentals.
fromOn or after this date, YYYY-MM-DD, on the family's own date.
toOn or before this date, YYYY-MM-DD. It must not be before from.
sinceRecords read at or after this ISO 8601 instant, such as 2026-10-01T00:00:00Z. What is new since your last read.
meetingA 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, and param names 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.

On this page