# Accela Building Permit Scraper: Contractors & Status Changes (`finaldynamics/building-permit-lookup-scraper`) Actor

Building permit data and leads from any Accela Citizen Access city or county: permit type, status, address, valuation and contractor details, with status change alerts.

- **URL**: https://apify.com/finaldynamics/building-permit-lookup-scraper.md
- **Developed by:** [Final Dynamics](https://apify.com/finaldynamics) (community)
- **Categories:** Lead generation, Real estate
- **Stats:** 2 total users, 1 monthly users, 50.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $6.00 / 1,000 permit records

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

## Accela Building Permit Scraper: Contractors & Status Changes

Pull building permit data and building permit leads from any city or county that runs Accela Citizen
Access, the permitting portal used by hundreds of US jurisdictions. Give it an agency and a date
range; get one clean row per permit with the type, status, address, valuation and, if you want them,
the contractor's name, licence and phone from the permit page.

Built for people who buy permit data on a schedule: roofing, solar, HVAC and remodeling contractors
looking for fresh projects, lead sellers, suppliers watching new construction, and anyone who needs
a weekly feed instead of a one-off export.

### What you get

One row per permit. Every field is flat, so the dataset drops straight into Google Sheets, Airtable
or a CRM.

| Field                                                         | Example                                                                                           |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `permitNumber`                                                | `BP-202603758`                                                                                    |
| `permitType`                                                  | `Commercial - Fire Sprinkler`                                                                     |
| `permitTypeNormalized`                                        | `roofing`, `solar`, `hvac`, `new_construction`, `electrical`, `plumbing`, `demolition` or `other` |
| `status`                                                      | `Routed for Review`                                                                               |
| `appliedDate`                                                 | `2026-09-15`                                                                                      |
| `addressFull`, `street`, `addressCity`, `addressState`, `zip` | `140 CHASTAIN PARK AVE NW, ATLANTA GA 30339`                                                      |
| `description`                                                 | Project description as published                                                                  |
| `declaredValuationUsd`                                        | `48500` (from the permit page, when published)                                                    |
| `contractorName`, `contractorLicense`, `contractorPhone`      | From the permit page with `includeDetails`                                                        |
| `parcelId`, `issuedDate`, `finalDate`                         | From the permit page, when published                                                              |
| `detailUrl`                                                   | Direct link to the public permit record                                                           |
| `agencyKey`, `agencyName`, `city`, `state`, `module`          | Which portal the row came from                                                                    |
| `isNewSinceLastRun`, `previousStatus`, `statusChanged`        | Set by the incremental and monitoring modes                                                       |
| `scrapedAt`                                                   | UTC timestamp                                                                                     |

Anything the portal does not publish is `null`. Owner names are off by default because owners are
usually private individuals; turn on `includeOwnerName` together with `includeDetails` if you need
them and your use allows it.

### How to use it

Pick an agency, set a date range, run.

```json
{
  "agency": "ATLANTA_GA",
  "module": "Building",
  "dateFrom": "2026-09-08",
  "dateTo": "2026-09-15",
  "maxResults": 200
}
```

Both dates are inclusive. `maxResults` caps the rows you pay for.

The actor reads the selected module's **Record Type** list and uses `permitTypeContains` to choose
matching permit types for server-side searches. It scans the full date window and filters rows
locally when the agency publishes no list, nothing matches, or more than 25 types match; the run log
explains the fallback. Leaving both filters empty includes all types.

To choose types yourself, set `recordTypes` to names or raw values from that menu, for example
`["Commercial Demolition"]` in Atlanta. Up to 25 explicit types are accepted. An unknown name stops
the run with examples from the available list; if the list is unavailable, clear `recordTypes` to
use the full scan. `permitTypeContains` still filters the returned rows when `recordTypes` is set.

`maxPages` defaults to 60 result pages across all type searches, including searches with no matches.
At the limit the run stops and tells you to raise `maxPages`, narrow `lastDays` or
`dateFrom`/`dateTo`, or select fewer `recordTypes`. Plain runs write rows page by page, preserving
completed pages if a later request fails or the run is killed. With `includeDetails`, rows wait
until the run-wide detail failure check passes. Persistent status-monitor runs keep all-or-nothing
delivery: an incomplete run writes no rows and does not seed or advance its baseline. Both
`maxPages` and `statusMonitorMaxPages` apply to monitors across all searches; the lower limit wins.
An incomplete run never advances the `sinceLastRun` watermark.

**Recent permits on a schedule:** set `lastDays: 14` to search the last 14 calendar days ending
today (UTC), including today. The date window moves automatically on every run, overriding
`dateFrom` and `dateTo`, so saved tasks stay current. Leave `lastDays` at `0` to use fixed dates.

**Roofing and solar jobs worth $10,000 or more, with contractor details:**

```json
{
  "agency": "SACRAMENTO",
  "module": "Building",
  "dateFrom": "2026-09-01",
  "dateTo": "2026-09-15",
  "permitTypeContains": ["roof", "solar"],
  "minValuation": 10000,
  "includeDetails": true
}
```

`permitTypeContains` matches any of the words against the permit type. Valuation and contractor
fields live on the permit page, so `includeDetails` must be on for `minValuation` to apply.

**Only permits you have not seen before:** create a key-value store in Apify Console, select it as
`stateStoreId`, set `sinceLastRun: true`, and reuse the same run configuration (a saved task). Each
complete run remembers the newest applied date it delivered and the next run emits only permits with
a newer applied date. A run that hits a cap or an error never moves that marker, so nothing is
skipped.

**Status changes on a schedule:** create a key-value store in Apify Console, select it as
`statusStateStoreId`, and schedule the task. The first complete run stores every permit's status;
later runs emit only permits whose status changed, with `previousStatus` filled in. Give each
schedule its own store.

**Other record types:** set `module` to `Enforcement` for code enforcement cases or `Planning` for
planning and zoning cases. The same fields are used; `permitNumber` holds the case number.

**Any other Accela portal:** set `agency` to the portal's agency code and `portalUrl` to its base
URL, for example `https://aca-prod.accela.com/CITYNAME/`. Portals must offer a public General Search
with a date range; a handful do not, and the run reports that clearly.

### Quick start

No code needed; about two minutes from input to spreadsheet. For a longer walkthrough with example
output, read the guide
[Build a permit research spreadsheet](https://finaldynamics.com/permit-guide.html).

1. Open the example "Sacramento roofing and solar permits, last 14 days" on this actor's page, or
   paste one of the inputs above into the input form.
2. Click **Start** and wait until the run shows **Succeeded**.
3. Open the **Output** tab to see the permits as a table.
4. Click **Export**, choose CSV or Excel, and download the file.
5. In Google Sheets, choose **File > Import > Upload** and select the file.

### Automate it

**A weekly permit pull:** keep `lastDays` in the task (for example 7 or 14) so the date window moves
with every run. Click **Save as a new task**, then add a schedule for that task under **Schedules**
in Apify Console (for example every Monday at 07:00). To get only permits you have not received
before, turn on `sinceLastRun` and select a key-value store as `stateStoreId`; for status changes
use `statusStateStoreId` as described above.

**Into Google Sheets with n8n:** add the **Apify** node with the **On new Apify Event** trigger,
choose your task and the succeeded event, then add an **Apify** node with **Get Dataset Items**
using the run's `defaultDatasetId`, and finish with a **Google Sheets** node that appends the rows.
Every scheduled run then adds its permits to the same sheet. Make and Zapier have Apify integrations
that work the same way.

### Pricing

Pay per event. There are no platform usage charges on top.

| Event                    |  Price | When                                                      |
| ------------------------ | -----: | --------------------------------------------------------- |
| `actor-start`            |  $0.01 | Once per run                                              |
| `permit-record`          | $0.006 | Per permit row delivered                                  |
| `contractor-detail-page` | $0.004 | Per permit page fetched, only when `includeDetails` is on |

Examples: 200 permits without details cost $1.21. Twenty permits with contractor details cost $0.21.
A weekly pull of 500 permits with details is about $5.01. A run that finds nothing costs $0.01.

Set a maximum charge on the run if you want a hard ceiling; the actor stops cleanly at the cap.

### Coverage

These portals are bundled and verified. Any other portal hosted on `aca-prod.accela.com` works with
`portalUrl`; portals a city hosts on its own domain are not supported yet.

| Agency key     | Jurisdiction        | State |
| -------------- | ------------------- | ----- |
| `ANAHEIM`      | Anaheim             | CA    |
| `ATLANTA_GA`   | Atlanta             | GA    |
| `BREA`         | Brea                | CA    |
| `CRYSTALLAKE`  | Crystal Lake        | IL    |
| `CULVERCITY`   | Culver City         | CA    |
| `DELAND`       | DeLand              | FL    |
| `DOWNEY`       | Downey              | CA    |
| `FONTANA`      | Fontana             | CA    |
| `GOODYEAR`     | Goodyear            | AZ    |
| `HARTFORD`     | Hartford            | CT    |
| `HCFL`         | Hillsborough County | FL    |
| `HERMOSABEACH` | Hermosa Beach       | CA    |
| `LOVELAND`     | Loveland            | CO    |
| `MANTECA`      | Manteca             | CA    |
| `MILWAUKEE`    | Milwaukee           | WI    |
| `MISSOULA`     | Missoula            | MT    |
| `MONTEREYPARK` | Monterey Park       | CA    |
| `PALOALTO`     | Palo Alto           | CA    |
| `RENO`         | Reno                | NV    |
| `SACRAMENTO`   | Sacramento          | CA    |
| `STOCKTON`     | Stockton            | CA    |
| `TAMPA`        | Tampa               | FL    |
| `THURSTONCO`   | Thurston County     | WA    |
| `VISALIA`      | Visalia             | CA    |

Some fields vary by agency: a few portals publish no valuation in the results grid, and a few list
the address in a different column. Rows always carry what the portal shows.

### Good to know

- If a permit detail page remains unavailable after retries, its grid row is retained with
  `detailFetchFailed: true`, null detail-only fields and no detail-page charge, unless more than 20%
  of attempted detail pages or more than 25 pages fail, which stops the run before row delivery.
- Public records only. The actor reads the same pages a visitor sees, honours the portal's robots
  rules, and never logs in, solves CAPTCHAs or uses proxies. If a portal blocks access, the run
  fails with a clear message rather than returning an empty result.
- Requests are polite: at most two at a time per portal, with retries and backoff on errors.
- Dates are the portal's local calendar dates. A permit's applied date is what the agency shows in
  the results grid; on some portals that is the last update date.
- The actor is not affiliated with Accela or with any listed agency. Public records can be
  incomplete or corrected later.

### FAQ

**How do I get a list of new building permits for my city?**

If your city or county runs Accela Citizen Access (the site address usually contains
`aca-prod.accela.com`), enter its agency key and a date range or `lastDays`. The Coverage table
lists the portals we have verified.

**Can I get roofing, solar or HVAC permit leads only?**

Yes. Put words from the permit type in `permitTypeContains`, for example `["roof", "solar"]`, and
turn on `includeDetails` for the contractor's name, licence and phone.

**Can I look up a permit by address?**

The actor searches by date, not by address. Pull the date range you need and filter the address
column in your spreadsheet.

**Is building permit data public?**

Yes. Permits are public records, and the actor reads the same public pages anyone can open on the
city's portal.

### Related actors

- [Code Violation Leads](https://apify.com/finaldynamics/code-violation-leads): code enforcement
  cases from Accela cities and counties.
- [New Homeowner & Business Leads (Certificates of Occupancy)](https://apify.com/finaldynamics/certificate-of-occupancy-leads):
  newly finished homes and business premises.
- [Florida Lis Pendens, Pre-Foreclosure & Deed Leads](https://apify.com/finaldynamics/florida-lis-pendens-leads):
  lis pendens and deeds from five Florida county clerks.

Questions or a portal that misbehaves? Open an issue on the actor page and include the agency and
date range.

# Actor input Schema

## `agency` (type: `string`):

Accela agency key. See agencies.json for 24 verified portal URLs.

## `portalUrl` (type: `string`):

HTTPS base URL ending with the agency key and a slash. Bundled deployment permits aca-prod.accela.com.

## `module` (type: `string`):

Citizen Access module using General Search: Building, Enforcement or Planning. Known names are trimmed and normalized; other module names retain their casing. Availability varies by agency; unsupported modules fail clearly.

## `dateFrom` (type: `string`):

Inclusive applied date, YYYY-MM-DD.

## `dateTo` (type: `string`):

Inclusive applied date, YYYY-MM-DD.

## `lastDays` (type: `integer`):

Search the last N calendar days ending today, instead of fixed dateFrom and dateTo. Leave at 0 to use the fixed dates.

## `recordTypes` (type: `array`):

Names or values from the selected module's Record Type list, for example "Commercial Demolition" in Atlanta. The portal searches only those types. At most 25 explicit types are allowed; unknown names stop the run with examples of available types. Leave empty to select types automatically using permitTypeContains. If no list is published, no types match, or more than 25 match, the actor scans the date window and filters rows locally.

## `permitTypeContains` (type: `array`):

Case-insensitive substring matching; any listed string may match. Empty includes all types. With recordTypes empty, matching types from the portal list are searched directly (up to 25). If automatic selection is unavailable, finds no matches, or exceeds 25 matches, the date window is scanned. This filter still applies to returned rows, including when recordTypes is set.

## `minValuation` (type: `number`):

Zero disables filtering. A positive value excludes unknown valuations; enable includeDetails to read valuation when published.

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

The most rows one run delivers. When a run stops at this limit, the "only new permits" memory is not updated, so nothing is skipped next time.

## `maxPages` (type: `integer`):

Ceiling on result pages read across all record-type searches (default 60). At the limit the run stops and names the input to change. Narrow the dates, select recordTypes, or raise maxPages. Plain runs retain delivered rows; detail runs deliver only after the failure threshold check. Incomplete status-monitor runs deliver no rows or baseline changes. sinceLastRun is not advanced on an incomplete run.

## `includeDetails` (type: `boolean`):

Fetch public detail links for contractor, parcel, dates and valuation. Owner names additionally require includeOwnerName. Each fetched detail page costs $0.004, even if a valuation filter later excludes the record.

## `includeOwnerName` (type: `boolean`):

Include the property owner's name from the permit detail page. Off by default because owners are usually private individuals.

## `sinceLastRun` (type: `boolean`):

Deliver only permits applied for after the newest one a previous run delivered. Needs a key-value store selected below as stateStoreId; give each schedule its own. A run that stops early does not move the marker forward.

## `stateStoreId` (type: `string`):

Key-value store that remembers the newest applied date delivered by sinceLastRun runs. Create one in Apify Console and select it here; each schedule should have its own.

## `statusBaseline` (type: `array`):

Optional list of permit numbers and statuses from an earlier run, to compare against. Permit numbers match without regard to case.

## `statusChangesOnly` (type: `boolean`):

Deliver only permits from the list above whose status is different now. Needs a non-empty list.

## `statusStateStoreId` (type: `string`):

A key-value store the actor uses to remember every permit's status between scheduled runs. The first complete run delivers everything and saves the statuses; later runs deliver only permits whose status changed. Use one store per schedule and let runs finish one at a time.

## `statusMonitorMaxPages` (type: `integer`):

Safety limit on search result pages when watching status changes. If the limit is reached, the run stops and the saved statuses stay as they were. Counts across all record-type searches; maxPages also applies, and the lower limit wins.

## `statusMonitorMaxRecords` (type: `integer`):

Safety limit on permits read when watching status changes. If the limit is reached, the run stops and the saved statuses stay as they were.

## Actor input object example

```json
{
  "agency": "ATLANTA_GA",
  "portalUrl": "https://aca-prod.accela.com/ATLANTA_GA/",
  "module": "Building",
  "dateFrom": "2026-09-06",
  "dateTo": "2026-09-13",
  "lastDays": 0,
  "recordTypes": [],
  "permitTypeContains": [],
  "minValuation": 0,
  "maxResults": 200,
  "maxPages": 60,
  "includeDetails": false,
  "includeOwnerName": false,
  "sinceLastRun": false,
  "statusBaseline": [],
  "statusChangesOnly": false,
  "statusMonitorMaxPages": 100,
  "statusMonitorMaxRecords": 5000
}
```

# Actor output Schema

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

Crawled data stored in default dataset

## `files` (type: `string`):

No description

# 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 = {
    "agency": "ATLANTA_GA",
    "portalUrl": "https://aca-prod.accela.com/ATLANTA_GA/",
    "module": "Building",
    "dateFrom": "2026-09-06",
    "dateTo": "2026-09-13",
    "lastDays": 0,
    "recordTypes": [],
    "permitTypeContains": [],
    "minValuation": 0,
    "maxResults": 200,
    "maxPages": 60,
    "includeDetails": false,
    "includeOwnerName": false,
    "sinceLastRun": false,
    "statusBaseline": [],
    "statusChangesOnly": false,
    "statusMonitorMaxPages": 100,
    "statusMonitorMaxRecords": 5000
};

// Run the Actor and wait for it to finish
const run = await client.actor("finaldynamics/building-permit-lookup-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 = {
    "agency": "ATLANTA_GA",
    "portalUrl": "https://aca-prod.accela.com/ATLANTA_GA/",
    "module": "Building",
    "dateFrom": "2026-09-06",
    "dateTo": "2026-09-13",
    "lastDays": 0,
    "recordTypes": [],
    "permitTypeContains": [],
    "minValuation": 0,
    "maxResults": 200,
    "maxPages": 60,
    "includeDetails": False,
    "includeOwnerName": False,
    "sinceLastRun": False,
    "statusBaseline": [],
    "statusChangesOnly": False,
    "statusMonitorMaxPages": 100,
    "statusMonitorMaxRecords": 5000,
}

# Run the Actor and wait for it to finish
run = client.actor("finaldynamics/building-permit-lookup-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 '{
  "agency": "ATLANTA_GA",
  "portalUrl": "https://aca-prod.accela.com/ATLANTA_GA/",
  "module": "Building",
  "dateFrom": "2026-09-06",
  "dateTo": "2026-09-13",
  "lastDays": 0,
  "recordTypes": [],
  "permitTypeContains": [],
  "minValuation": 0,
  "maxResults": 200,
  "maxPages": 60,
  "includeDetails": false,
  "includeOwnerName": false,
  "sinceLastRun": false,
  "statusBaseline": [],
  "statusChangesOnly": false,
  "statusMonitorMaxPages": 100,
  "statusMonitorMaxRecords": 5000
}' |
apify call finaldynamics/building-permit-lookup-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,finaldynamics/building-permit-lookup-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/vxE2abDZ2RzkD0uJ5/builds/XCPTJHd7EupkjUG4t/openapi.json
