# Commercial Building Permit Opportunity Feed (`plusonets/commercial-permit-opportunity-feed`) Actor

Qualify commercial building permits from official city open data for one trade and sales stage, group amendments into one project, and emit only new or changed opportunities.

- **URL**: https://apify.com/plusonets/commercial-permit-opportunity-feed.md
- **Developed by:** [Olalekan Johnson](https://apify.com/plusonets) (community)
- **Categories:** Lead generation, Real estate
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $100.00 / 1,000 qualified projects

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

## Commercial Building Permit Opportunity Feed

Unofficial monitor of commercial building permits for contractors and suppliers who sell into a specific trade. It reads official city open-data APIs, groups amendments into one project, and returns a project only when it is new or changed and still inside the sales window for that trade.

An issued electrical permit is often already too late for a design-stage pitch. A renewal with no scope change is not a new job. This actor keeps those cases out unless you ask for them.

Unofficial, not affiliated with the City of Chicago, the City of New York, the Department of Buildings, DataSF, the City of Austin, the City of Seattle, or the City of Los Angeles.

Contact: plusonecares@gmail.com

### Who it is for

Estimators, manufacturer reps, and subcontractors who want a short list of commercial jobs in one trade, with the stage, valuation, published contractor or business name, and a link back to the city row. It is a permit observation, not permission to call or email anyone.

### What you put in

Leave the input empty and the actor runs the default: **Chicago, HVAC, last 14 days**, residential wording removed, only new or changed projects, at most 300 source rows and 25 projects.

| Field | Default | What it does |
| --- | --- | --- |
| `cities` | `chicago` | One or more of `chicago`, `new-york`, `san-francisco`, `austin`, `seattle`, `los-angeles` |
| `trades` | `hvac` | HVAC, fire and life safety, electrical, plumbing, roofing, restaurant fit-out, signage, demolition and waste, solar, or `custom` |
| `customKeywords` | empty | Extra phrases when you select custom. Letters, numbers, spaces, and `&./+-` |
| `lookbackDays` | `14` | Filing, issue, and status dates inside this window |
| `stages` | empty | Empty uses each trade's window. Electrical defaults to `application` only. Other trades default to `application` and `issued` |
| `excludeResidential` | `true` | Drops single-family, duplex, townhouse, apartment, ADU, and occupancy groups R-2 through R-5 |
| `onlyChanges` | `true` | Later runs of the same monitor emit a project only when it changed |
| `minValuation` | unset | When set, projects with a missing or lower valuation are skipped |
| `maxRecords` | `300` | Normalized source rows per city |
| `maxProjects` | `25` | New or changed projects pushed this run. The rest wait for the next run |
| `includeNormalizedRecords` | `false` | Also push one flat row per trade-matching filing |
| `monitorId` | derived | Isolates saved state. Derived from cities, trades, keywords, stages, residential filter, and minimum valuation |
| `socrataAppToken` | empty | Optional token you supply. Sent only as the `X-App-Token` header. The actor runs on the public API without one |

### What you get back

Qualified projects land in the default dataset (view **Qualified projects**). A run summary named `OUTPUT` lands in the default key-value store: coverage, counts, source lag, and charged units.

Each project row is flat, so a CSV export or a Google Sheet mapping works without nested JSON. Pipe characters separate multiple trades, permit numbers, and matched terms.

`changeType` is `new` or `changed`. `materialChange` is true when the filing itself is a renewal or amendment that the city marked as having scope changes. `qualifiesBecause` is the plain-language reason. `sourceLink` opens the city row. `sourceLagHours` is how old the portal's last refresh was when this run observed it. `confidence` is `high` when the city published a structured permit type for the trade, `medium` for a description match, and `low` for a short custom keyword with no occupancy.

#### Example from the default input

Observed 23 October 2026. Chicago HVAC, last 14 days. One of 11 qualified projects:

```json
{
  "recordType": "qualified-project",
  "projectId": "chicago:permit:101086447",
  "monitorId": "m2f97b6857eb39a9a1fcf",
  "changeType": "new",
  "city": "chicago",
  "cityName": "Chicago",
  "primaryTrade": "hvac",
  "trades": "hvac",
  "stage": "issued",
  "stageReason": "Permit issued.",
  "salesTiming": "in-window",
  "qualifiesBecause": "Issued as of 2026-10-21. Scope matches HVAC (MECHANICAL). The city classifies the occupancy as commercial.",
  "confidence": "medium",
  "valuationUsd": 1050000,
  "valuationSource": "reported_cost",
  "contractorName": "LEOPARDO CONSTRUCTION INC",
  "contractorRole": "CONTRACTOR-GENERAL CONTRACTOR",
  "address": "222 S RIVERSIDE PLZ",
  "addressCity": "Chicago",
  "region": "IL",
  "permitNumbers": "101086447",
  "recordCount": 1,
  "amendmentCount": 0,
  "applicationDate": "2026-09-15",
  "issuedDate": "2026-10-21",
  "eventDate": "2026-10-21",
  "eventAgeDays": 2,
  "sourceUpdatedAt": "2026-10-22T12:08:50+00:00",
  "observedAt": "2026-10-23T04:45:58+00:00",
  "sourceLagHours": 16.6,
  "sourceName": "City of Chicago Building Permits",
  "sourceDatasetId": "ydr8-5enu",
  "sourceLink": "https://data.cityofchicago.org/resource/ydr8-5enu.json?id=3455865",
  "datasetUrl": "https://data.cityofchicago.org/d/ydr8-5enu",
  "evidenceSnippet": "SELF-CERT 2019 CBRC: TENANT BUILDOUT IN EXISTING OFFICE BUILDING ON THE 22ND FLOOR, OFFICE SUITE 2250. NEW PARTITIONS, DOORS, CEILINGS, LIGHTING, MILLWORK, AND FINISHES. ASSOCIATED ELECTRICAL, MECHANICAL AND PLUMBING WORK AS PER PLANS. (OCCUPANCY GROUP B; TYPE 1A CONSTRUCTION)",
  "evidenceHash": "sha256:366cb6cc0def63c946a32a798fcaa391faf32c7060e34ce0b820a6ccccfbd7e2",
  "matchedTerms": "MECHANICAL",
  "occupancy": "commercial",
  "materialChange": false,
  "runCoverage": "complete"
}
```

That same default run also returned a $6.5 million ballpark air system, a restaurant HVAC remodel, and several office interiors. A second run with the same state emitted nothing new.

### Cities and datasets

Verified against the public SODA 2.0 APIs on 23 October 2026. Older history datasets are out of scope. The actor requests only these hosts, refuses redirects, and will not fetch a private IP, localhost, or a non-https URL. Portal links on a row are returned to you and are never downloaded.

| City | Dataset | ID | How a row is kept |
| --- | --- | --- | --- |
| Chicago | Building Permits | `ydr8-5enu` | Status and milestone. No occupancy class, so the description is used |
| New York | DOB NOW: Build – Approved Permits | `rbx6-tga4` | Issued mechanical, plumbing, sprinkler, standpipe, boiler, solar, sign, and demolition permits. This set does not include electrical |
| New York | DOB NOW: Electrical Permit Applications | `dm9a-ab7w` | Application through issued. Used for electrical, and for HVAC when the filing flags HVAC or boiler wiring |
| San Francisco | Building Permits | `i98e-djp9` on `data.sf.gov` | Filed, issued, complete, reinstated. `data.sfgov.org` now redirects here |
| Austin | Issued Construction Permits | `3syk-w9eu` | `Commercial` class when residential is excluded. Trade permits share `masterpermitnum` |
| Seattle | Building Permits | `76t5-zqzr` | `Non-Residential` class when residential is excluded |
| Los Angeles | Building permits issued, 2020 to present | `pi9x-tg5x` | Issued building, sign, and demolition |
| Los Angeles | Building permits submitted | `gwh9-jnip` | Plan check and other pre-issue statuses |
| Los Angeles | Mechanical permits issued / submitted | `67is-svtd`, `9rag-xmmd` | HVAC, plumbing, fire sprinkler |
| Los Angeles | Electrical permits issued / submitted | `ysqd-apz7`, `9k3p-zrda` | Electrical |

Los Angeles Building and Safety's issued dataset metadata was last refreshed at 2026-10-19T17:02:00Z, about three days before this check. Treat `sourceLagHours` as part of the lead, especially in Los Angeles.

#### Source attribution

- **Chicago.** City of Chicago, Building Permits (`ydr8-5enu`). The dataset licence field says "See Terms of Use".
- **New York.** Department of Buildings. Approved permits (`rbx6-tga4`) and electrical applications (`dm9a-ab7w`). The metadata names no separate licence; the dataset pages carry NYC Open Data terms.
- **San Francisco.** DataSF Building Permits (`i98e-djp9`). Open Data Commons Public Domain Dedication and License.
- **Austin.** City of Austin, Issued Construction Permits (`3syk-w9eu`). The portal metadata labels the licence "Public Domain U.S. Government".
- **Seattle.** City of Seattle, Building Permits (`76t5-zqzr`). Public domain.
- **Los Angeles.** Building and Safety permits listed above. Metadata attribution is "TSB" and no licence name is published.

### How a filing becomes a project

Addresses are folded into a single line (direction and street suffix normalized). Project ids:

- New York jobs drop the work-type suffix, so `B01424126-S2` and `B01424126-S2-MS` are one project.
- Austin trade permits join their master permit.
- Seattle child permits join the parent permit number.
- A filing that cites another permit number (`PERMIT 101086907`) joins that permit, including Chicago revisions.
- Everything else is one project per permit number. Permits are not merged just because they share an address.

Stages, in the order a later date wins: application, issued, renewal, inspection, completed. "Corrections Issued" in Los Angeles stays an application. Chicago "inspection eligible" stays issued. Seattle "Ready for Issuance" stays an application. A New York renewal **with** changes stays issued. A renewal **without** changes is stage `renewal` and is dropped unless you add that stage.

Default windows:

| Trade | Stages that qualify |
| --- | --- |
| Electrical | application |
| HVAC, fire and life safety, plumbing, roofing, restaurant fit-out, signage, demolition and waste, solar, custom keywords | application, issued |

Add `issued` to `stages` if you want issued electrical permits. Those rows are marked `salesTiming: late-for-design`. Add `renewal`, `inspection`, or `completed` only when you want work that is already in the field or closed.

### State across runs

State lives in the named key-value store `permit-opportunity-state`, under a key for the monitor id. A successful run saves the fingerprint of every project it emitted and every project that was unchanged. Projects past `maxProjects` are not marked emitted, so the next run can send them.

A failed run does not replace that baseline. Two runs of the same monitor will not update state at the same time; the second exits if the lock is still held.

`record-normalized` is charged for each source row normalized in **this** run, including rows that do not become a project. The next scheduled run reads the window again and can charge those rows again. `qualified-project` is charged only when a new or changed project is pushed. The same fingerprint is not charged twice.

### Pricing

This Actor uses pay-per-event pricing. Apify also charges a small start event once per run (about $0.0025 at the default 512 MB).

| Event | When it is charged | Price |
| --- | --- | --- |
| `record-normalized` | A source row was normalized. Invalid rows are not charged. A duplicate id in the same run is charged once | $0.002 |
| `qualified-project` | A new or changed project was pushed to the dataset | $0.10 |

Measured on 23 October 2026 with public endpoints and no app token.

| Run | What happened | Events | Charge |
| --- | --- | --- | --- |
| Default: Chicago, HVAC, 14 days | Coverage `complete`. 72 rows normalized, 11 projects pushed. 42 residential filings and 15 inspection-stage filings were held back | 72 × $0.002 + 11 × $0.10 | **$1.244** |
| Same Chicago monitor, run again with no city changes | Coverage `complete`. 72 rows normalized again. 0 projects pushed | 72 × $0.002 + 0 × $0.10 | **$0.144** |
| New York, HVAC, 14 days, default caps | Coverage `partial`. 300 rows normalized, 25 projects pushed, 189 further qualified projects left for the next run | 300 × $0.002 + 25 × $0.10 | **$3.10** |

$3.10 is also the ceiling of the default caps when every normalized row is unique and 25 projects qualify. Raising `maxRecords` or `maxProjects` raises that ceiling. A quiet daily Chicago schedule is about $0.144 plus $0.10 for each project whose stage, scope, valuation, contractor, or evidence actually changed.

Local runs and runs that are not on pay-per-event pricing still deliver the rows. `OUTPUT.billed` is false in that case, and `chargedUnits` shows what would have been billed.

### Coverage

`OUTPUT.coverage` is one of:

- `complete` — every selected portal answered and the caps were not hit
- `partial` — a cap or a charge limit stopped the read, or only some portals failed
- `empty` — the portals answered and nothing in the window matched (`status` `no-data`, exit 0)
- `blocked` — every portal failed, or another run holds the monitor lock (exit 1)
- `unsupported` — the input was rejected (exit 1)

### Limits

- Six cities, current Socrata datasets only. No county portals, no scraped contractor websites, no login-only systems.
- Chicago does not publish an occupancy class. A house described only as "interior remodel" can remain, with `occupancy: unknown` and `confidence: medium`. Dwelling units, ADUs, coach houses, apartments, and groups R-2 through R-5 are removed when `excludeResidential` is true. A hotel (group R-1) stays when the text says hotel.
- "Mechanical room" by itself is not treated as HVAC. A description that only says "mechanical, electrical, and plumbing" on a real commercial build-out still matches.
- Contractor values are the business name, role, and license the city published. Owner names, architect names, phone numbers, insurance policies, and firm street addresses are not requested and are not returned.
- New York electrical is a second dataset. A New York run that fills `maxRecords` on approved permits may read only part of the electrical applications. Coverage is `partial` when that happens.
- San Francisco's feed is incremental: a row can show up because `data_loaded_at` changed, not only because a permit was filed or issued in the window.
- Los Angeles mechanical and electrical datasets do not carry valuation. A minimum valuation skips those rows.
- Valuation is whatever the city typed in. It is not a bid.
- Lookback and the caps are per run. They are not a full historical export.
- The actor does not geocode. Latitude and longitude are copied when the city published them.

### FAQ

**Why is the second run empty?** `onlyChanges` is on, and the monitor already stored those projects. You still pay to re-read the window. Turn `onlyChanges` off to push the current matches again (each pushed project is charged again), or change `monitorId` to start a fresh baseline.

**Why did electrical in Chicago return nothing?** Issued electrical permits are outside the default window. Add `issued` to `stages` if you want them, and read `salesTiming`.

**A renewal showed up as issued.** The city marked it as a renewal with scope changes, so the work is still an open issued permit. A renewal without changes is stage `renewal` and is omitted until you select that stage.

**Can I trust the contractor field?** Only as a business name printed on the permit. There is no phone, no email, and no claim that the company wants to be contacted.

**The description looks residential but the row came through.** Chicago often has no occupancy field. If the description never says dwelling, family, apartment, or a residential occupancy group, the row stays and says that no residential occupancy was published.

**What does source lag mean?** Hours between the portal's `rowsUpdatedAt` (or the row's load time, when the city publishes one) and `observedAt`. A permit dated last week can still be sitting on a dataset that refreshed two days ago.

