Recipes
Export a town's bylaws
Write every bylaw a town has published to a CSV file, page by page, with a link to each one.
After this recipe you can write one town's bylaws to bylaws.csv, with each bylaw's number, title, kind, subject, adoption date and link to the town's own copy.
Uses: GET /v1/bylaws, one records request a page of up to 100.
How it works
- List the town's bylaws with
jurisdictionset, 100 to a page. - Write each page's rows, then follow
next_cursoruntil it isnull.
To narrow the export, add subject (such as zoning or fees_charges) or kind (bylaw, amendment, consolidated or repeal). The text of each bylaw is at its source_url.
The code
import csv
import os
import requests
BASE = "https://data.heytownhall.com"
TOWN = "marlow-county"
FIELDS = ["number", "title", "kind", "subject", "adopted_on", "source_url", "read_at"]
HEADERS = {"Authorization": f"Bearer {os.environ['HTD_KEY']}"}
params = {"jurisdiction": TOWN, "limit": 100}
rows = 0
with open("bylaws.csv", "w", newline="", encoding="utf-8") as out:
writer = csv.DictWriter(out, fieldnames=FIELDS, extrasaction="ignore")
writer.writeheader()
while True:
res = requests.get(f"{BASE}/v1/bylaws", params=params, headers=HEADERS, timeout=30)
page = res.json()
if not res.ok:
raise RuntimeError(f"{page['error']['code']}: {page['error']['message']}")
writer.writerows(page["results"])
rows += len(page["results"])
if page["next_cursor"] is None:
break
params["cursor"] = page["next_cursor"]
print(f"Wrote {rows} bylaws to bylaws.csv")import { writeFileSync } from 'node:fs';
const BASE = 'https://data.heytownhall.com';
const TOWN = 'marlow-county';
const FIELDS = ['number', 'title', 'kind', 'subject', 'adopted_on', 'source_url', 'read_at'];
const headers = { Authorization: `Bearer ${process.env.HTD_KEY}` };
const cell = (v) => (v === null || v === undefined ? '' : /[",\n]/.test(String(v)) ? `"${String(v).replaceAll('"', '""')}"` : String(v));
const lines = [FIELDS.join(',')];
let cursor = null;
do {
const url = new URL('/v1/bylaws', BASE);
url.searchParams.set('jurisdiction', TOWN);
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url, { headers });
const page = await res.json();
if (!res.ok) throw new Error(`${page.error.code}: ${page.error.message}`);
for (const bylaw of page.results) lines.push(FIELDS.map((f) => cell(bylaw[f])).join(','));
cursor = page.next_cursor;
} while (cursor);
writeFileSync('bylaws.csv', `${lines.join('\n')}\n`);
console.log(`Wrote ${lines.length - 1} bylaws to bylaws.csv`);One page from the shell, with jq:
curl -s "https://data.heytownhall.com/v1/bylaws?jurisdiction=marlow-county&limit=100" \
-H "Authorization: Bearer $HTD_KEY" \
| jq -r '["number","title","kind","subject","adopted_on","source_url"], (.results[] | [.number, .title, .kind, .subject, .adopted_on, .source_url]) | @csv'What the sandbox writes
number,title,kind,subject,adopted_on,source_url,read_at
1503,Water Utility Bylaw,bylaw,other,2026-08-13,https://marlowcounty.example/bylaws/400013.pdf,2026-10-30T16:00:00.000Z
1502,"Fees and Charges Bylaw, Consolidated",consolidated,fees_charges,2026-07-13,https://marlowcounty.example/bylaws/400012.pdf,2026-10-30T16:00:00.000Z
1501,Short-Term Rental Regulation Amendment Bylaw,amendment,business_licence,2026-06-13,https://marlowcounty.example/bylaws/400011.pdf,2026-10-30T16:00:00.000Z
1500,Zoning Bylaw,bylaw,zoning,2026-05-13,https://marlowcounty.example/bylaws/400010.pdf,2026-10-30T16:00:00.000ZGoing further
- Keep it current with
since:since=2026-11-01T00:00:00Zlists only the bylaws read at or after that instant. Or followbylaw.seenevents in the change feed. - The terms a bylaw defines are in
GET /v1/defined-terms, with the start of each definition. - Every town at once: leave out
jurisdictionand setregion=BC. Addjurisdiction_idto the columns.