# Philadelphia L\&I - Code Violations, Inspections & Complaints (`j0401/philly-li-violations`) Actor

Philadelphia L\&I enforcement records (public open data, 5.2M rows, three linked tables): violation notices, inspector visit outcomes and resident complaints, with violation code and title, case status, dates, address, owner, parcel and geocode.

- **URL**: https://apify.com/j0401/philly-li-violations.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 l\&i code case 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 L\&I - Code Violations, Inspections & Complaints

The enforcement record of the Philadelphia Department of Licenses & Inspections: **5,200,849 rows across three linked tables** from the City's current L\&I system, spanning 2007 to today.

L\&I is the City's building-code and property-maintenance enforcement arm. A case moves through three stages, and this actor carries all three rather than making you buy them separately or guess which one answers your question:

| Record set | Rows | What it is |
|---|---|---|
| `violations` | 2,019,604 | The notices themselves - what was cited, under which code, and what happened |
| `investigations` | 2,117,950 | Inspector visits and their outcomes - passed, failed, closed |
| `complaints` | 1,063,295 | What a resident reported, and what came of it |

All three are keyed on the case number, so a violation and its inspections share an identifier.

### 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 |
|---|---|
| `caseNumber` | The L\&I case. Links a violation to its inspections |
| `violationCode` / `violationCodeTitle` | What was cited - the code and its plain-English title (99.9% populated) |
| `violationStatus` / `caseStatus` | Where it stands. Violations: `COMPLIED` 1,571,553 / `CLOSEDCASE` 124,309 / `CLOSED` 102,698 / `OPEN` 93,150 plus 10 more |
| `caseType` | `NOTICE OF VIOLATION` 621,558 / `SITE VIOLATION NOTICE` 21,279 / `WARNING` 1,492 and 3 more |
| `violationDate` / `caseCreatedDate` / `violationResolutionDate` / `mostRecentInvestigation` | The dates, from 2007-01-01 forward |
| `investigationType` / `investigationStatus` / `investigationCompleted` | The inspector's visit - `HCEU INSP` 677,596 / `PRECOURT` 142,381 / `CSUINITIAL` 131,904 and 66 more types; outcomes `FAILED` 1,273,492 / `PASSED` 565,376 / `CLOSED` 261,849 |
| `complaintCode` / `complaintCodeName` / `complaintDate` / `complaintStatus` | The complaint - `MAINTENANCE RESIDENTIAL` 169,545 / `COMMUNITY LIFE IMPROVEMENT PROGRAM` 84,181 / `PROPERTY MAINTENANCE COMPLAINT EXTERIOR` 79,769 and 134 more |
| `ticket311` | The linked Philly311 ticket, where one exists |
| `underAppeal` / `publicNotice` / `caseResponsibility` / `unitResponsible` | Case handling |
| `address` / `zip` / `censusTract` / `councilDistrict` | The property |
| `opaOwner` / `opaAccountNumber` / `parcelId` | Owner of record, OPA account and parcel |
| `latitude` / `longitude` / `jobId` | Coordinates and the L\&I system's job id |

### Modes

- **`rows`** (default) - records of the chosen `corpus` matching your filters.
- **`aggregate`** - one count row per group, with the dimensions that record set actually has.

### Example inputs

**Every inspection under one case** - `caseNumber` is the number that links a violation to the inspections it produced.

```json
{ "corpus": "investigations", "caseNumber": "CF-2026-073037" }
```

**Notices where the case is still in violation** - `caseStatus` is available on the violations and complaints record sets.

```json
{ "corpus": "violations", "caseStatus": "IN VIOLATION", "maxResults": 25 }
```

**Complaints filed in one ZIP** - `corpus` picks the record set; each one carries its own filters.

```json
{ "corpus": "complaints", "zip": "19139", "maxResults": 25 }
```

**What neighbours complain about** - one count per complaint code on the complaints record set.

```json
{ "corpus": "complaints", "mode": "aggregate", "groupBy": "complaintCode" }
```

**Fail the inspection, then check the notice** - `status` filters the investigations record set; the case number then resolves against `corpus=violations`.

```json
{ "corpus": "investigations", "status": "FAILED" }
```