### Integrations

- **Webhook.** Point an Apify webhook at the run event `ACTOR.RUN.SUCCEEDED`. The dataset items are the payload. Also read `OUTPUT` so you can ignore a run whose coverage is `empty`, `partial`, or `blocked`.
- **Make and n8n.** Use the Apify app or node. Map the dataset fields straight onto the scenario; `permitNumbers`, `trades`, and `matchedTerms` are already single strings.
- **Google Sheets.** Use the Apify integration to append dataset items. The qualified-project view is the tab most teams want. Schedule the actor daily and keep `onlyChanges` on so the sheet grows only when a project is new or changed.
- **CSV.** Export the dataset. Every field is a string, number, or boolean.

### Run it locally

Python 3.12.

```bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python -m src
```

With no `INPUT` record, the default Chicago HVAC run starts. To pass input, write JSON to `storage/key_value_stores/default/INPUT.json` before launching. Results land in `storage/datasets/default` and the summary in `storage/key_value_stores/default/OUTPUT`.

```bash
pip install -r requirements-dev.txt
pytest
```

`tests/test_live.py` calls the public portals. It skips if the network fails.

# Actor input Schema

## `cities` (type: `array`):

Official open-data portals to read.

## `trades` (type: `array`):

Service categories. Electrical defaults to the application stage only, because an issued electrical permit is often late for a design-stage pitch.

