# Building Permit Address Lookup - Open & Expired Permit Check (`maydit/building-permit-address-lookup`) Actor

Check an address in 10 supported US cities for building permits: permit history, open permits, and permits issued but never finaled. One verdict row per address. No homeowner personal data.

- **URL**: https://apify.com/maydit/building-permit-address-lookup.md
- **Developed by:** [Brandt May](https://apify.com/maydit) (community)
- **Categories:** Real estate, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $12.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

## Building Permit Address Lookup - Open & Expired Permit Check

Check a list of street addresses against a city's building permit records and get back **one verdict row per address**, not a bulk permit dump. For every address this Actor reports the permit history, how many permits are still open, and - the field most people come for - whether any permit was **issued, passed its expiration date, and never finaled**. That last condition is the classic sign of unpermitted or unfinished work, and it is what shows up as a problem during a property sale, a title review, or an insurance inspection.

This is built for people checking specific properties: real estate agents and buyers doing pre-offer due diligence, insurance underwriters assessing a risk, title and escrow officers clearing a file, contractors checking whether a prior job at an address was ever closed out, and property managers auditing a portfolio. You submit a list of addresses and get back a verdict for each one, rather than a whole-city permit feed you then have to filter yourself.

It queries official municipal open-data portals (Socrata / SODA), normalizes every city into one schema, and tells you honestly how good each address match was.

***

### What you get

One row per address you submit. Every row carries the verdict fields plus a nested `permits` array with the matching permits.

| Field | Type | Description |
|---|---|---|
| `queryAddress` | string | The address exactly as you submitted it |
| `normalizedAddress` | string | The canonical form used for matching (`4414 BANISTER LN`) |
| `city`, `state` | string | The portal that was queried |
| `matchConfidence` | string | `exact`, `normalized`, `fuzzy`, or `none` - the weakest grade among the permits this row counts; see below |
| `permitCount` | number | Permits matched to this address within the lookback window |
| `permitCapReached` | boolean | **true** when the portal returned `maxPermitsPerAddress` records for this address, so older permits were not fetched and every count in the row is a lower bound. The summary then says "at least N" (or, when none of the fetched records matched, that none matched among the records checked) |
| `earliestPermit`, `latestPermit` | string | Date range of the matched permits (`YYYY-MM-DD`) |
| `openPermitCount` | number / null | Permits not finaled, completed, withdrawn, or cancelled. Permits with neither a status nor a completion date are not counted here - see `statusUnknownCount`. `null` when the portal publishes no status |
| `statusUnknownCount` | number / null | Permits that carry neither a status nor a completion date, so whether they are open cannot be determined (in Chicago: sign, elevator equipment, scaffolding and Easy Permit records). `null` when the portal publishes no status |
| `issuedNeverFinaledCount` | number / null | Open permits that were actually issued (not just applications in review) |
| `expiredButOpen` | boolean / null | **true** when at least one permit is issued, past expiry, and never finaled. `null` when the portal publishes no expiration date |
| `expiredButOpenCount` | number / null | How many permits meet that condition |
| `humanSummary` | string | A plain-English sentence you can paste into a report |
| `verdictBasis` | string | States exactly what evidence the verdict rests on for this city |
| `permits[]` | array | Matched permits: id, type, class, status, description, valuation, applied / issue / expiry / completion dates, lat/lon, source link, per-permit `matchConfidence`, `isOpen` (`null` when unknown) and `expiredButOpen` |
| `portal`, `scrapedAt` | string | Source dataset and run timestamp |

#### How `matchConfidence` works

Addresses in permit systems are typed by hand, so this Actor grades every match instead of pretending they are all equal:

- **`exact`** - the portal's street line is character-identical to yours once case and punctuation are ignored.
- **`normalized`** - identical after expanding abbreviations (`LANE` / `LN`, `WEST` / `W`), writing numbered streets one way (`34th` / `34`, `3rd` / `03rd`, `Fifth` / `5`), and setting aside a unit or building designator. Same property, different spelling.
- **`fuzzy`** - same house number and street name, but one side carries tokens the other does not: a street direction you did not enter (leading, or trailing like the `S` in `Rainier Ave S`), a leading direction you entered that the portal omits, a missing street type, or extra words. **Verify these before relying on them.**
- **`none`** - nothing matched. The row is still returned, with `permitCount: 0`.

A row's `matchConfidence` is the **weakest** grade among the permits it counts, so a row reads `exact` only when every permit in it matched exactly; each permit also carries its own grade.

Street directions and street types have to agree. A direction or street type that conflicts with the one you typed never matches: `500 E Cesar Chavez St` does not pick up `500 W`, `1000 4th Ave S` does not pick up `1000 4th Ave`, and `200 Park Avenue South` does not pick up `200 Park Avenue`. If you leave out a direction (leading or trailing), permits that add one are used only when nothing matches the address exactly as you typed it - `100 Broadway` does not pick up `100 East Broadway`, `1000 4th Ave` does not pick up `1000 4th Ave S`, `200 Park Avenue` does not pick up `200 Park Avenue South`. When they are used (`3820 Rainier Ave` finds the permits at `3820 Rainier Ave S`), they are graded `fuzzy` and the summary names the direction you left out; when they carry more than one direction (`500 E` and `500 W`), the summary says they may be different buildings and asks you to add it.

Numbered streets can be typed the normal way: `151 West 34th Street` and `151 West 34 Street` find the same New York permits.

#### What is deliberately NOT in the output

**No homeowner names. No personal phone numbers. No personal email addresses.** Several of these portals publish the property owner's name right next to the permit, and this Actor strips it. Permit party names are emitted only when the name reads as a business (`Vargas Electric LLC`, `Kind Roofing`); anything that reads as an individual is withheld and flagged with `contractorNameWithheld: true`.

This is the differentiator, not an oversight. Competitors in this category bolt on skip-tracing and contact enrichment, so what starts as a permit check arrives as a lead list attached to somebody's home address. This Actor does not do that. It reads public government records, returns a verdict about the property, and stops there - there is no personal-contact data in the output to strip out later, and nothing here is sourced from a private marketplace or directory. If you need homeowner contact data, this is not the tool, and that is on purpose.

***

### Input

| Field | Type | Default | Description |
|---|---|---|---|
| `addresses` | array | 3 sample Austin addresses | Street addresses to check, one per line. House number required; unit, city, state and ZIP are optional and ignored when matching |
| `city` | select | `austin` | Which municipal portal to query. **One city per run** - addresses from other cities will not match |
| `lookbackYears` | integer | `10` | How far back to search from today (1-60) |
| `maxPermitsPerAddress` | integer | `100` | Cap on permits fetched per address before matching, newest first. A row that hits it says so with `permitCapReached: true` |
| `maxRunSeconds` | integer | `240` | Stop cleanly at this point and keep what has been written |
| `socrataAppToken` | string | empty | Optional Socrata token to raise rate limits on long lists. Not required |

#### Supported cities and what each one can actually tell you

Not every portal publishes the data an open/expired verdict needs. Rather than guess, this Actor returns `null` for verdicts a city cannot support and says so in `verdictBasis` and `humanSummary`.

| City | Open verdict | Expired-but-open verdict |
|---|---|---|
| Austin, TX | yes | yes |
| Seattle, WA | yes | yes |
| Cincinnati, OH | yes | yes |
| New York, NY | yes | yes |
| Chicago, IL | yes, except permit types published with no status (signs, elevator equipment, scaffolding, Easy Permit), which are counted in `statusUnknownCount` | no - portal publishes no expiration date |
| San Francisco, CA | yes | no - portal publishes no expiration date |
| Los Angeles, CA | yes | no - portal publishes no expiration date |
| Mesa, AZ | yes | no - portal publishes no expiration date |
| Baton Rouge, LA | no - portal publishes no permit status | no |
| Marin County, CA | no - portal publishes no permit status | no |

***

### Example output

A real result for `2603 Ware Rd` in Austin, copied verbatim and unedited from a run of this Actor. The permits are shown in full, in the order the Actor emits them (most recent first).

```json
{
  "queryAddress": "2603 Ware Rd",
  "normalizedAddress": "2603 WARE RD",
  "city": "Austin",
  "state": "TX",
  "matchConfidence": "exact",
  "permitCount": 2,
  "permitCapReached": false,
  "earliestPermit": "2024-01-17",
  "latestPermit": "2024-02-23",
  "openPermitCount": 1,
  "statusUnknownCount": 0,
  "issuedNeverFinaledCount": 1,
  "expiredButOpen": true,
  "expiredButOpenCount": 1,
  "humanSummary": "2 permits on record between 2024-01-17 and 2024-02-23. 1 still open (not finaled or closed out). 1 of those is past the permit expiry date with no final inspection on record - worth investigating before closing or binding coverage.",
  "verdictBasis": "open/closed derived from permit status and completion date; expiry derived from the published permit expiration date",
  "lookbackYears": 10,
  "permits": [
    {
      "permitId": "2024-004889 MP",
      "permitType": "Mechanical Permit",
      "permitClass": "Residential",
      "status": "Final",
      "workDescription": "REPLACEMENT OF LONESTAR UPFLOW NATURAL GAS 3.5 TON 15.20 SEER CONDENSOR AND 2 FLEX DUCT OUTLETS",
      "sourceAddress": "2603 WARE RD",
      "zip": "78741",
      "valuation": null,
      "contractorName": null,
      "appliedDate": "2024-01-15T00:00:00.000",
      "issueDate": "2024-02-23T00:00:00.000",
      "expiryDate": "2024-03-01T00:00:00.000",
      "completedDate": "2024-03-01T00:00:00.000",
      "latitude": 30.22428863,
      "longitude": -97.73227205,
      "sourceUrl": "https://abc.austintexas.gov/web/permit/public-search-other?t_detail=1&t_selected_folderrsn=13265815",
      "contractorNameWithheld": false,
      "matchConfidence": "exact",
      "isOpen": false,
      "expiredButOpen": false
    },
    {
      "permitId": "2024-004892 EP",
      "permitType": "Electrical Permit",
      "permitClass": "Residential",
      "status": "Active",
      "workDescription": "Installing an outdoor meter loop. Disconnect/Reconnect",
      "sourceAddress": "2603 WARE RD",
      "zip": "78741",
      "valuation": null,
      "contractorName": "JON WAYNE ELECTRICAL",
      "appliedDate": "2024-01-15T00:00:00.000",
      "issueDate": "2024-01-17T00:00:00.000",
      "expiryDate": "2024-08-10T00:00:00.000",
      "completedDate": null,
      "latitude": 30.22428863,
      "longitude": -97.73227205,
      "sourceUrl": "https://abc.austintexas.gov/web/permit/public-search-other?t_detail=1&t_selected_folderrsn=13265818",
      "contractorNameWithheld": false,
      "matchConfidence": "exact",
      "isOpen": true,
      "expiredButOpen": true
    }
  ],
  "portal": "https://data.austintexas.gov/resource/3syk-w9eu",
  "scrapedAt": "2026-09-24T03:28:07.832Z"
}
```

The second permit above - the electrical one - was issued on 2024-01-17, expired on 2024-08-10, and carries no completion date. That is what `expiredButOpen: true` means. The mechanical permit listed first was finaled on 2024-03-01, so it counts toward `permitCount` and nothing else.

***

### FAQ

**What exactly counts as an "open" permit?**
A permit with no completion or final-inspection date whose status is not a closed-out one (finaled, completed, closed, signed-off, withdrawn, cancelled, void, revoked, rejected, denied, or a certificate of completion or occupancy issued). A status of **Expired** is deliberately counted as open, because an expired permit is precisely one that was never finaled. A status that names a finished step before the permit itself is finished - Seattle's "Application Completed" and "Reviews Completed", Los Angeles's "CofO in Progress" - counts as open. Permits still in review with no issue date count as open too, and are excluded from `issuedNeverFinaledCount` so you can tell the two apart. A permit that carries neither a status nor a completion date is counted as neither open nor closed: its `isOpen` is `null` and it goes into `statusUnknownCount`.

**Does `expiredButOpen: true` prove there is unpermitted work?**
No, and the Actor does not claim it does. It means the city's own published record shows a permit that was issued, has passed its expiration date, and carries no final inspection or completion date. Records also lag reality - a permit may have been finaled without the portal being updated. Treat it as a flag to investigate with the building department, not as a finding.

**Why is one address `fuzzy` and another `exact`?**
Because permit clerks type addresses by hand. `4414 BANISTER LN BLDG 1` and `4414 Banister Ln` are the same parcel but not the same string, so that match is graded `normalized`. A match missing a directional (`1847 Newcastle Ave` against `1847 N NEWCASTLE AVE`) is graded `fuzzy`. A conflicting direction or street type (`500 E` against `500 W`) is not a match at all. The grade is in every row and on every permit so you can decide how much to trust it.

**Can I check addresses in several cities in one run?**
No. Each run queries one city portal, because the address filter is built for that portal's specific schema. Run the Actor once per city; results append to the same dataset if you want them together.

**Why are contractor names sometimes blank?**
Either the portal publishes no contractor for that permit (San Francisco and Los Angeles publish none at all on these datasets), or the name looked like a person rather than a business and was withheld on purpose. `contractorNameWithheld: true` distinguishes the second case from the first.

**What happens if the run is too slow for a long address list?**
It stops cleanly at `maxRunSeconds`, keeps every row already written, and logs how many addresses were left. Raise `maxRunSeconds` or split the list.

***

### Data source, access, and limits

**Permit data** comes from official municipal open-data portals running Socrata (SODA), one dataset per city, listed in the table above. These are public government APIs that need no login and no API key. The optional `socrataAppToken` only raises rate limits on long runs; everything works without it.

**That is the only source.** Every field in the output traces back to a government open-data record, so there is no third-party site in the path that can rate-limit the run, block it, or change its terms underneath you. Nothing is read from a private marketplace, contractor directory, or lead broker.

**Known limits.** Coverage is the ten cities listed, not nationwide. Verdict quality varies by city exactly as the capability table states. Open-data portals refresh on their own schedules (typically daily to weekly), so very recent permits may not appear yet, and a permit finaled last week may still read as open. Addresses without a house number cannot be matched and are reported as errors. Large commercial parcels can exceed the default 100-permit cap - the row then says `permitCapReached: true` and "at least N"; raise `maxPermitsPerAddress` for those. New York is matched on house number and street name across all five boroughs, so the same address in two boroughs is not told apart.

**Billing.** This Actor is billed per result on the Apify platform: one result is one address verdict row, regardless of how many permits that address turns out to have. Addresses that fail to look up are not emitted and therefore are not charged. Check the pricing shown on the Actor's Apify Store page for the current rate.

**Legal.** Permit records are public government data. Nothing here is a substitute for an official records search, a title report, or a municipal building department inquiry, and none of it is legal, insurance, or investment advice.

# Actor input Schema

## `addresses` (type: `array`):

One street address per line, in the city selected below. A house number is required; unit, city, state and ZIP are optional and are ignored when matching (e.g. "4414 Banister Ln", "4414 Banister Ln, Austin, TX 78745"). Each address produces exactly one result row. Leave empty to run a built-in 3-address Austin sample.

## `city` (type: `string`):

Which municipal permit portal to query. One city per run - addresses from another city will not match. The label states what each portal supports: only some publish the expiration and final-inspection data needed to flag an expired-but-open permit.

## `lookbackYears` (type: `integer`):

How far back to search permit history from today. 10 years covers most disclosure and underwriting needs; raise it for a full property history, lower it to speed up a large address list.

## `maxPermitsPerAddress` (type: `integer`):

Upper bound on permits fetched per address before matching, newest first. The default of 100 covers any normal parcel; large commercial sites can hold several hundred permits, so raise it if you are checking one. When an address hits this cap its row says so (permitCapReached: true, and the summary reads "at least N"), because older permits were not fetched.

## `maxRunSeconds` (type: `integer`):

Stop cleanly after this many seconds and keep whatever has already been written, rather than being killed mid-run. Raise it for long address lists - roughly 1-2 seconds per address.

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

Optional Socrata app token to raise the open-data rate limit on long address lists. Free to obtain from any Socrata portal. Not required for normal use.

## Actor input object example

```json
{
  "addresses": [
    "2603 Ware Rd",
    "4414 Banister Ln",
    "1306 Payne Ave"
  ],
  "city": "austin",
  "lookbackYears": 10,
  "maxPermitsPerAddress": 100,
  "maxRunSeconds": 240
}
```

# Actor output Schema

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

One row per input address: a permit verdict (counts, open permits, expired-but-open flag, match confidence) with the matching permits nested inside.

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

Totals for the run, including how many records were requested versus returned and whether the run stopped early on its time budget.

# 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 = {
    "addresses": [
        "2603 Ware Rd",
        "4414 Banister Ln",
        "1306 Payne Ave"
    ],
    "city": "austin"
};

// Run the Actor and wait for it to finish
const run = await client.actor("maydit/building-permit-address-lookup").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 = {
    "addresses": [
        "2603 Ware Rd",
        "4414 Banister Ln",
        "1306 Payne Ave",
    ],
    "city": "austin",
}

# Run the Actor and wait for it to finish
run = client.actor("maydit/building-permit-address-lookup").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 '{
  "addresses": [
    "2603 Ware Rd",
    "4414 Banister Ln",
    "1306 Payne Ave"
  ],
  "city": "austin"
}' |
apify call maydit/building-permit-address-lookup --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,maydit/building-permit-address-lookup"
        }
    }
}
```

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/yhmUbg1s3WK9fSkRj/builds/NpfFdCTx0QH3viSlY/openapi.json