**How cases end in one district** - `councilDistrict` is available on the violations record set; district 5 holds 96,107 violation rows.

```json
{ "corpus": "violations", "councilDistrict": 5, "mode": "aggregate", "groupBy": "caseStatus" }
```

### Filters and groupings are per record set

The three tables have genuinely different columns, and this actor does not paper over that. A filter that does not exist on the record set you chose is **rejected with an error** rather than quietly ignored - so you never receive unfiltered rows believing you had filtered them.

The clearest case: `councilDistrict` is populated on the violations (640,943 rows) and complaints (415,435) tables, and the investigations table has the column but **every single one of its 2,117,950 rows is empty**. Filtering on it there would return zero rows and read as "no cases in that district", which is the wrong conclusion. It is refused on that record set instead.

### Complaints do not always become cases

`caseNumber` is populated on only **27.3%** of complaints, and `complaintStatus` on 39.2%. Most complaints are resolved without ever opening an L\&I case, so the case number is a *partial* link across the three tables, not a guarantee. Joining the complaints table to the violations table on case number is correct where a case number exists and meaningless where it does not.

### The old L\&I tables are still online - do not use them

Two similarly-named services from L\&I's previous system, `LI_VIOLATIONS` (1,451,562 rows) and `LI_PERMITS` (1,051,902 rows), remain published. Both are **frozen in March 2020**: `LI_VIOLATIONS` holds nothing after 2020-03-12, and 2020 carries only 18,090 rows against 108,346 in 2017, 117,090 in 2018 and 121,805 in 2019. They are retired legacy that the City has not taken down.

This actor reads the live tables. If you have seen those older services and wondered why they stopped, that is why.

### Example output

**One complaint** - `corpus=complaints`, `zip=19139` returns records like this one:

```json
{
  "platform": "philly-li-violations",
  "source": "philadelphia-licenses-inspections",
  "mode": "rows",
  "groupKey": "",
  "groupCount": "",
  "groupBy": "",
  "corpus": "complaints",
  "address": "38 S 54TH ST",
  "addressObjectId": "15486788",
  "caseCompletedDate": "",
  "caseCreatedDate": "",
  "caseNumber": "",
  "casePriority": "",
  "caseResponsibility": "",
  "caseStatus": "",
  "caseType": "",
  "censusTract": "008500",
  "complaintCode": "WNP",
  "complaintCodeName": "WORK UNDERWAY WITHOUT PERMITS",
  "complaintDate": "2026-09-22",
  "complaintNumber": "CM-2026-129370",
  "complaintResolutionDate": "",
  "complaintStatus": "IN REVIEW",
  "councilDistrict": "3",
  "initialInvestigationDate": "",
  "investigationCompleted": "",
  "investigationProcessId": "",
  "investigationStatus": "",
  "investigationType": "",
  "jobId": "",
  "latitude": "2675079.99501881",
  "longitude": "238159.5386170289",
  "mostRecentInvestigation": "",
  "opaAccountNumber": "603147400",
  "opaOwner": "SAAT CAPITAL INVESTMENTS LLC",
  "parcelId": "200870",
  "publicNotice": "",
  "systemOfRecord": "ECLIPSE",
  "ticket311": "",
  "underAppeal": "",
  "unit": "",
  "unitResponsible": "CONSTRUCTION COMPLAINT INVESTIGATOR",
  "unitType": "",
  "violationCode": "",
  "violationCodeTitle": "",
  "violationDate": "",
  "violationNumber": "",
  "violationResolutionCode": "",
  "violationResolutionDate": "",
  "violationStatus": "",
  "zip": "19139-3311",
  "sourceUpdatedAt": "2026-09-23"
}
```

**`corpus=complaints`, `mode=aggregate`, `groupBy=complaintCode`** - one row per group:

```
MR     169,545
VL     100,484
CP      84,181
PME     79,769
BC      59,622
```

### Source

> **Counts below are a live snapshot** - the layers are edited continuously, 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 `VIOLATIONS`, `CASE_INVESTIGATIONS` and `COMPLAINTS` - public open data, no login and no key.

### Output