## `customKeywords` (type: `array`):

Extra scope phrases. Required when Custom keywords is selected. Letters, numbers, spaces, and &./+- only.

## `lookbackDays` (type: `integer`):

How far back to read filing, issue, and status dates.

## `stages` (type: `array`):

Leave empty to use each trade's default sales window. Add renewal, inspection, or completed only when you want those later stages.

## `excludeResidential` (type: `boolean`):

Skip single-family, duplex, townhouse, and apartment filings when the city publishes that occupancy, and skip residential wording when it does not.

## `onlyChanges` (type: `boolean`):

On later runs of the same monitor, emit a project only when its stage, scope, valuation, or contractor changed.

## `minValuation` (type: `integer`):

Optional. Projects with a missing valuation are skipped when this is set.

## `maxRecords` (type: `integer`):

Cap on source rows normalized in each city. Each successfully normalized row can incur a record-normalized charge.

## `maxProjects` (type: `integer`):

Cap on new or changed projects emitted this run. The rest stay unsent and can be emitted on the next run.

## `includeNormalizedRecords` (type: `boolean`):

Adds one flat row per trade-matching source filing, in addition to the qualified project.

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

Isolates saved state. Leave blank to derive one from the cities, trades, stages, and residential filter.

## `socrataAppToken` (type: `string`):

