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

Watches Calgary's open-data building permit registry by community and reports only real status changes: new permits, status-cycle moves (Application Received, In Review, Issued Permit, Completed...), and permits leaving the public dataset slice (two-run confirmed). Free when nothing changes.

- **URL**: https://apify.com/titan\_coder/calgary-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?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

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

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Calgary Building Permit Status Delta

Durable, informational monitor of the City of Calgary's official **"Building Permits"** open dataset
(Socrata Open Data Portal, `data.calgary.ca`, dataset `c2es-76ed`) — 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 `statuscurrent` actually changes (Application Received → In Review → Issued
Permit → Completed, and more), a new permit shows up in a community's slice of the dataset, or a
previously-tracked permit drops out of it (confirmed over two runs).

### Why

The city already publishes the full permit table for free (nearly 500,000 rows), 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 filters by application
or issue **date** (i.e. "new applications"); none tracks status transitions of permits already in the
pipeline over time. Contractors, real-estate agents, inspectors, and neighbours use a status change (e.g.
"Issued Permit", "Completed") as a real, actionable signal — and a permit applied for years ago can still
change status today. This actor keeps that diff for you: a list of watches (one Calgary community 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 permit history** for one Calgary community (`communityCode`, the
   City's own 3-character code, e.g. `"DNC"` for Downtown Commercial Core) — not a window of recent
   activity. This is a deliberate, load-bearing difference from a "last N by date" design: a permit
   applied for months or years ago can change status *today*, and a date-window monitor would silently
   miss exactly that transition. Every run therefore re-reads the whole community slice, page by page,
   sorted deterministically by `permitnum`, and only accepts it as complete if every page's row count
   matches the community's declared total (checked fresh on every page) from start to finish.
2. The identity of one tracked item is `permitnum` alone — confirmed globally unique across the entire
   499,681-row dataset (unlike some sibling monitors in this line, no composite key is needed here).
3. The **first** run for a watch establishes a baseline: every permit in the slice gets a content hash
   computed from its lifecycle fields (`statuscurrent` + applied/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 community's declared total between two page reads, an overlapping
   `permitnum` across two different pages, 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 permit never seen before (or previously confirmed removed) is **new**;
   - a permit seen before whose lifecycle content hash differs is **status\_changed**;
   - a permit 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 permit
     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-calgary-watch",
  "watches": [
    { "watchId": "downtown-core", "communityCode": "DNC" }
  ],
  "maxResultsPerWatch": 200,
  "notifyOn": "new_alerts",
  "webhookUrl": "https://example.com/webhook"
}
```

- `watches` — 1-5 objects, each `{watchId, communityCode}`. `communityCode` is the City of Calgary's own
  3-character community code (case-insensitive, normalized to uppercase) — the City currently maintains
  316 of them. A `watchId` is bound to its `communityCode` on first use; reusing the same `watchId` with a
  different `communityCode` later fails the run honestly (`watch_config_mismatch`) instead of silently
  mixing history under a stale index. A community code that is well-formed but has no permits yet (or a
  typo that happens to match the 3-character pattern) is not an input error — it simply establishes an
  honest, empty baseline.
- `maxResultsPerWatch` — 1-2000, default 200.
- Keep the number of watches small: each run re-scans the **whole** permit history per community, so
  large communities (e.g. `"DNC"`, `"SAD"`) take longer and cost more requests than small ones.

### Output

One row per new/changed/removed permit: `watchId`, `communityCode`, `changeType` (`new`/`status_changed`/
`removed`), `permitNum`, `status`, `previousStatus`, `appliedDate`, `issuedDate`, `completedDate`,
`permitType`, `permitClass`, `permitClassGroup`, `permitClassMapped`, `workClass`, `workClassGroup`,
`description`, `applicantName`, `contractorName`, `housingUnits`, `estProjectCost`, `totalSqft`,
`originalAddress`, `communityName`, `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 `permitnum` that is new (after baseline), whose status/lifecycle dates
  changed, or whose two-run-confirmed disappearance from the community's dataset slice we just reported.

Deduplicated by an **atomic claim gate** keyed on the permit plus a persistent `changeSequence` (so a
cyclical status like Issued Permit → Completed → Issued Permit again — a rare but observed correction in
this dataset — 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 Calgary's own published `statuscurrent` 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 permit left the **public dataset slice** for that
  community for two confirmed runs — it is not a statement that the permit was legally closed, cancelled,
  or approved in any other sense the city does not publish directly in this dataset.
- Unlike a CKAN-based dataset (e.g. our companion Toronto monitor), Socrata's list endpoint does not
  return a total row count in the page body itself — this actor issues a separate lightweight `count(*)`
  query alongside every page read specifically so the same "did the declared total drift between two
  reads of the same watch" safety check still applies (see the source code for the exact mechanism).
- A baseline (and every later comparison) requires reading the **entire** history for a watch's
  `communityCode` in one run. If the source returns a truncated page, a row-count mismatch, an overlapping
  `permitnum` across two pages, 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".
- `permitnum` is confirmed globally unique in this dataset (verified against all 499,681 rows); any
  repeated `permitnum` within a single page — even with identical content — is treated as a structural
  anomaly and stops the run for that watch, rather than being silently collapsed.
- 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": "downtown-core", "communityCode": "DNC"}. Each watch tracks the FULL permit history for one Calgary community (communityCode, the City's own 3-character code, e.g. "DNC" for Downtown Commercial Core) — not a date window: a permit applied for years ago that only now changes status is still caught. Because each run re-scans the whole community slice, keep the number of watches small; large communities (e.g. "DNC", "SAD") 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": "downtown-core",
      "communityCode": "DNC"
    }
  ],
  "maxResultsPerWatch": 200,
  "notifyOn": "new_alerts"
}
```

# Actor output Schema

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

Every row this run produced. Key fields: watchId, communityCode, permitNum, changeType (new|status\_changed|removed), status, previousStatus, appliedDate, issuedDate, completedDate, permitType, permitClass, originalAddress, communityName, contentHash. "removed" means the permitnum dropped out of the City's public dataset slice for that community for two consecutive clean runs — not a legal confirmation of project completion or cancellation. 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": "downtown-core",
            "communityCode": "DNC"
        }
    ],
    "maxResultsPerWatch": 200,
    "notifyOn": "new_alerts"
};

// Run the Actor and wait for it to finish
const run = await client.actor("titan_coder/calgary-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": "downtown-core",
            "communityCode": "DNC",
        }],
    "maxResultsPerWatch": 200,
    "notifyOn": "new_alerts",
}

# Run the Actor and wait for it to finish
run = client.actor("titan_coder/calgary-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": "downtown-core",
      "communityCode": "DNC"
    }
  ],
  "maxResultsPerWatch": 200,
  "notifyOn": "new_alerts"
}' |
apify call titan_coder/calgary-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/calgary-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/M1tI416hiSfAdRB0f/builds/XWhmhVs21ESBhwmCl/openapi.json
