# Denver Residential Permit Delta (`titan_coder/denver-building-permits-monitor`) Actor

Tracks new Denver residential construction permits as reported in the City of Denver dataset, plus valuation/contractor/inspection changes on permits already seen — filter by neighborhood or minimum project value for contractor and real estate leads. A day with nothing new is free.

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

## Pricing

$6.00 / 1,000 new building permit detecteds

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

## Denver Residential Permit Delta

Durable, informational monitor of the City and County of Denver's official **building permit
registry** — the free, public ArcGIS FeatureServer feed the city's Open Data Catalog publishes at
`https://services1.arcgis.com/zdB7qR0BtYrg0Xpl/arcgis/rest/services/ODC_DEV_RESIDENTIALCONSTPERMIT_P/FeatureServer/316/query`
(plus sister layers for commercial and demolition permits). No API key, no account, no proxy, 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 the city issues a brand-new permit matching your filters, or materially changes one
you've already seen (valuation revised, contractor changed, final inspection recorded).

### Why

Denver already publishes every permit for free, but there's no durable "what's new for me since I last
looked" — you either poll the whole layer yourself and diff it client-side, or you don't watch at all.
Contractors, real estate agents, and building-material suppliers use new-permit data as lead
generation: an address, a project value, and a contractor name is a warm lead. This actor keeps that
diff for you: a list of watches (permit type + optional neighborhood + optional minimum valuation), a
durable memory of every matching permit's content hash between runs, and a bill only for permits that
are genuinely new.

Residential permits are the primary use case (a much-requested, previously-unserved niche — the
closest competing "national building permits" aggregator lists Denver as its most-requested
still-unbuilt expansion). Commercial and demolition permits from the same city feed are available as
an option for the same lead-gen workflow.

### How it works

1. Each `watch` fetches the **last `FETCH_WINDOW_SIZE` (300) permits** issued for its `permitType`
   (`residential`, `commercial`, or `demolition` — three separate ArcGIS layers, same schema),
   ordered `DATE_ISSUED DESC, OBJECTID DESC` (a stable, deterministic tie-break confirmed live). This
   is a **window of recent activity, not the city's full permit history** (the residential layer alone
   holds 79,000+ records as of 13.09.2026) — the same philosophy as Federal Register Monitor.
   `neighborhood` and `minValuation` are optional filters applied **on our side**, after fetching.
2. The **first** run for a watch establishes a baseline: every permit in that window gets a content
   hash computed from its material fields (address, class, valuation, fee, contractor, dates,
   neighborhood, etc.) and stored — nothing is billed or delivered.
3. Every later run compares the fresh window against the stored index:
   - an `(OBJECTID, PERMIT_NUM)` pair never seen before is **new**;
   - a pair seen before whose content hash differs is **changed** — delivered as a plain fact (a field
     changed), with no claim about legality, approval, or construction progress.
4. A hard per-watch cap (`maxResultsPerWatch`) protects you from a single run billing/delivering an
   unbounded backlog — anything over the cap is picked up cleanly on the next run.
