# US Commercial Fire Permit & Inspection Leads Scraper (`scrapelabmax/us-fire-permit-leads-scraper`) Actor

Commercial fire-protection permits, failed inspections and code violations from official city sources — open-data APIs (Socrata, ArcGIS, CKAN, Carto) and permit portals (Accela, Tyler EnerGov) — normalized, deduplicated, scored 0-100, ready for fire-protection sales teams.

- **URL**: https://apify.com/scrapelabmax/us-fire-permit-leads-scraper.md
- **Developed by:** [Scrapelab Max](https://apify.com/scrapelabmax) (community)
- **Categories:** Lead generation, Real estate, Business
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $6.00 / 1,000 results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

## US Commercial Fire Permit & Inspection Leads Scraper

Turn official government permit records into a ready-to-call sales pipeline for fire-protection
contractors, alarm/sprinkler installers, and fire-safety compliance vendors.

### 1. What you get

This actor is a **commercial fire-protection lead-intelligence feed**, not a generic permit
scraper. Every run pulls new sprinkler and fire-alarm permits, fire-pump and standpipe work,
kitchen-suppression installs, failed fire inspections, reinspection orders, fire-code
violations, and expiring certifications straight from official city/county sources — open-data
APIs (Socrata, ArcGIS, CKAN, Carto) and permit portals (Accela Citizen Access, Tyler EnerGov). Raw
municipal records are normalized into one consistent schema, deduplicated across overlapping
datasets, and scored 0–100 with human-readable `leadSignals` explaining exactly why a record is
(or isn't) worth a sales call — a brand-new sprinkler permit with no contractor listed and a
$250k project value scores very differently than a two-year-old closed permit. Every record
carries a `source.url` back to the official government dataset so a buyer can verify it
independently before spending a call.

### 2. Supported jurisdictions

Coverage is **33 metro areas**, chosen by verified data availability (clean
Socrata/ArcGIS/CKAN/Carto open-data, a scriptable Accela Citizen Access or Tyler EnerGov CSS
portal, or a city report that answers plain HTTP, with real fire-protection records), not by
population or geography alone.

| Jurisdiction | Sources | Record types | Data richness |
|---|---|---|---|
| New York City, NY | NYC DOB NOW: Build – Approved Permits | permit | Fire-specific work-type filter (Sprinklers, Standpipe). FDNY fire-alarm permits are not on open data, so alarm coverage here is partial. |
| Chicago, IL | Chicago Building Permits | permit | Keyword-matched from free-text work descriptions across the full permit table. |
| San Francisco, CA | SF DBI Building Permits, SFFD Fire Permits, SFFD Fire Inspections, SFFD Fire Violations | permit, inspection, violation | The richest jurisdiction in the registry — permits, dedicated fire-department operational permits, fire inspections (incl. NFPA-25 sprinkler inspections), and fire violations, all four sources. High inspection volume; use `minimumLeadScore` to throttle. |
| Seattle, WA | Seattle Trade Permits | permit | Cleanest source in the registry — an explicit "Fire Sprinkler and Suppression" permit type, not keyword-matched. |
| Austin, TX | Austin Issued Construction Permits | permit | Explicit `work_class` values (e.g. "Fireline") plus keyword-matched descriptions. |
| Los Angeles, CA | LA Building Permits, LA Electrical Permits | permit | Two complementary sources — building permits (heavily residential; the residential filter matters) and electrical permits (the better source for commercial fire-alarm work). |
| Mesa, AZ | Mesa Building Permits | permit | Explicit commercial/residential permit-type field; deliberately excludes bare "FIRE" keyword matching to avoid "National Fire Protection Association" boilerplate false positives. |
| New Orleans, LA | New Orleans Permits | permit | Explicit "Hoods" permit type for commercial kitchen suppression, plus keyword matching. |
| Washington, DC | DC Building Permits (current + prior year) | permit | ArcGIS feature layers, one per calendar year — see Architecture.md for the annual maintenance this requires. |
| Charlotte / Mecklenburg County, NC | Mecklenburg County Accela Permits; Mecklenburg County Building Permits (legacy) | permit | County-wide — covers Charlotte and every other Mecklenburg municipality; `address.city` names the town. The county moved new projects to Accela in January 2025, and that layer carries the trade permits: fire-alarm work here is filed as a commercial electrical permit. Status, zip, coordinates and owner are published; contractor is not. The county's server occasionally returns a description with text from a neighbouring row (about 2% of rows), so check `source.recordUrl` where the wording matters. The legacy feed is still read for projects begun before the move. |
| Boston, MA | Boston Approved Building Permits | permit | CKAN datastore (SQL API), daily refresh. Rich free-text `comments` plus a dedicated "Electrical Fire Alarms" permit type. |
| Philadelphia, PA | Philadelphia L\&I Building & Trade Permits | permit | Carto SQL API. Dedicated "Fire Suppression" permit types (current + legacy BP\_FIRESUP) plus keyword-matched scope-of-work; explicit commercial/residential field. |
| Nashville, TN | Nashville Building Permits Issued | permit | ArcGIS Hub layer with a rolling ~3-year window. Keyword-matched from the `Purpose` free text; fire-damage rehab permits deliberately excluded; no status field. |
| Columbus, OH | Columbus Building Permits | permit | Dedicated "Fire Alarm"/"Fire Suppression" permit categories plus keyword-matched value descriptions; closed 9-value commercial/residential field. |
| Miami, FL | Miami Building Permits (Since 2014) | permit | Keyword-matched scope-of-work; native lat/lon; clean binary Residential/Commercial flag. |
| Portland, OR | Portland BDS All Permits | permit | Bureau of Development Services "All Permits" layer, keyword-matched descriptions; no zip/contractor/coordinate fields. |
| Baltimore, MD | Open Baltimore Building Permits | permit | Keyword-matched descriptions; combined address string; no coordinate attributes. The city files apartment buildings under the use `Dwelling: Multi-Family`; those rows are treated as commercial and arrive in default runs. One- and two-family homes are held back unless `includeResidential` is on. |
| Kansas City, MO | Kansas City, MO Issued Building Permits | permit | Explicit work-class values like "Electrical Fire Alarm Commercial" plus keyword-matched permit types. |
| Minneapolis, MN | Minneapolis CCS Permits | permit | Keyword-matched comments; native lat/lon; occupancy-code commercial/residential field (~20% of rows blank). |
| Louisville, KY | Louisville Active Construction Permits | permit | Dedicated "Fire Detection"/"Fire Suppression System" permit types; the fullest field set of the phase-2 sweep. |
| Detroit, MI | Detroit BSEED Trades Permits; Detroit BSEED Building Permits | permit | Detroit files fire work as trade permits: a fire alarm is its own permit type, and sprinkler, standpipe, fire-pump and hood-suppression work is filed as a mechanical permit. Owner and the filing company are published; status, contractor and project value are not. Building permits whose description names fire work are read as well. |
| Raleigh, NC | Raleigh Building Permits | permit | Keyword-matched descriptions; contractor company, plus the contractor's phone and email with `includeContactDetails`; explicit Residential/Non-Residential field. |
| Fort Worth, TX | Fort Worth Permits (CIVIC) | permit | On-prem MapServer without an OBJECTID field (paginated via a configured sort column); the filing date stands in for the issued date — the layer publishes no true issued-date field. |
| San Antonio, TX | San Antonio Permits Issued | permit | CKAN datastore updated ~monthly; free-text project names catch fire work the permit-type taxonomy doesn't. |
| Memphis, TN | Memphis DPD Building Permits | permit | Keyword-matched descriptions; clean RES/COM commercial/residential field. |
| Tucson, AZ | Tucson Commercial Building Permits | permit | Commercial-only permit layer (Tyler EnerGov published through a standard ArcGIS MapServer). |
| Sacramento, CA | Sacramento Issued Building Permits — current-year + archive layers, each split into a dedicated County Fire permit-type source and a keyword source | permit | Dedicated 'County Fire' permit type (many with terse descriptions a keyword search would miss) plus keyword-matched work descriptions. Dates are MM/DD/YYYY strings — served by the hybrid string-date support; two layers require January boundary maintenance (see Architecture.md). |
| Virginia Beach, VA | Virginia Beach Building Permits — dedicated Fire permit-type source + keyword source | permit | Dedicated 'Fire' permit type (NFPA 13/72 scope text, ~860/yr) plus keyword-matched descriptions with server-side exclusion of 'NON-SPRINKLERED' boilerplate. Dates are YYYY/MM/DD strings — served by the hybrid string-date support. No contractor or valuation fields. |
| Colorado Springs, CO | Colorado Springs Fire Department Records (Accela Citizen Access) | permit | Accela Citizen Access fire module, read over plain HTTP — 10 construction fire-system record types (sprinkler, alarm, pumps, standpipe, suppression, responder radio) with full work descriptions. Addresses aren't in the CSV export, so they're recovered in bulk from the results grid; each of the ten fire record types is searched separately so the grid pages fetched are all fire records rather than ~15% of them, which is what makes a large `maxResults` affordable. Records the walk still can't reach are withheld rather than sold without an address, and the run summary says how much of the window was covered and why it stopped. |
| Omaha, NE | Omaha Fire Prevention Records (Accela Citizen Access) | permit | Accela Citizen Access fire module, read over plain HTTP — fire alarm applications, protection equipment, standpipe and extinguisher-system records. Same bulk grid-based address recovery as Colorado Springs; typically cheaper per lead here since Omaha's weekly volume is small. |
| Atlanta, GA | Atlanta Fire Protection Permits (Accela Citizen Access, Building module) | permit | Accela Citizen Access, read over plain HTTP — Atlanta files fire-system permits as record types inside its Building module, and three are searched: Commercial - Fire Sprinkler, Residential - Fire Sprinkler and Commercial - Fire Underground, with work descriptions and permit names. Dates are filing dates, so leads arrive before issuance. "Residential" is the city's own type name and mostly covers multi-unit buildings: default runs include those rows, and the type name never triggers `includeResidential`. The usual text check still applies, so a row whose own description says townhouse or single-family is held back unless `includeResidential` is on. Known portal gaps: on about 9% of rows the portal prints the placeholder ZIP `00000`, which is delivered as a null `zip`; about 3% have no ZIP at all and keep the whole address in `street`; and in-progress applications can have a blank status or description. |
| Tulsa, OK | Tulsa Fire Prevention Records (Tyler EnerGov Citizen Self Service) | permit | Tyler EnerGov CSS, a stateless multi-tenant JSON REST API read anonymously — no cookies, viewstate, or sessions. A tenant-header chain pinned from the portal's tenant list scopes every request to Tulsa, and coverage comes from one type-narrowed search per fire permit-type/work-class pair, with exact counts read off the API's own total. Unlike the Accela adapter, this one never withholds a record for a missing address. |
| Houston, TX | Houston Sold Permits (city report) | permit | **Pilot.** Reads the city's Sold Permits report for a pinned list of 20 ZIPs, for up to 3 days per run: the day before the run in Houston's time and the two days before it, as far back as your window reaches. A 90-day run reads 3 days of it and says so in the log. Fire alarm and sprinkler permits and their plan review fees, with the occupant's name and the street address; `saleKind` says which of the two a lead is. With `onlyNewRecords` on, a project is delivered once, when it is first sold, which is often its plan review fee; with it off, a project is delivered by every run whose days include a day it was sold on, as every source re-delivers its window. No contractor, status or link. The ZIPs: 77002, 77003, 77006, 77007, 77008, 77017, 77018, 77019, 77022, 77024, 77027, 77042, 77055, 77056, 77072, 77079, 77082, 77084, 77092, 77098. A ZIP you ask for outside them is listed under `zipsNotWalked` in the coverage report and is not read. A ZIP you ask for selects the city, as for every source: the run reads all 20 ZIPs and delivers leads from all of them, not only from the ZIP you named. |

Coverage is growing. **Requested locations outside this list are reported as unsupported with a
reason in the run's coverage report — you are never charged for them.**

Known-unsupported metros and why (verified 2026-08-08, re-checked entries 2026-08-10):

| Metro | Reason |
|---|---|
| San Diego, CA | Permits published only as static CSV files; no queryable API. |
| Jacksonville, FL | Permits live in a custom Azure-AD-gated app (JAXEPICS); its API refuses anonymous requests and the open-data portal is dead. |
| San Jose, CA | Open data is current but carries no fire-protection-distinguishing permit types — zero matches. |
| Indianapolis, IN | Accela is scriptable but has no fire permit taxonomy, no description column in results, and a silent 150-row cap. |
| Oklahoma City, OK | Accela is anonymous here but publishes no fire module; the data portal remains bot-blocked. |
| El Paso, TX | The Accela fire module is login-gated; the anonymous building module carries no fire permit types. |
| Las Vegas, NV | Permit table is current but has only generic trade categories and no fire-distinguishing text across 436k rows; fire permits live in an unpublished LVFR system (re-verified 2026-08-10). |
| Milwaukee, WI | Permit dataset is live but still has 4 coarse type buckets and no work description — fire work indistinguishable (re-verified 2026-08-10). |
| Albuquerque, NM | Permit layer stopped updating 2025-01-16; no current feed. |
| Fresno, CA | Anonymous Accela access is disabled agency-wide; the replacement portal needs JavaScript. |
| Long Beach, CA | No building-permit dataset on either of its open-data portals. |
| Oakland, CA | Accela is scriptable but the fire module has no date search and the building module no fire types. |
| Bakersfield, CA | Runs Click2Gov, which offers no date-range search at all. |
| Tampa, FL | Current layers contain no fire-protection-distinguishable permits. |
| Arlington, TX | Structured type fields have zero fire-protection values; free-text hits were legal-notice boilerplate. |
| Dallas, TX | The Socrata dataset is frozen at 2019-12-31, the newest ArcGIS layer ends Nov 2024, and the Accela portal has no fire module. |
| Phoenix, AZ | The CKAN portal has no record-level permit data — its only permit dataset is aggregate annual housing-unit counts, stale since 2023. |
| Denver, CO | Permit layers are current but expose no free-text description, so fire work is indistinguishable; fire-department permits are unpublished. |

### 3. Quick start

Minimal input to pull fresh fire-alarm and fire-sprinkler leads across three cities:

```json
{
  "cities": ["Seattle, WA", "San Francisco, CA", "New York, NY"],
  "permitTypes": ["fire_alarm", "fire_sprinkler"],
  "startDate": "2026-07-01",
  "maxResults": 5000
}
```

Run this and the actor resolves each city to its registered source(s), fetches permits issued
on or after `2026-07-01`, keeps only fire-alarm and fire-sprinkler records, deduplicates them,
scores each 0–100, caps at 5,000, sorts best-first, validates every record against the output
schema, and pushes the result to the run's dataset — plus a `COVERAGE_REPORT` key in the run's
key-value store summarizing what succeeded, what failed, and what was unsupported.

**Pricing and the per-run charge limit.** You pay a flat price per lead delivered (see the
pricing tab) plus Apify's nominal actor-start fee (a fraction of a cent per run); unsupported
jurisdictions, failed sources and suppressed duplicates are never charged. Every run also has a maximum charge, set by Apify from your plan and
adjustable per run (`maxTotalChargeUsd`). The actor reads that limit before it fetches
anything and stops at whichever is lower: `maxResults` or the number of leads your limit can
pay for — so it never fetches leads it can't deliver, and every lead in the dataset is one you
were charged for. When the limit was the binding constraint, the coverage report says so
(`chargeLimit.reached: true`, `chargeLimit.leadsWithinLimit`); leads that weren't delivered are
**not** marked as seen, so a follow-up run with a higher limit or `onlyNewRecords: true` picks
them up. For Houston a follow-up run reads the last 3 days only.

**How the cap is shared.** Every source you asked for is read, whatever the cap. If more leads
match than `maxResults` (or your charge limit) allows, the sources that have leads share the
cap equally and each gives its best-scored leads; a source with fewer leads than its share
gives them all and the rest is passed on. With `onlyNewRecords` on, leads you already received
do not count against the cap. The coverage report shows, for each source, how many leads it
found (`emittedCount`), how many you had already received (`alreadySeenCount`) and how many
were delivered (`deliveredCount`).

**What a source can ever fill.** Each source's row in the coverage report has a `publishes`
block: `status`, `applicationDate`, `issuedDate`, `expirationDate`, `projectValue`,
`businessName`, `owner`, `applicant`, `contractor`, `contractorLicense`, `contactDetails` and
`recordUrl`, each `true` or `false`. `true` means the source has a column for the field, though
a record may leave it empty. `false` means no record of that source will ever hold it. So a
lead with no contractor from a source whose `contractor` is `false` is not a job without a
contractor yet: the city does not name one.

#### More example inputs

**Daily fresh leads for one state.** Schedule this daily; only records not seen on a previous
run (or whose status changed) come back, so you pay only for what is new.

```json
{
  "states": ["WA"],
  "permitTypes": ["fire_alarm", "fire_sprinkler"],
  "onlyNewRecords": true,
  "maxResults": 1000
}
```

**One permit-portal city.** Tulsa is served by a Tyler EnerGov portal rather than an open-data
API — same input shape, same output schema. Portal cities take longer per lead than open-data
cities (roughly two minutes for 50 leads), so keep `maxResults` modest on a first run.

```json
{
  "cities": ["Tulsa, OK"],
  "startDate": "2026-08-01",
  "maxResults": 100
}
```

**Best-scored leads only, permits and inspections.** San Francisco publishes fire inspections
and violations as well as permits; `minimumLeadScore` keeps the feed to records worth a call.

```json
{
  "cities": ["San Francisco, CA"],
  "includeInspections": true,
  "includeViolations": true,
  "minimumLeadScore": 60,
  "maxResults": 250
}
```

### 4. Example output

One real record, captured live from the Seattle Trade Permits source (public government data —
nothing redacted):

```json
{
  "recordId": "seattle-trade-permits:7005022-FS",
  "jurisdiction": { "city": "Seattle", "county": "King", "state": "WA" },
  "businessName": null,
  "projectName": null,
  "address": {
    "street": "705 NE NORTHLAKE WAY",
    "city": "Seattle",
    "state": "WA",
    "zip": null,
    "latitude": null,
    "longitude": null
  },
  "recordType": "permit",
  "fireSystemType": "fire_pump",
  "workType": "replacement",
  "permitNumber": "7005022-FS",
  "permitStatus": "Issued",
  "saleKind": null,
  "applicationDate": null,
  "applicationDateKind": null,
  "issuedDate": "2025-11-19",
  "expirationDate": null,
  "inspectionDate": null,
  "inspectionStatus": null,
  "violations": [],
  "description": "REVISION TO PERMIT 6911078-FS: Add vertical turbine fire pump taking suction from Lake Washington to replace water supply from municipal system for dry-standpipe system serving a boat moorage. | Fire Sprinkler and Suppression",
  "projectValue": null,
  "propertyType": null,
  "owner": { "name": null, "company": null, "phone": null, "email": null },
  "applicant": { "name": null, "company": null, "phone": null, "email": null },
  "contractor": { "name": null, "company": null, "licenseNumber": null, "phone": null, "email": null },
  "leadScore": 80,
  "leadSignals": ["FIRE_PUMP_INSTALLATION", "SYSTEM_REPLACEMENT", "NO_CONTRACTOR_LISTED", "COMMERCIAL_PROPERTY"],
  "source": {
    "sourceId": "seattle-trade-permits",
    "jurisdiction": "Seattle, WA",
    "provider": "socrata",
    "url": "https://data.seattle.gov/Permitting/Trade-Permits/c87v-5hwh",
    "recordUrl": "https://services.seattle.gov/portal/customize/LinkToRecord.aspx?altId=7092673-FS",
    "recordUrlKind": "page"
  },
  "scrapedAt": "2026-08-07T23:38:48.781Z"
}
```

`leadScore` 80 reflects a fire-pump replacement (high-value system work) with no contractor
listed yet (an unsold job) on a commercial property — three positive signals stacked on the
40-point base score. Fields the source didn't populate (`businessName`, `projectValue`, `zip`,
etc.) are `null`, never guessed.

### 5. Input reference

All fields from `.actor/input_schema.json` (`maxResults` is the only required field):

| Field | Type | Default | Description |
|---|---|---|---|
| `cities` | `string[]` | `[]` | Cities as `"City, ST"` (e.g. `"Seattle, WA"`). |
| `counties` | `string[]` | `[]` | Counties as `"County, ST"` (e.g. `"Mecklenburg, NC"`). |
| `states` | `string[]` | `[]` | State codes or names (e.g. `"CA"`, `"Texas"`). Expands to every supported jurisdiction in the state. |
| `zipCodes` | `string[]` | `[]` | 5-digit ZIP codes; matched to supported jurisdictions by prefix. |
| `permitTypes` | `string[]` (enum) | `[]` (= all) | Restrict to specific fire-protection categories: `fire_alarm`, `fire_sprinkler`, `fire_pump`, `standpipe`, `kitchen_suppression`, `special_suppression`, `inspection`, `fire_code_violation`, `certificate_of_occupancy`, `other_fire_protection`. |
| `keywords` | `string[]` | `[]` | Case-insensitive keywords the record description must contain (e.g. `"NFPA 13"`). |
| `startDate` | `string` (`YYYY-MM-DD`) | `null` → `lookbackDays` before run time | Earliest record date. |
| `endDate` | `string` (`YYYY-MM-DD`) | `null` → today | Latest record date. |
| `lookbackDays` | `integer` (1–365) | `null` → 90 | Days before run time to read from when `startDate` is not set. A schedule's input is fixed, so this is how a daily run asks for "the last week". `startDate`, if set, wins. |
| `includeInspections` | `boolean` | `true` | Include fire inspection records (failed inspections are high-value leads). |
| `includeViolations` | `boolean` | `true` | Include fire-code violation records. |
| `includeExpiredOrExpiring` | `boolean` | `true` | Include records whose permits/certifications are expired or expiring soon. |
| `includeResidential` | `boolean` | `false` | Include single-family residential records (off by default — this is a commercial lead product). |
| `onlyNewRecords` | `boolean` | `false` | Return only records new or changed since your last run (per-account state). Run daily for a fresh-leads feed. |
| `maxResults` | `integer` (1–100000) | required, prefill `500` | Hard cap on returned leads — you pay per result. Every requested source is read; when the cap binds, sources share it equally. |
| `minimumLeadScore` | `integer` (0–100) | `0` | Only return leads scoring at least this. `60+` = strong signals only. |
| `includeRawData` | `boolean` | `false` | Attach the original source record under `rawData`. |
| `includeContactDetails` | `boolean` | `false` | Fill the `phone` and `email` fields with what a permit record itself publishes for its contractor, applicant or owner. Off: both fields are `null` on every record. Filled only where the city's own record carries them (today Raleigh, Austin and Mesa), copied as published. With `includeRawData` on, `rawData` is the portal's row as published and holds whatever contact columns the portal publishes, whatever this input says. |
| `includeSourceMetadata` | `boolean` | `true` | Include merged-source provenance details (`mergedSources`). The primary `source` is always included. |
| `socrataAppToken` | `string` (secret) | `null` | Optional token from any Socrata portal — raises rate limits for heavy use. |

### 6. Output reference

Every dataset item is a `PermitLead`:

| Field | Type | Meaning |
|---|---|---|
| `recordId` | `string` | Stable ID, namespaced by source (`<sourceId>:<permitNumber-or-hash>`). |
| `jurisdiction` | `{ city, county, state }` | The registry jurisdiction the record belongs to (nullable city/county, 2-letter state). |
| `businessName` | `string \| null` | Business associated with the permit/inspection, where the source publishes one. |
| `projectName` | `string \| null` | Named project, where published. |
| `address` | `{ street, city, state, zip, latitude, longitude }` | All nullable except where the source provides them. |
| `recordType` | `"permit" \| "inspection" \| "violation"` | What kind of government record this is. |
| `fireSystemType` | enum | Normalized fire-protection category (see `permitTypes` above) — classifier output. |
| `workType` | `"new_installation" \| "modification" \| "replacement" \| "repair" \| "inspection" \| "unknown"` | What kind of work this record represents. |
| `permitNumber` | `string \| null` | Official permit/case number. |
| `permitStatus` | `string \| null` | Source's status string (e.g. `Issued`, `Closed`, `Expired`). |
| `saleKind` | `"plan-review-fee" \| "permit" \| null` | Houston only: what the city sold. `plan-review-fee` when only the fee that comes before a permit was sold on the days the run read, `permit` when a permit was. `null` on every other source. With `onlyNewRecords` on a project is delivered once, so this is what was first seen, not the current state. |
| `applicationDate` / `issuedDate` / `expirationDate` | `string \| null` (`YYYY-MM-DD`) | Permit lifecycle dates, where published. `applicationDate` is the portal's filing date. On four sources the portal does not document its date as a filing date, and it is delivered as published: Portland (`CREATEDATE`, the day the record was created), Chicago (`application_start_date`, "date when City began reviewing"), Colorado Springs (the results grid's `Date`) and Omaha (the results grid's `Date`). Houston is a fifth: the day the city sold the permit or its plan review fee. The report prints no date, so this is the day that was searched, and for a project sold on several of the days a run read it is the oldest of them. It is not a filing date and not a verified issue date, and Houston's `issuedDate` is always null. An approval date is never delivered as `applicationDate`, with that one exception: for a Houston permit the sale day may be the day of issue. |
| `applicationDateKind` | `"filed" \| "sold" \| "other" \| null` | What kind of date `applicationDate` is. `filed`: the portal's filing date. `sold`: the day the city sold the permit or its plan review fee (Houston). `other`: a date the portal does not document as a filing date (Portland, Chicago, Colorado Springs, Omaha). `null` when `applicationDate` is. |
| `inspectionDate` | `string \| null` | For inspection/violation records. |
| `inspectionStatus` | `string \| null` | e.g. `Failed`, `Passed`, `Reinspect`. |
| `violations` | `string[]` | Violation description(s), for `violation` records. |
| `description` | `string \| null` | Concatenated source description fields — the raw text the classifier read. |
| `projectValue` | `number \| null` | Reported/estimated project cost, where published. |
| `propertyType` | `string \| null` | Normalized property category (e.g. `restaurant`, `office`, `warehouse`) — classifier output; `null` when the source gives no usable signal. |
| `owner` | `{ name, company, phone, email }` | The property owner. All nullable. `phone` and `email`: see `contractor`. |
| `applicant` | `{ name, company, phone, email }` | Who filed, where the portal names them apart from the owner and the contractor. All nullable. |
| `contractor` | `{ name, company, licenseNumber, phone, email }` | All nullable — a permit with no contractor listed is itself a lead signal (unsold job). `licenseNumber` is filled where the permit row carries it (New York, Mesa). `phone` and `email` are that party's own, copied as the permit record publishes them, and only when `includeContactDetails` is on; a value that is not a phone or an email is `null`. When several rows of one permit name different parties, a phone, email or licence number is never moved beside another party's name. |
| `leadScore` | `integer` (0–100) | See below. |
| `leadSignals` | `string[]` | Every scoring rule that fired, in evaluation order. |
| `source` | `{ sourceId, jurisdiction, provider, url, recordUrl, recordUrlKind }` | Provenance. `url` is the dataset's or portal's home page, the same for every lead of a source. `recordUrl` opens this one record on the government's own site, and is `null` where no such link has been verified — a missing link rather than a guessed one. `recordUrlKind` says what it opens: `page` (the city's own page for the record), `rest` (the ArcGIS view of its row: plain HTML, a raw listing of attributes) or `data` (the open-data API's answer for its row, in JSON). |
| `mergedSources` | `SourceRef[]` (optional) | Present when a record was deduplicated across multiple sources; the additional sources that contributed data. Omitted entirely when `includeSourceMetadata: false`. |
| `rawData` | `object` (optional) | The original source row, only when `includeRawData: true`. |
| `scrapedAt` | `string` (ISO timestamp) | When this run fetched the record. |

#### Lead scoring

Every lead starts at a base score of **40** and every matching rule below adds or subtracts
points; the total is clamped to 0–100. `leadSignals` lists every rule that fired.

| Signal | Points | Fires when |
|---|---|---|
| `FAILED_INSPECTION` | +25 | Inspection status indicates a failed/deficient/not-approved result. |
| `NEW_FIRE_SPRINKLER_PERMIT` | +20 | New-installation permit classified as `fire_sprinkler`. |
| `FIRE_CODE_VIOLATION` | +20 | A violation record, or `fireSystemType: fire_code_violation`. |
| `NEW_FIRE_ALARM_PERMIT` | +15 | New-installation permit classified as `fire_alarm`. |
| `FIRE_PUMP_INSTALLATION` | +15 | `fireSystemType: fire_pump`. |
| `KITCHEN_SUPPRESSION_SYSTEM` | +15 | `fireSystemType: kitchen_suppression`. |
| `REINSPECTION_REQUIRED` | +15 | Inspection status indicates a reinspection is required. |
| `EXPIRING_CERTIFICATION` | +15 | `expirationDate` falls within the next 90 days. |
| `SYSTEM_REPLACEMENT` | +12 | `workType: replacement`. |
| `RECENTLY_ISSUED` | +12 | `issuedDate` within the last 30 days. |
| `LARGE_PROJECT_VALUE` | +12 | `projectValue >= $100,000`. |
| `RECENTLY_SUBMITTED` | +8 | No `issuedDate` yet, but `applicationDate` within the last 30 days. |
| `NO_CONTRACTOR_LISTED` | +8 | Permit record with no contractor name or company — an unsold job. |
| `RESTAURANT` | +8 | `propertyType: restaurant`. |
| `COMMERCIAL_PROPERTY` | +5 | Classifier determined the property is not residential. |
| `OLD_RECORD` | −20 | Most recent activity date is more than 365 days old. |
| `WORK_COMPLETED` | −12 | `permitStatus` indicates completed/final/closed/expired/withdrawn/cancelled. |
| `RESIDENTIAL` | −25 | Classifier determined the property is residential. |
| `MISSING_ADDRESS` | −25 | No street address — the record is real, but nobody can turn up at the building. |
| `SPARSE_RECORD` | −15 | No street address, no business name, and no description — almost nothing to act on. |
| `STATUS_CHANGED` | (no points) | Added by change detection when a record's status/dates changed since the last run — not a scoring rule, an informational signal. |

### 7. Daily fresh-leads workflow

Set `onlyNewRecords: true` and schedule the actor to run daily (Apify Console → Actor →
Schedules, or the Scheduler API). Each account's runs share one named key-value store
(`fire-permit-leads-state`) holding a fingerprint per record — permit number where available,
otherwise a normalized-address + fire-type + date fingerprint. This state is **per Apify
account**, so your daily schedule and another customer's daily schedule never see each other's
history. On each run, records not seen before are returned as new leads; records whose status,
dates, or violations changed since last seen are returned with a `STATUS_CHANGED` signal added;
unchanged records are suppressed entirely. Fingerprints older than 400 days are pruned
automatically so the state store doesn't grow unbounded.

For a daily schedule, also set `lookbackDays` (7 to 14 is a sensible range). Without it each
run re-reads the last 90 days from every source to find the handful of records that are new.
A shorter window is faster and lighter on the cities' portals; the trade-off is that a status
change on a record older than the window is not seen. Leave it unset for your first run so
the full 90 days are delivered once.

Houston is read for 3 days per run at most, whatever `lookbackDays` says: the day before the
run, in Houston's time, and the two days before it. `lookbackDays: 1` reads one day. A daily
run therefore reads again the two days before yesterday, and picks up what a failed or skipped
run missed. The coverage report lists the days read (`daysWalked`) and every ZIP and day that
was not read in full, with the reason (`zipDaysNotRead`). A gap older than 3 days is read by a
run with `startDate` and `endDate` both set to that day. If the city's server fails three times
in a row, or refuses a request, the run stops asking it and says so.

Run Houston one run at a time. Every request is 1.36 MB from a city server whose tolerance is
not known, and the adapter sends one request at a time for that reason. Two runs at once double
the load, and nothing in the actor prevents it: do not start a second run that includes Houston
while one is still running.

### 8. API usage examples

All three examples use the quick-start input from section 3.

**JavaScript (`apify-client`)**

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

const client = new ApifyClient({ token: '<YOUR_APIFY_TOKEN>' });

const { items } = await client.actor('<ACTOR_ID>').call({
  cities: ['Seattle, WA', 'San Francisco, CA', 'New York, NY'],
  permitTypes: ['fire_alarm', 'fire_sprinkler'],
  startDate: '2026-07-01',
  maxResults: 5000,
}).then((run) => client.dataset(run.defaultDatasetId).listItems());

console.log(items.length, 'leads');
```

**Python (`apify-client`)**

```python
from apify_client import ApifyClient

client = ApifyClient("<YOUR_APIFY_TOKEN>")

run = client.actor("<ACTOR_ID>").call(run_input={
    "cities": ["Seattle, WA", "San Francisco, CA", "New York, NY"],
    "permitTypes": ["fire_alarm", "fire_sprinkler"],
    "startDate": "2026-07-01",
    "maxResults": 5000,
})

items = list(client.dataset(run["defaultDatasetId"]).iterate_items())
print(len(items), "leads")
```

**curl**

```bash
curl "https://api.apify.com/v2/acts/<ACTOR_ID>/run-sync-get-dataset-items?token=<TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "cities": ["Seattle, WA", "San Francisco, CA", "New York, NY"],
    "permitTypes": ["fire_alarm", "fire_sprinkler"],
    "startDate": "2026-07-01",
    "maxResults": 5000
  }'