Optional. Your own Socrata application token, sent only as a header. The actor runs without one.

## Actor input object example

```json
{
  "cities": [
    "chicago"
  ],
  "trades": [
    "hvac"
  ],
  "customKeywords": [],
  "lookbackDays": 14,
  "excludeResidential": true,
  "onlyChanges": true,
  "maxRecords": 300,
  "maxProjects": 25,
  "includeNormalizedRecords": false
}
```

# Actor output Schema

## `opportunities` (type: `string`):

New or changed commercial projects that matched the selected trade and sales stage.

## `summary` (type: `string`):

Coverage by city, source lag, qualified and suppressed counts, and charged units.

# 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 = {
    "cities": [
        "chicago"
    ],
    "trades": [
        "hvac"
    ],
    "customKeywords": [],
    "lookbackDays": 14,
    "excludeResidential": true,
    "onlyChanges": true,
    "maxRecords": 300,
    "maxProjects": 25,
    "includeNormalizedRecords": false
};

// Run the Actor and wait for it to finish
const run = await client.actor("plusonets/commercial-permit-opportunity-feed").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 = {
    "cities": ["chicago"],
    "trades": ["hvac"],
    "customKeywords": [],
    "lookbackDays": 14,
    "excludeResidential": True,
    "onlyChanges": True,
    "maxRecords": 300,
    "maxProjects": 25,
    "includeNormalizedRecords": False,
}

# Run the Actor and wait for it to finish
run = client.actor("plusonets/commercial-permit-opportunity-feed").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 '{
  "cities": [
    "chicago"
  ],
  "trades": [
    "hvac"
  ],
  "customKeywords": [],
  "lookbackDays": 14,
  "excludeResidential": true,
  "onlyChanges": true,
  "maxRecords": 300,
  "maxProjects": 25,
  "includeNormalizedRecords": false
}' |
apify call plusonets/commercial-permit-opportunity-feed --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,plusonets/commercial-permit-opportunity-feed"
        }
    }
}
```

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/kViv6DtIHZMf0y9R4/builds/ZqBgLmzhE9Xf3rRnI/openapi.json