5. Every fetch also checks the city's own reported total record count for that permit type
   (`returnCountOnly=true`, queried **before** any client-side neighborhood/valuation filtering) against
   how many rows the window actually got back. If the city returned fewer than
   `min(300, declaredCount)` should imply, that's a truncated/corrupted response — the watch is
   reported `source_access_limited` for that run, its index is left untouched, and nothing is billed
   (ArcGIS's own `exceededTransferLimit` flag is **not** used for this — see "Honest limits").

### Input

```json
{
  "monitorId": "my-denver-watch",
  "watches": [
    { "watchId": "cherry-creek-residential", "permitType": "residential", "neighborhood": "Cherry Creek", "minValuation": 50000 }
  ],
  "maxResultsPerWatch": 100,
  "notifyOn": "new_alerts",
  "webhookUrl": "https://example.com/webhook"
}
```

- `watches` — 1-30 objects, each `{watchId, permitType, neighborhood?, minValuation?}`. `permitType`
  is required: `residential` (primary use case), `commercial`, or `demolition`. `neighborhood` is
  optional (case-insensitive substring match against the city's `NEIGHBORHOOD` field). `minValuation`
  is optional (minimum permit value in USD, filtered on our side; omit or `0` = every value). A
  `watchId` is bound to its filters on first use — reusing the same `watchId` with a different
  `permitType`/`neighborhood`/`minValuation` later fails the run honestly (`watch_config_mismatch`)
  instead of silently hiding records under a stale index.
- `maxResultsPerWatch` — 1-300, default 100.

### Output

One row per new/changed permit: `watchId`, `permitType`, `status` (`new`/`changed`), `itemId`,
`objectId`, `permitNum`, `address`, `class`, `valuation`, `permitFee`, `contractorName`, `dateIssued`,
`dateReceived`, `finalDate`, `neighborhood`, `sourceUrl`. 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:

- `new-building-permit-detected` — an `(OBJECTID, PERMIT_NUM)` pair never seen before under this watch.

Deduplicated by an **atomic claim gate** keyed on `itemId` (`OBJECTID.PERMIT_NUM`) — the same permit can
never be charged twice. `status=changed` rows (an already-known permit whose content materially changed)
are delivered for free — there is no second named PPE event for them — but they pass through their own
claim gate as well, because a dataset write is irreversible even when it costs nothing. The baseline run
establishes history without charging. Failed/blocked runs (source fetch failed, run timed out) are never
charged.

#### 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.
- The guarantee applies from the build in which the claim gate was introduced onward. Older builds must
  not keep running against the same `monitorId`.

### Honest limits

- **The durable dataset is a delivery-attempt log, not a guaranteed mirror of the default dataset.**
  Each row is written to the durable dataset first, then mirrored to the run's default dataset before
  billing proceeds for that row. If the durable write succeeds but the default-dataset mirror write
  fails (e.g. transient Apify storage error), the item is marked `dataset_unknown`, billing for it is
  permanently blocked (fail-closed — we never charge for a row we can't confirm was delivered), and the
  run is not retried into re-creating that exact row. The durable dataset can therefore end up with a
  small number of orphan rows that were never mirrored and never billed. The **default dataset is the canonical log of rows successfully written to this
  run's output** (see its `run_summary` row) — but a default-dataset row does not by itself prove the
  row was billed: the row is written before `Actor.charge()` runs, so if charging then fails or comes
  back `charge_unknown`, the row is present but not confirmably paid. **`run_summary.eventsBilled` and
  Apify's own billing ledger are the source of truth for confirmed payment**, not the presence of a row
  in either dataset.
- We mirror Denver's own permit registry as published, filtered to the window and fields described
  above; we do not judge legality, project completeness, or construction status, and we do not
  guarantee the city's underlying data is complete or current.
- We watch a **window** of the most recent permits, not the whole city history. A permit that ages out
  of the window without ever appearing as `new` (e.g. a monitor started long after it was issued) will
  never be reported.
- `CONTRACTOR_NAME` (the only person/organization-name field in this feed) is always a company name in
  every live sample checked — there is no owner/individual-resident field in this dataset at all, and
  none is ever included in our output.
- ArcGIS's `exceededTransferLimit` flag is essentially always `true` for this feed's windowed queries
  (the underlying tables are far larger than any sane window size), so it is **not** used as a
  truncation signal. We instead compare the city's own `returnCountOnly` total against how many rows
  the window actually returned (see "How it works", step 5) — see ROADMAP.md for the P0-class mistake
  this design deliberately avoids.
- If the feed is temporarily unavailable, its shape changes, or the window comes back short of what the
  city's own declared count implies, the affected watch reports that honestly instead of silently
  returning zero results.

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-30 objects: {"watchId": "cherry-creek-residential", "permitType": "residential", "neighborhood": "Cherry Creek", "minValuation": 50000}. permitType is required — residential is the primary use case (new-construction and remodel permits for contractor/real-estate leads); commercial and demolition are also available. neighborhood is optional (a Denver neighborhood name, matched as a case-insensitive substring against the city's NEIGHBORHOOD field — omit for every neighborhood). minValuation is optional (minimum permit valuation in USD, filtered on our side — omit or 0 for every value). New watches can be added later under the same monitorId.

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

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

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

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

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

Optional. Receives a digest of delivered new/changed permits as JSON. HTTPS only.

## Actor input object example

```json
{
  "monitorId": "example-monitor",
  "watches": [
    {
      "watchId": "denver-residential",
      "permitType": "residential",
      "neighborhood": "",
      "minValuation": 0
    }
  ],
  "maxResultsPerWatch": 100,
  "notifyOn": "new_alerts"
}
```

# Actor output Schema

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

Every row this run produced. Key fields: watchId, permitType, status (new|changed), permitNum, address, neighborhood, valuation, contractorName, dateIssued, finalDate. If nothing new or changed was found, a single run\_summary row explains why the dataset is otherwise empty. Informational only — not a guarantee of the city's data completeness or accuracy, and not legal or construction-status advice.

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

What this run actually covered and what it charged for: per-watch status/reason, records delivered and records billed, requested/attempted/succeeded/failed watch counts, sourceAccessLimitedCount. 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": "denver-residential",
            "permitType": "residential",
            "neighborhood": "",
            "minValuation": 0
        }
    ],
    "maxResultsPerWatch": 100,
    "notifyOn": "new_alerts"
};

// Run the Actor and wait for it to finish
const run = await client.actor("titan_coder/denver-building-permits-monitor").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": "denver-residential",
            "permitType": "residential",
            "neighborhood": "",
            "minValuation": 0,
        }],
    "maxResultsPerWatch": 100,
    "notifyOn": "new_alerts",
}

# Run the Actor and wait for it to finish
run = client.actor("titan_coder/denver-building-permits-monitor").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": "denver-residential",
      "permitType": "residential",
      "neighborhood": "",
      "minValuation": 0
    }
  ],
  "maxResultsPerWatch": 100,
  "notifyOn": "new_alerts"
}' |
apify call titan_coder/denver-building-permits-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,titan_coder/denver-building-permits-monitor"
        }
    }
}
```

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/nwO25PjxJtDruOFsu/builds/g6aVc2phQfD1dTduS/openapi.json
