# Toronto Building Permit Status Delta (`titan_coder/toronto-building-permit-status-delta`) Actor

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.

- **URL**: https://apify.com/titan\_coder/toronto-building-permit-status-delta.md
- **Developed by:** [Radu Furtuna](https://apify.com/titan_coder) (community)
- **Categories:** Real estate, Automation, Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$10.00 / 1,000 permit status changeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Toronto Building Permit Status Delta

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

1. Each `watch` tracks the **entire active-permit slice** for one `permitType` — 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 reported `total` agree from start to finish.
2. The identity of one tracked item is the pair `(PERMIT_NUM, REVISION_NUM)` — `PERMIT_NUM` alone is
   **not** unique in this dataset (a permit can carry multiple revisions).
3. 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.
4. 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`.
5. 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

```json
{
  "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}`. `permitType` must be one of the 24 exact values
  the City of Toronto uses in its own `PERMIT_TYPE` field (spelling/punctuation included, e.g.
  `"Mechanical(MS)"` without a space) — see the input schema for the full enum. A `watchId` is bound to
  its `permitType` on first use; reusing the same `watchId` with a different `permitType` later 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 `STATUS` field 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. `removed` means 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 `limit` at 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
  `permitType` in 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_NUM` is 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)

# Actor input Schema

## `monitorId` (type: `string`):

Name of this monitor's durable history (a-z, 0-9, dash; up to 40 chars).

## `watches` (type: `array`):

1-5 objects: {"watchId": "rental-renovation", "permitType": "Rental Renovation Licence"}. Each watch tracks the FULL active-permit slice for one permitType (one of the 24 exact values the City of Toronto uses in its own PERMIT\_TYPE field, including its punctuation/spacing) — not a date window: a permit issued months ago that only now changes status is still caught. Because each run re-scans the whole slice, keep the number of watches small; large permit types (e.g. "Small Residential Projects", "Plumbing(PS)") take longer and cost more requests than small ones. New watches can be added later under the same monitorId.

## `maxResultsPerWatch` (type: `integer`):

Caps how many new/status-changed/removed permits are delivered per watch in a single run (the rest are picked up on the next run). Protects against a runaway bill on a watch's very first post-baseline run.

## `notifyOn` (type: `string`):

new\_alerts — post the webhook only when billed status changes were delivered; always — post it every run; never — do not call webhookUrl at all.

## `webhookUrl` (type: `string`):

Optional. Receives a digest of delivered (billed) permit status changes as JSON. HTTPS only.

## Actor input object example

```json
{
  "monitorId": "example-monitor",
  "watches": [
    {
      "watchId": "standard-attachments",
      "permitType": "Toronto Building Standard Attachments"
    }
  ],
  "maxResultsPerWatch": 200,
  "notifyOn": "new_alerts"
}
```

# Actor output Schema

## `results` (type: `string`):

Every row this run produced. Key fields: watchId, permitType, changeType (new|status\_changed|removed), permitNum, revisionNum, status, previousStatus, applicationDate, issuedDate, completedDate, streetNum/streetName/streetType, wardGrid, builderName, contentHash. "removed" means the (PERMIT\_NUM, REVISION\_NUM) pair dropped out of the City's public active-permit slice for two consecutive clean runs — not a legal confirmation of project completion. Informational only.

## `coverage` (type: `string`):

What this run actually covered and what it charged for: per-watch status/reason/declaredTotal/recordsInSlice, records delivered and billed, requested/attempted/succeeded/failed watch counts, sourceAccessLimitedCount, watchCapacityExceededCount. Enough to reconcile every charge against every row.

## `digest` (type: `string`):

A short human-readable summary of what this run found, written every run.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "monitorId": "example-monitor",
    "watches": [
        {
            "watchId": "standard-attachments",
            "permitType": "Toronto Building Standard Attachments"
        }
    ],
    "maxResultsPerWatch": 200,
    "notifyOn": "new_alerts"
};

// Run the Actor and wait for it to finish
const run = await client.actor("titan_coder/toronto-building-permit-status-delta").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "monitorId": "example-monitor",
    "watches": [{
            "watchId": "standard-attachments",
            "permitType": "Toronto Building Standard Attachments",
        }],
    "maxResultsPerWatch": 200,
    "notifyOn": "new_alerts",
}

# Run the Actor and wait for it to finish
run = client.actor("titan_coder/toronto-building-permit-status-delta").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "monitorId": "example-monitor",
  "watches": [
    {
      "watchId": "standard-attachments",
      "permitType": "Toronto Building Standard Attachments"
    }
  ],
  "maxResultsPerWatch": 200,
  "notifyOn": "new_alerts"
}' |
apify call titan_coder/toronto-building-permit-status-delta --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,titan_coder/toronto-building-permit-status-delta"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/o8HPGw5KvKQ7fpgBG/builds/GsuFbdM0ZbfV9cNlz/openapi.json