Every record carries the same key set regardless of mode or record set - the fields above plus `corpus`, and the aggregate columns (`groupKey`, `groupCount`, `groupBy`), which are `""` outside aggregate mode. Fields that belong to another record set are `""` rather than absent, so the schema is stable across all three. Every record carries `sourceUpdatedAt`, the date the City last edited that layer.

### Related actors

- **Philadelphia Building Permits** - the permits these violations are raised against.
- **Philly311 Service Requests** - the resident reports that feed the complaints table.

# Actor input Schema

## `corpus` (type: `string`):

Which of the three linked L\&I tables to read. violations = the notices themselves (2,019,604). investigations = inspector visits and outcomes (2,117,950). complaints = what a resident reported (1,063,295). Filters and groupBy dimensions differ per record set; an unsupported combination is rejected rather than silently ignored.

## `caseNumber` (type: `string`):

Substring match on the L\&I case number, e.g. 'CF-2026-073037'. The same number links a violation to its inspections. Blank = any.

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

Exact status of the chosen record set, matched against that set's own vocabulary (violations: COMPLIED / CLOSED / OPEN / SVN ISSUED / ...; investigations: PASSED / FAILED / CLOSED / ...; complaints: CLOSED / UNDER INVESTIGATION / IN REVIEW / ...). A value that does not belong to the chosen set is rejected. Blank = any.

## `caseStatus` (type: `string`):

Case-level status, available on the violations and complaints record sets. Blank = any.

## `caseType` (type: `string`):

Type of notice, on the violations record set only. Blank = any.

## `violationNumber` (type: `string`):

Substring match on the violation number (violations record set). Blank = any.

## `violationCode` (type: `string`):

Substring match on the violation code (violations record set). Blank = any.

## `detail` (type: `string`):

Substring match on the human-readable violation code title (violations record set). Blank = any.

## `investigationType` (type: `string`):

Substring match on the inspection type, e.g. 'HCEU', 'PRECOURT' (investigations record set). Blank = any.

## `complaintNumber` (type: `string`):

Substring match on the complaint number (complaints record set). Blank = any.

## `complaintCode` (type: `string`):

Substring match on the complaint code (complaints record set). Blank = any.

## `complaint` (type: `string`):

Substring match on the complaint code's name, e.g. 'VACANT', 'MAINTENANCE' (complaints record set). Blank = any.

## `ticket311` (type: `string`):

Substring match on the linked Philly311 ticket number. Present on 27% of complaints. Blank = any.

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

Substring match on the property address. Blank = any.

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

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

## `zip` (type: `string`):

ZIP or ZIP prefix. Blank = any.

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

City council district (1-10). Available on the violations and complaints record sets; the investigations layer has the column but every row is empty, so it is rejected there rather than returning zero rows. Blank = any.

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

Earliest date for the chosen record set: violation date for violations, completed date for investigations, complaint date for complaints. YYYY-MM-DD. Blank = any.

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

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

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

rows = records of the chosen record set matching your filters (default). aggregate = one count row per group (see groupBy).

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

Which dimension to aggregate over (mode=aggregate). Blank = status. Dimensions are per record set: violations has status / caseStatus / caseType / councilDistrict / violationYear; investigations has status / investigationType / completedYear; complaints has status / complaintCode / councilDistrict / complaintYear. An unsupported pair is rejected. Every group is returned.

## `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
{
  "corpus": "violations",
  "caseNumber": "",
  "status": "",
  "caseStatus": "",
  "caseType": "",
  "violationNumber": "",
  "violationCode": "",
  "detail": "",
  "investigationType": "",
  "complaintNumber": "",
  "complaintCode": "",
  "complaint": "",
  "ticket311": "",
  "address": "",
  "owner": "",
  "zip": "",
  "dateFrom": "",
  "dateTo": "",
  "mode": "rows",
  "groupBy": "",
  "maxResults": 50
}
```

# Actor output Schema

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

Philadelphia L\&I violations, inspections & complaints - 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-li-violations").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-li-violations").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-li-violations --silent --output-dataset

```

## MCP server setup

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

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/MbUBrYOOOuYv9R2f2/builds/wgfjNrrKhPYYTdIdq/openapi.json
