# Philadelphia Building Permits - Contractor & Property Data (`j0401/philly-permits`) Actor

Philadelphia building & trade permits (public open data, 584,800 permits, 2007-today): type, description, approved scope, issue and completion dates, owner of record, full contractor block, address, council district and geocode.

- **URL**: https://apify.com/j0401/philly-permits.md
- **Developed by:** [Wenhao Yang](https://apify.com/j0401) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.03 / 1,000 philadelphia 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.
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

## Philadelphia Building Permits - Contractor & Property Data

Every building and trade permit the City of Philadelphia has issued since 2007, **584,800** of them, current through this week.

Each permit names the contractor who pulled it and their full business address, the property owner of record and the OPA account, the approved scope of work, the issue and completion dates, and the exact property it covers - including the council district, census tract and coordinates.

### Low cost

**From $0.00005 per record, down to $0.00003 at Gold** Pay-per-event: you are charged per record delivered, and nothing for the query.

### What you get

| Field | Meaning |
|---|---|
| `permitNumber` / `permitType` | The permit, and its type |
| `permitDescription` / `approvedScopeOfWork` / `typeOfWork` | What the work is, in the filer's own words |
| `status` | `Completed` 491,228 / `Expired` 50,163 / `Issued` 37,875, plus the amendment workflow states |
| `issuedDate` / `completedDate` / `mostRecentInspection` | Issue, completion and the latest inspection |
| `contractorName` / `contractorAddress1` / `contractorCity` / `contractorState` / `contractorZip` | The contractor, with the **full business address** - name populated on 78% of permits |
| `opaOwner` / `opaAccountNumber` / `parcelId` | Property owner of record, OPA account and parcel |
| `address` / `unit` / `zip` / `censusTract` / `councilDistrict` | The property |
| `latitude` / `longitude` | Coordinates |
| `category` / `numberOfStories` | Classification. `category` is the residential/commercial split on every row; `numberOfStories` where the source carries it |

### Modes

- **`rows`** (default) - permits matching your filters.
- **`aggregate`** - one count row per group: by permit type, status, system of record, category, council district or issue year.

### Example inputs

**One permit in full** - `permitNumber` is a substring match, so the whole number returns the single permit.

```json
{ "permitNumber": "PP-2026-013242" }
```

**Every permit a contractor has pulled** - `contractor` matches on the name, which is present on 78% of permits.

```json
{ "contractor": "BEST", "maxResults": 25 }
```

**Work issued in one council district this month** - `councilDistrict` is a number (1-10) and `issuedFrom` is a date.

```json
{ "councilDistrict": 5, "issuedFrom": "2026-09-01", "maxResults": 25 }
```

**Volume by permit type** - one count per type; both the HANSEN codes and the ECLIPSE names land in the one column.

```json
{ "mode": "aggregate", "groupBy": "permitType" }
```

**What is being built in a council district** - `councilDistrict` is a number (1-10); `mode=aggregate` with `groupBy=permitType` breaks the district down by trade.

```json
{ "councilDistrict": 5, "mode": "aggregate", "groupBy": "permitType" }
```

**New zoning and building work this month** - `issuedFrom` opens the issue-date window.

```json
{ "issuedFrom": "2026-09-01" }
```

### One register, two systems of record

This is the single most confusable thing about this dataset, and it will cost you if you filter by type without knowing it.

Philadelphia's permit data is a union of two systems that are being migrated from one to the other, tagged in `system`:

```
HANSEN    364,847      ECLIPSE   219,953
```

The older system uses short codes and the newer one uses plain English names, and **both vocabularies land in the same `permitType` column**:

| HANSEN | ECLIPSE |
|---|---|
| `BP_NEWCNST` 8,947 | `Building` 12,559 |
| `PP_PLUMBNG` 93,691 | `Plumbing` 60,586 |
| `EP_ELECTRL` 65,334 | `Electrical` 41,404 |
| `ZP_ZON/USE` 21,555 | `Zoning` 24,286 |

So a filter for `permitType=Plumbing` returns 60,586 permits, not the 154,277 that plumbing work actually accounts for - you are reading ECLIPSE's share of it. Filter `system` alongside `permitType` when you need the complete picture for a trade, or leave both blank and filter on `description` instead.

### What is not here

Four columns exist in the source and are delivered, but are filled on so few rows that no reliable query can be built on them, and this listing makes no claim about them:

| Column | Populated |
|---|---|
| `useCategories` | 3.8% |
| `numberOfUnits` | 4.5% |
| `occupancyType` | 6.9% |
| `coIssuedDate` | 1.6% |
| `unitType` | 0.5% |

There is no occupancy classification, unit count or certificate-of-occupancy data worth selling on this source, so occupancy is not advertised as a feature. The columns are delivered where the source fills them.

### Example output

**One permit** - `permitNumber=PP-2026-013242` returns this record:

```json
{
  "platform": "philly-permits",
  "source": "philadelphia-permits",
  "mode": "rows",
  "groupKey": "",
  "groupCount": "",
  "groupBy": "",
  "permitNumber": "PP-2026-013242",
  "permitType": "Plumbing",
  "permitDescription": "Plumbing Permit",
  "category": "Residential",
  "typeOfWork": "Alterations",
  "approvedScopeOfWork": "Piping and instlling (6) water cloests, (6) lavs, (3) bathtubs, (2)kitchen sink, (1) storm drain, (2) washing machines, (1) hub drain (2) washing machines (2) water heaters",
  "status": "Issued",
  "system": "ECLIPSE",
  "issuedDate": "2026-09-22",
  "completedDate": "",
  "mostRecentInspection": "",
  "coIssuedDate": "",
  "coRequired": "",
  "applicantType": "Professional / Tradesperson",
  "contractorName": "Michael Jen",
  "contractorAddress1": "Michael Jen\r\n708 SOUTH 08TH STREET\r\n2ND FLOOR\r\nPHILADELPHIA, PA  19147\r\nUSA",
  "contractorAddress2": "",
  "contractorCity": "",
  "contractorState": "",
  "contractorZip": "",
  "address": "1006 N MARSHALL ST",
  "unit": "",
  "unitType": "",
  "zip": "19123-1309",
  "censusTract": "014100",
  "councilDistrict": "5",
  "opaAccountNumber": "141000099",
  "opaOwner": "SIU KING YUEN",
  "parcelId": "251483",
  "latitude": "2697780.80932022",
  "longitude": "242584.91036913998",
  "numberOfUnits": "",
  "numberOfStories": "",
  "useCategories": "",
  "occupancyType": "",
  "parentJobId": "907281302",
  "zoningPermitJobId": "",
  "denialDate": "",
  "denialDocumentLink": "",
  "coDocumentLink": "",
  "sourceUpdatedAt": "2026-09-23"
}
```

**`mode=aggregate`, `groupBy=permitType`** - one row per group:

```
PP_PLUMBNG     93,691
EP_ELECTRL     65,334
BP_ALTER       62,651
Plumbing       60,586
Electrical     41,404
BP_MECH        28,689
```

**Every permit a contractor has pulled** - `contractor=BEST`:

```
ZP-2026-009412  2026-09-22  Issued  Zoning     726 S 7TH ST
```

**New zoning and building work this month** - `issuedFrom=2026-09-01`:

```
ZP-2026-009412  2026-09-22  Issued  Zoning   726 S 7TH ST
ZP-2026-008546  2026-09-22  Issued  Zoning   301 W OLNEY AVE
ZP-2026-007728  2026-09-22  Issued  Zoning   3815 BROOKVIEW RD
```

### Source

> **Counts below are a live snapshot** - the service is republished in place, so exact figures move between reads. The order of magnitude and the ratios are stable.

City of Philadelphia, Department of Licenses & Inspections, published on the City's ArcGIS open-data service as `PERMITS` - public open data, no login and no key. Permits range from 2007-01-01 to the current week.

**The row count is not stable.** During a single day's work on this listing the service was caught mid-republish: it returned 935,503 records, then 584,800 after the item was modified, with **every column shrinking by the same 0.625 factor**. That signature - uniform across all fields rather than concentrated at the old end - says the whole register was re-cut, not that old permits were dropped. The figures here are the post-republish ones. If the City restores the window these numbers move again.

### Output

Every record carries the same key set regardless of mode - the permit fields plus the aggregate columns (`groupKey`, `groupCount`, `groupBy`), which are `""` outside aggregate mode. Every record carries `sourceUpdatedAt`, the date the City last edited the layer.

### Related actors

- **Philadelphia L\&I Violations** - the code violations, inspections and complaints raised against these same properties.
- **Philly311 Service Requests** - what residents reported about them.

# Actor input Schema

## `status` (type: `string`):

Exact status. 'Completed' 491,228 / 'Expired' 50,163 / 'Issued' 37,875, plus the amendment workflow states. Blank = any.

## `permitType` (type: `string`):

Exact permit type. Two vocabularies coexist: HANSEN codes (BP\_NEWCNST, PP\_PLUMBNG, EP\_ELECTRL) and ECLIPSE names (Building, Plumbing, Electrical). Filter 'system' too, or you are reading only one system's share. Blank = any.

## `system` (type: `string`):

Which source system the row came from: 'HANSEN' 364,847 or 'ECLIPSE' 219,953. The two use different permit-type vocabularies. Blank = both.

## `category` (type: `string`):

Commercial / residential split. Blank on 63% of rows and left blank rather than guessed. Blank = any.

## `contractor` (type: `string`):

Substring match on the contractor name (present on 78% of permits), e.g. 'BEST'. Blank = any.

## `owner` (type: `string`):

Substring match on the assessed owner of record. Blank = any.

## `address` (type: `string`):

Substring match on the site address, e.g. 'MARKET ST'. Blank = any.

## `permitNumber` (type: `string`):

Substring match on the permit number. Blank = any.

## `description` (type: `string`):

Substring match on the permit description. Blank = any.

## `councilDistrict` (type: `integer`):

City council district number (1-10). Blank = any.

## `issuedFrom` (type: `string`):

Earliest permit issue date, YYYY-MM-DD. Blank = any.

## `issuedTo` (type: `string`):

Latest permit issue date, YYYY-MM-DD. Blank = any.

## `mode` (type: `string`):

rows = permits matching your filters (default). aggregate = one count row per group (see groupBy).

## `groupBy` (type: `string`):

Which dimension to aggregate over (mode=aggregate). Blank = permitType. Every group is returned - aggregate mode is not cut off by maxResults.

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

Cap the number of records pushed in rows mode (0 = default 50; up to 2,000 per run). Each record is metered individually, so there is no per-run charge cap. Aggregate mode returns every group.

## Actor input object example

```json
{
  "status": "",
  "permitType": "",
  "system": "",
  "category": "",
  "contractor": "",
  "owner": "",
  "address": "",
  "permitNumber": "",
  "description": "",
  "issuedFrom": "",
  "issuedTo": "",
  "mode": "rows",
  "groupBy": "",
  "maxResults": 50
}
```

# Actor output Schema

## `recordsUrl` (type: `string`):

Philadelphia building permits - as JSON

## `datasetUrl` (type: `string`):

No description

## `runUrl` (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 = {};

// Run the Actor and wait for it to finish
const run = await client.actor("j0401/philly-permits").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 = {}

# Run the Actor and wait for it to finish
run = client.actor("j0401/philly-permits").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 '{}' |
apify call j0401/philly-permits --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,j0401/philly-permits"
        }
    }
}
```

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/ok8MyF2uSKzNKswB6/builds/9y9uGs24pwIwjoZnH/openapi.json
