Denver Residential Permit Delta avatar

Denver Residential Permit Delta

Pricing

$6.00 / 1,000 new building permit detecteds

Go to Apify Store
Denver Residential Permit Delta

Denver Residential Permit Delta

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.

Pricing

$6.00 / 1,000 new building permit detecteds

Rating

0.0

(0)

Developer

Radu Furtuna

Radu Furtuna

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

a day ago

Last modified

Share

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

{
"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)