# 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.

```js
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:

| 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](https://data.heytownhall.com/docs/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.