```

### 9. Troubleshooting

- **Run failed with `NoJurisdictionsMatched`.** None of your requested `cities`/`counties`/
  `states`/`zipCodes` resolved to a supported jurisdiction — see section 2 for the exact list
  and unsupported reasons. You are not charged when this happens.
- **A jurisdiction shows `failed` in the coverage report.** That source had an outage, or its
  upstream schema drifted (a probe canary detects sources that suddenly return zero records and
  refuses to report a false empty success — it's reported as failed instead). The run still
  returns results from every other requested jurisdiction. If a jurisdiction stays failed across
  multiple runs, please file an issue.
- **Zero results.** Usually means the date range is too narrow (try widening `startDate`), or
  `minimumLeadScore` is filtering everything out (try lowering it or setting it to `0`).
- **Socrata throttling / 429 errors under heavy use.** Set `socrataAppToken` to a free app token
  from any Socrata portal (e.g. `data.seattle.gov`, `data.cityofnewyork.us`) — this raises your
  per-host rate limit.

### 10. Data sourcing & responsible use

This actor collects **only** from public government open-data platforms and citizen-access
portals (Socrata, ArcGIS Feature Services, CKAN, Carto SQL, Accela Citizen Access, and Tyler
EnerGov CSS) that municipalities publish, or leave anonymously browsable, for exactly this kind of reuse — every
request is plain HTTP, never browser automation. It never bypasses authentication, solves a
CAPTCHA, or accesses non-public records. Requests are rate-limited and retried politely per host
— no aggressive parallel hammering of city infrastructure. Every record keeps a `source.url`
pointing back to the official dataset so a buyer can independently verify it. When a field isn't published by the source, the actor emits `null` — it never fabricates a contact name, phone
number, or any other detail not present in the government data. The `phone` and `email`
fields are filled only when `includeContactDetails` is on, only where the permit record
itself publishes one for that party, and exactly as published; nothing is looked up anywhere
else. With `includeRawData` on, `rawData` is the portal's row as published: it holds whatever
contact columns the portal publishes, whatever `includeContactDetails` says. A contractor can be a sole trader, so the calling and commercial-email rules that apply
to you apply to these fields.

# Actor input Schema

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

Cities as 'City, ST' (e.g. 'Seattle, WA'). See README for the supported list; unsupported cities are reported with a reason, never silently dropped.

## `counties` (type: `array`):

Counties as 'County, ST' (e.g. 'Mecklenburg, NC').

## `states` (type: `array`):

State codes or names (e.g. 'CA', 'Texas'). Expands to every supported jurisdiction in the state.

## `zipCodes` (type: `array`):

5-digit ZIP codes; matched to supported jurisdictions by prefix.

## `permitTypes` (type: `array`):

Restrict to specific fire-protection categories. Empty = all.

## `keywords` (type: `array`):

Case-insensitive keywords the record description must contain (e.g. 'NFPA 13').

## `startDate` (type: `string`):

Earliest record date (YYYY-MM-DD). Default: 'Lookback days' before the run, or 90 days if that is not set.

## `endDate` (type: `string`):

Latest record date (YYYY-MM-DD). Default: today.

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

How many days back to read when no start date is given. Default: 90. For a daily schedule with 'Only new/changed records' on, 7 to 14 keeps each run short; a start date, if set, wins.

## `includeInspections` (type: `boolean`):

Include fire inspection records (failed inspections are high-value leads).

## `includeViolations` (type: `boolean`):

Include fire-code violation records.

## `includeExpiredOrExpiring` (type: `boolean`):

Include records whose permits/certifications are expired or expiring soon.

## `includeResidential` (type: `boolean`):

Include single-family residential records (off by default — this is a commercial lead product).

## `onlyNewRecords` (type: `boolean`):

Return only records that are new or changed since your last run (per-account state). Run daily for a fresh-leads feed.

## `maxResults` (type: `integer`):

Hard cap on returned leads — you pay per result. Every requested source is read; if more leads match than the cap allows, the sources share it equally and each gives its best-scored leads.

## `minimumLeadScore` (type: `integer`):

Only return leads scoring at least this (0–100). 60+ = strong signals only.

## `includeRawData` (type: `boolean`):

Attach the original source record under rawData.

## `includeContactDetails` (type: `boolean`):

Add the phone number and email address that a permit record itself publishes for its contractor, applicant or owner. Off by default: both fields are then null on every record. Filled only where the city's own record carries them (today Raleigh, Austin and Mesa), copied as published, never looked up anywhere else. With Include raw source data on, rawData is the portal's row as published and holds whatever contact columns the portal publishes, whatever this setting says.

## `includeSourceMetadata` (type: `boolean`):

Include merged-source provenance details. The primary source URL is always included.

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

Optional token from any Socrata portal — raises rate limits for heavy use.

## Actor input object example

```json
{
  "cities": [
    "Seattle, WA",
    "San Francisco, CA"
  ],
  "includeInspections": true,
  "includeViolations": true,
  "includeExpiredOrExpiring": true,
  "includeResidential": false,
  "onlyNewRecords": false,
  "maxResults": 500,
  "minimumLeadScore": 0,
  "includeRawData": false,
  "includeContactDetails": false,
  "includeSourceMetadata": true
}
```

# Actor output Schema

## `leads` (type: `string`):

Every lead the run produced, best-scored first, in the compact Leads view (score, fire system, work type, business, address, permit number, status, issued date, description, signals, source).

## `coverageReport` (type: `string`):

Which requested jurisdictions succeeded, failed, were unsupported, or were skipped by the maxResults stop, with per-source counts and coverage. Read this before trusting a small result.

# 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": [
        "Seattle, WA",
        "San Francisco, CA"
    ],
    "maxResults": 500
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapelabmax/us-fire-permit-leads-scraper").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": [
        "Seattle, WA",
        "San Francisco, CA",
    ],
    "maxResults": 500,
}

# Run the Actor and wait for it to finish
run = client.actor("scrapelabmax/us-fire-permit-leads-scraper").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": [
    "Seattle, WA",
    "San Francisco, CA"
  ],
  "maxResults": 500
}' |
apify call scrapelabmax/us-fire-permit-leads-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapelabmax/us-fire-permit-leads-scraper"
        }
    }
}
```

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/cR3xb56Vxs2M9NGEe/builds/YgcAzX1Jv8i05D7i6/openapi.json
