Toronto Building Permit Status Delta
Pricing
$10.00 / 1,000 permit status changeds
Toronto Building Permit Status Delta
Watches Toronto's open-data building permit registry by permit type and reports only real status changes: new active permits, status-cycle moves (Under Review, Permit Issued, Inspection, Closed...), and permits leaving the active slice (two-run confirmed). Free when nothing changes.
Pricing
$10.00 / 1,000 permit status changeds
Rating
0.0
(0)
Developer
Radu Furtuna
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
2 days ago
Last modified
Categories
Share
Durable, informational monitor of the City of Toronto's official "Building Permits — Active Permits"
open dataset (CKAN Open Data Portal, resource_id=6d0229af-bc54-46de-9c2b-26759b01dd05) — free, public,
no API key, no account, no captcha, no browser.
This is a mirror-with-diff of a public government dataset, not legal, real-estate, or construction
advice. It does not judge whether a project is legal, complete, or approved — it tells you, reliably
and cheaply, when a permit's STATUS actually changes (Application Received → Under Review → Permit
Issued → Inspection → Closed, and more), a new permit shows up in the active-permit slice, or a
previously-tracked permit drops out of it (confirmed over two runs).
Why
The city already publishes the full active-permit table for free, but there is no durable "what changed
since I last checked" — you either poll the whole table yourself and diff it client-side, or you don't
watch at all. Every competing scraper/exporter we found for this dataset is a one-off CSV dump; none
tracks status transitions over time. Contractors, real-estate agents, inspectors, and neighbours use a
status change (e.g. "Permit Issued", "Closed") as a real, actionable signal. This actor keeps that diff
for you: a list of watches (one City of Toronto PERMIT_TYPE each), a durable per-permit content hash
between runs, and a bill only for permits that are genuinely new, changed, or confirmed gone.
How it works
- Each
watchtracks the entire active-permit slice for onepermitType— not a window of recent activity. This is a deliberate, load-bearing difference from a "last N by date" design: a permit issued months ago can change status today, and a date-window monitor would silently miss exactly that transition. Every run therefore re-reads the whole slice for each watch, page by page (datastore_search?filters={"PERMIT_TYPE":...}&limit=5000&offset=N), and only accepts it as complete if every page's declared row count and the source's own reportedtotalagree from start to finish. - The identity of one tracked item is the pair
(PERMIT_NUM, REVISION_NUM)—PERMIT_NUMalone is not unique in this dataset (a permit can carry multiple revisions). - The first run for a watch establishes a baseline: every permit in the slice gets a content hash
computed from its lifecycle fields (
STATUS+ application/issued/completed dates) and stored — nothing is billed or delivered. A baseline is only ever committed from a complete slice: any truncated page, any mismatch in the source's declared row count, or a slice larger than this actor's safety cap leaves the index untouched and reports the run honestly instead of silently producing a partial "everything is new" baseline. - Every later run compares the fresh slice against the stored index:
- a pair never seen before (or previously confirmed removed) is new;
- a pair seen before whose lifecycle content hash differs is status_changed;
- a pair present in the index but missing from two consecutive clean full-slice reads is
removed — a single missing read (a pagination race on a live table) does not count; the pair
simply keeps its history and can return later as a normal
status_changed.
- A hard per-watch cap (
maxResultsPerWatch) protects you from a single run billing/delivering an unbounded backlog on the very first post-baseline run — anything over the cap is picked up cleanly on the next run.
Input
{"monitorId": "my-toronto-watch","watches": [{ "watchId": "rental-renovation", "permitType": "Rental Renovation Licence" }],"maxResultsPerWatch": 200,"notifyOn": "new_alerts","webhookUrl": "https://example.com/webhook"}
watches— 1-5 objects, each{watchId, permitType}.permitTypemust be one of the 24 exact values the City of Toronto uses in its ownPERMIT_TYPEfield (spelling/punctuation included, e.g."Mechanical(MS)"without a space) — see the input schema for the full enum. AwatchIdis bound to itspermitTypeon first use; reusing the samewatchIdwith a differentpermitTypelater fails the run honestly (watch_config_mismatch) instead of silently mixing history under a stale index.maxResultsPerWatch— 1-2000, default 200.- Keep the number of watches small: each run re-scans the whole active-permit slice per watch, so large permit types (e.g. "Small Residential Projects", "Plumbing(PS)") take longer and cost more requests than small ones.
Output
One row per new/changed/removed permit: watchId, permitType, changeType (new/status_changed/
removed), permitNum, revisionNum, status, previousStatus, applicationDate, issuedDate,
completedDate, structureType, work, streetNum/streetName/streetType/streetDirection,
postal, wardGrid, description, currentUse/proposedUse, dwellingUnitsCreated/
dwellingUnitsLost, estConstCost, builderName, contentHash. A run that finds nothing new/changed
still writes an honest run_summary row to the default dataset (never silently empty).
Billing
Pay-per-event, one named event:
permit-status-changed— a(PERMIT_NUM, REVISION_NUM)pair that is new (after baseline), whose status/lifecycle dates changed, or whose two-run-confirmed disappearance from the active-permit slice we just reported.
Deduplicated by an atomic claim gate keyed on the pair plus a persistent changeSequence (so a
cyclical status like Issued → Closed → Issued again bills each real transition once, never zero times and
never twice). The baseline run establishes history without charging. A watch that never gets to fetch its
slice at all (source fetch incomplete/blocked before any event was claimed, or the run timed out before
reaching that watch) is never charged for that watch.
Charging is decided per delivered event, not by the run's final status. A run is only ever marked
FAILED as a whole after everything up to the point of failure has already happened for real — so if
watch A's events were already delivered and charged earlier in the same run, and watch B then hits a fatal
error (e.g. the lease is lost), the run correctly reports FAILED overall, but watch A's already-billed
rows are not retracted: they were genuinely delivered, remain visible in the Dataset, and stay charged
(reversing a charge that already reflects real, delivered work would itself be dishonest bookkeeping). In
other words: FAILED describes the run as a whole, not a promise that nothing in it was ever billed.
Delivery/billing guarantee: at-most-once, not exactly-once
The right to perform an irreversible action (dataset write + PPE charge) is granted by the only atomic
primitive Apify offers — RequestQueue.addRequest(uniqueKey) → wasAlreadyPresent — in a separate named
queue that acts as a permanent journal (<prefix>-<monitorId>-claims). The Apify Key-Value Store has no
CAS, no conditional write and no ETag, so it can only ever be a diagnostic state machine, never the source
of at-most-once.
Concretely: for one computed event, delivery and charging each happen no more than once. If the run
dies after taking the claim, the event may be lost (it stays dataset_unknown/charge_unknown and
is never re-delivered) — but you will never be billed twice. That is a deliberate trade: "never overcharge"
beats "never lose a row".
Boundaries of the guarantee, stated honestly:
- Between the internal lease check and the dataset write/charge there is an unavoidable TOCTOU gap; what actually protects your money is the claim gate, not the lease.
- The guarantee holds for as long as the named claims queue exists. Anyone with account access can delete or recreate it via Console/API, which starts the journal from zero. This is a boundary of any durable storage, not a defect of the protocol.
Honest limits
- We mirror the City of Toronto's own published
STATUSfield as-is; we do not judge legality, project completeness, or actual construction progress, and we do not guarantee the city's underlying data is complete or current.removedmeans the pair left the public dataset slice for two confirmed runs — it is not a statement that the permit was legally closed in any other sense the city does not publish directly in this dataset. - The server silently caps
limitat 32,000 rows per page regardless of what is requested (confirmed live); this actor deliberately requests pages far below that cap (5,000) with a page count computed from, and always exceeding, its own safety ceiling — so an unexpectedly large slice is detected, not silently truncated into an incomplete baseline. - A baseline (and every later comparison) requires reading the entire slice for a watch's
permitTypein one run. If the source returns a truncated page, a row-count mismatch, or a slice bigger than this actor's safety cap, the run reports that honestly (source_access_limited/watch_capacity_exceeded) and leaves the durable index untouched — never a partial baseline, never a false "everything disappeared". PERMIT_NUMis not unique in this dataset; the identity used throughout is(PERMIT_NUM, REVISION_NUM). A small number of live rows (258 of 205,932 as of 14.09.2026) are exact duplicate rows for the same pair — treated as harmless and collapsed; a duplicate pair with different content is treated as a structural anomaly and stops the run for that watch rather than guessing which version is correct.- If the feed is temporarily unavailable, its shape changes, or a watch's slice can't be confirmed complete, the affected watch reports that honestly instead of silently returning zero results — and any partial run is surfaced in the coverage record, the digest, and the webhook payload, never masked as "no changes found".
Author: OmniCoder (https://t.me/OmniCoder)