If you're a contractor, trade or building-supply company, the earliest public sign of a project is usually a development application or a building permit. A rezoning in Toronto, a new detached garage permit in Calgary, a renovation permit in Montréal: each one is a project that will need people and materials, and each one is published by the city.
The data is open. The problem is that every city publishes it differently: different portal software (CKAN, ArcGIS and others), different field names, different date meanings, French in Montréal. This post shows the general approach for turning that into one weekly list of leads, and the Python to run it.
Disclosure: I'm Aurelix, and I built the Canada Development Applications Actor used below. It's a paid tool (prices in the Cost section). Everything it reads is open data, so you can also build this yourself from the city portals.
The general approach
- Know which datasets exist. Not every city publishes applications. Some publish only issued permits, which come later in the process. Both are useful, but they answer different questions: applications tell you a project is being planned; issued permits tell you work is about to start.
- Normalise to one schema. Address, type, status, the key dates, value, and the builder company where published.
- Filter for your trade. By type ("new", "commercial", "addition"), by keywords in the description ("storey", "mixed-use", "basement"), by ward, and by project value.
- Fetch only what's new or changed. A weekly job should surface new applications and the ones whose status moved (Under Review to Approved is often the moment to call).
- Respect privacy. Permit data can name owners and applicants. Keep companies, drop individuals.
The datasets
The Canada Building Permits Scraper Actor reads 10 official open-data datasets, chosen with the cities input:
cities value |
What it is |
|---|---|
toronto |
Development applications: rezoning, official plan, site plan, subdivision, condo, part lot |
toronto-building |
Building permit applications and issued permits |
calgary |
Development permits |
calgary-building |
Issued building permits |
edmonton |
Development permits |
edmonton-building |
Issued building permits |
vancouver |
Issued building permits |
winnipeg |
Issued development permits |
kitchener |
Issued building permits (refreshed weekly) |
montreal |
Issued building, alteration and demolition permits (refreshed weekly) |
Other inputs:
-
daysBack(0 to 365, default 60): keep records dated on or after today minus this many days. Which date depends on the dataset: submitted date for Toronto and Calgary development permits, decision date for Edmonton development permits, issue date for the rest. -
applicationTypes,statuses,wards,keywords: case-insensitive "contains" filters. Empty means all. -
maxResultsPerCity(default 1,000; 0 = no limit), newest first. -
incrementalandstateKey: return only records not returned before plus records whose status changed.
A record:
{
"app_id": "calgary-building:BP2026-14894",
"city": "Calgary",
"province": "AB",
"application_number": "BP2026-14894",
"application_type": "Residential Improvement Project",
"category": "1113 - Detached Garage Dwelling Unit - New",
"address": "83G MARBANK WY NE",
"addresses": ["83G MARBANK WY NE"],
"postal_code": null,
"ward": null,
"neighbourhood": "MARLBOROUGH",
"description": "Additional Dwelling Unit",
"status": "Issued Permit",
"submitted_date": "2026-08-07",
"decision_date": null,
"issued_date": "2026-11-02",
"project_value": 113393.31,
"land_use": null,
"permit_category": null,
"contractor_company": "AIM BUILDER & DEVELOPMENT",
"latitude": 51.06397737432121,
"longitude": -113.97820309651591,
"url": "https://data.calgary.ca/resource/c2es-76ed.json?permitnum=BP2026-14894",
"source_url": "https://data.calgary.ca/resource/c2es-76ed.json?permitnum=BP2026-14894",
"scraped_at": "2026-11-04T18:47:17Z"
}
Two fields matter most for contractors: project_value (published for Toronto, Calgary and Edmonton building permits, Vancouver and Kitchener) and contractor_company (Toronto and Calgary building permits, Kitchener). A builder is returned only when it's a company, never a named individual.
The weekly script
pip install apify-client
import csv
import os
from collections import Counter
from datetime import date
from apify_client import ApifyClient
MIN_VALUE = 250_000
RUN_INPUT = {
"cities": ["toronto", "toronto-building", "calgary-building", "kitchener"],
"daysBack": 14,
"keywords": ["storey", "mixed-use", "addition", "new"],
"maxResultsPerCity": 1000,
"incremental": True,
"stateKey": "contractor-weekly",
}
COLUMNS = [
"city", "application_number", "status", "application_type", "category",
"address", "neighbourhood", "ward", "submitted_date", "issued_date",
"project_value", "contractor_company", "description", "url",
]
def main():
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("aurelix/canada-building-permits-scraper").call(run_input=RUN_INPUT)
records = list(client.dataset(run["defaultDatasetId"]).iterate_items())
def keep(r):
value = r.get("project_value")
return value is None or value >= MIN_VALUE
leads = [r for r in records if keep(r)]
leads.sort(key=lambda r: r.get("project_value") or 0, reverse=True)
out = f"projects_{date.today().isoformat()}.csv"
with open(out, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=COLUMNS, extrasaction="ignore")
writer.writeheader()
writer.writerows(leads)
print(f"{len(records)} new or changed records, {len(leads)} written to {out}")
by_city = Counter(r["city"] for r in leads)
for city, n in by_city.most_common():
print(f" {city:12} {n}")
builders = Counter(r["contractor_company"] for r in records if r.get("contractor_company"))
if builders:
print("\nMost active builders this period:")
for name, n in builders.most_common(10):
print(f" {n:3} {name}")
if __name__ == "__main__":
main()
What it does:
-
14 days back with
incrementalon. A weekly schedule with a two-week window tolerates a skipped week; incremental mode stops you seeing the same permit twice, but still re-surfaces it if its status changes. - Keeps records without a value. Toronto development applications don't publish one, and a rezoning is often the most interesting lead of all. Filtering them out by value would throw away the earliest signals.
- Counts active builders. If you're a supplier or subcontractor, the companies pulling the most permits in your area are your prospects. If you're a general contractor, they're your competition.
Tuning it for your trade
-
Roofing, windows, exteriors:
keywords: ["addition", "new", "replace"]on the building-permit datasets. -
Commercial fit-outs:
applicationTypes: ["commercial"], or keywords like"tenant improvement"and"change of use". Restaurant and retail openings often show up as changes of use. -
Multi-unit residential: Toronto development applications with
applicationTypes: ["zoning", "site plan"]andkeywords: ["storey", "mixed-use"]. These are months or years ahead of construction, which is exactly when developers pick consultants and trades. -
One part of a city:
wardstakes a number or name (only Toronto development applications, Calgary and Edmonton development permits and Winnipeg publish a ward), or filter onlatitude/longitudein Python for a radius around your yard. - Montréal: records are in French as published, so use French keywords that match how the city words its permits.
Limits
- Seven cities only. Toronto Committee of Adjustment, Mississauga and Ottawa aren't covered because they have no current open dataset.
-
Applications vs permits. Vancouver, Kitchener and Montréal are issued building permits and Winnipeg is issued development permits, not applications. Edmonton development permits publish only a decision date. Toronto building permits include applications not yet issued (empty
issued_date) and one record per permit revision. - Toronto lag. Toronto adds applications to its dataset some weeks after submission, so very short look-backs can return nothing for Toronto. The default is 60 days for this reason; my 14-day window works only because incremental mode carries earlier finds forward. If you start fresh, run once with 60 days.
- Refresh rates differ. Toronto, Calgary, Edmonton, Winnipeg and Vancouver refresh daily; Kitchener and Montréal about weekly, so look back at least 8 days if you rely on them.
- Coordinates and links. Toronto coordinates are converted from the city's MTM grid and accurate to about 20 m; Toronto building permits have no coordinates. Most links point to the open-data record rather than a city web page.
- Look-back is capped at 365 days.
- Descriptions are free text. Emails and phone numbers are removed; the rest is as published, which can be terse or inconsistent.
- Owner, applicant and planner names are never returned. If you contact a builder company, keep it relevant and individual and follow CASL.
Why this tool over the alternatives
Compared with the leading alternatives on the Apify Store (the full table is on each Store page):
- $3.00 per 1,000 records vs $3.50 and $5.00 for the other Canadian permit scrapers.
- The only one with development applications (rezonings, site plans) and a ward filter, across 7 Canadian cities against 5 and 4.
Cost
At the time of writing, the README lists $3.00 per 1,000 applications on Apify's free plan and $2.10 per 1,000 on paid plans (Bronze, Silver, Gold), plus $0.00005 per run. Apify's free plan includes $5 of credit a month, which the README puts at about 1,600 applications.
Because you pay per record returned after filters, narrow keywords and incremental mode keep a weekly run cheap: 300 new or changed records a week would be $0.90.
Links
- Canada Building Permits Scraper
- The underlying portals: open.toronto.ca, data.calgary.ca, data.edmonton.ca, opendata.vancouver.ca, data.winnipeg.ca, open-kitchenergis.opendata.arcgis.com and donnees.montreal.ca, all under open-government licences (Montréal under CC BY 4.0).
Top comments (0)