# EPA Facility & Enforcement Data Scraper (US EPA ECHO Records) (`steadfetch/epa-echo-facility-monitor`) Actor

US EPA ECHO air, water (CWA/NPDES) and waste (RCRA) facilities plus enforcement cases from official EPA web services. Universe: all ECHO records, queried per state; coverage complete within it - 44x the air-only slice in DE (18,937 vs 427). Monitor emits only changes.

- **URL**: https://apify.com/steadfetch/epa-echo-facility-monitor.md
- **Developed by:** [Steadfetch](https://apify.com/steadfetch) (community)
- **Categories:** Business
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $5.00 / 1,000 facility delivereds

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

## EPA Facility & Enforcement Data Scraper (US EPA ECHO Records)

Extract US EPA ECHO records from the official ECHO web services: **air, water (CWA/NPDES) and hazardous waste (RCRA) facilities plus federal enforcement cases**, in one schema - address, coordinates, operating status, compliance status, high-priority-violation/SNC status, NAICS, penalties and enforcement case metadata. Built by [Steadfetch](https://apify.com/steadfetch) ("Web data that doesn't break").

### Universe and coverage

- **Universe:** every facility and enforcement case in US EPA's Enforcement and Compliance History Online (ECHO), the official public register of EPA and state environmental compliance.
- **Coverage:** complete within that universe, queried per state (ECHO rejects unfiltered nationwide queries). Five services in one Actor: air (`air_rest_services`), all media in one query (`echo_rest_services`), water (`cwa_rest_services`), waste (`rcra_rest_services`) and enforcement cases (`case_rest_services.get_cases`). The all-media search is the difference that matters: in Delaware, **18,937 facilities across all media vs 427 air-only (44x)** (measured 2026-11-01 with plain HTTP calls against the documented services).

### What it does

The Actor calls the official, public and key-less EPA ECHO REST services:

- `<service>_rest_services.get_facilities` (or `case_rest_services.get_cases`) runs the search and returns a QueryID.
- `<service>_rest_services.get_qid` returns the records for that QueryID (paged).

It does not scrape the ECHO website, does not need a login or API key, and does not touch CAPTCHAs. Only facility and enforcement-case records are read: no officer names, contacts or other personal data.

Search inputs are mapped to the documented ECHO parameters:

| Input | ECHO parameter | Notes |
| --- | --- | --- |
| `service` | service family | `air` (default), `all` (air + water + waste), `water`, `waste` or `enforcement`. |
| `state` | `p_st` (`p_state` for cases) | Two-letter US postal abbreviation, for example `NJ`. Required for `enforcement`: case records carry no state of their own. |
| `facilityName` | `p_fn` (`p_name` for cases) | Facility or case name filter. |
| `naics` | `p_ncs` (`p_naics` for cases) | NAICS prefix, 2 to 6 digits. `p_ncs` is the documented parameter (`p_naics` is not, for facility searches). |
| `sic` | `p_sic` | Four-digit SIC code. |
| `maxResults` | `responseset` + pages | Default 100, maximum 5000. |

At least one of `state`, `facilityName`, `naics` or `sic` is required: ECHO rejects unfiltered nationwide queries because the result set exceeds its query limit.

#### Snapshot and monitor modes

- `mode: "snapshot"` (default): emit every record returned by the query. Use it for one-off research, backfills and exports.
- `mode: "monitor"`: emit only new records, or records whose `compliance_status` or `hpv_status` changed since the previous run **of the same service**. The dedupe key is `registry_id + compliance_status + hpv_status` (the case number stands in for the registry ID on enforcement cases), and the state is kept in the Actor's key-value store under one key per service, so status spaces never mix. Unchanged records are not pushed, so they are not charged.

Each item is validated against the published output schema before it is pushed. With `strictSchema: true` (the default) an invalid item fails the run instead of delivering malformed data. Every item carries a `service` field (`air`, `all`, `water`, `waste`, `enforcement`) so mixed datasets stay self-describing.

### Example output

Facility item (air service; `all`, `water` and `waste` items share the same shape with `service` set accordingly):

```json
{
    "url": "https://echo.epa.gov/detailed-facility-report?fid=110014984464",
    "title": "JERFS MOBIL SERVICE CENTER",
    "scrapedAt": "2026-10-20T12:00:00.000Z",
    "service": "air",
    "registry_id": "110014984464",
    "facility_name": "JERFS MOBIL SERVICE CENTER",
    "source_id": "NJ000B0294",
    "street": "592 RAMAPO VALLEY RD",
    "city": "",
    "state": "NJ",
    "zip": "07436",
    "county": "Bergen",
    "latitude": 41.03846,
    "longitude": -74.23281,
    "status": "Permanently Closed",
    "compliance_status": "No Violation Identified",
    "hpv_status": "No High Priority Violation",
    "naics": "447190: Other Gasoline Stations",
    "last_violation_date": null,
    "source_url": "https://echodata.epa.gov/echo/air_rest_services.get_qid?output=JSON&qid=876"
}
```

Enforcement case item:

```json
{
    "url": "https://echodata.epa.gov/echo/case_rest_services.get_case_report?output=JSON&p_id=02-2012-0813",
    "title": "DuPont Chambers Works",
    "scrapedAt": "2026-11-01T09:00:00.000Z",
    "service": "enforcement",
    "registry_id": "02-2012-0813",
    "facility_name": "DuPont Chambers Works",
    "source_id": "EPCRA-02-2012-0813",
    "street": "",
    "city": "",
    "state": "DE",
    "zip": "",
    "county": "",
    "compliance_status": "Closed",
    "last_violation_date": null,
    "case_category": "Administrative - Formal",
    "enforcement_agency": "EPA",
    "federal_penalty": "$0.00",
    "source_url": "https://echodata.epa.gov/echo/case_rest_services.get_qid?output=JSON&qid=812"
}
```

Field notes:

- `url` is the stable public ECHO Detailed Facility Report page, keyed by the FRS registry ID (the ECHO case report service, keyed by case number, for enforcement cases).
- `service` says which ECHO search produced the item, so mixed datasets stay self-describing.
- `source_url` is the exact ECHO `get_qid` JSON URL used for the run.
- `city` is empty when ECHO returns the street in the city field (a real data quirk, normalized here instead of duplicating the address).
- `naics` uses the code plus description when ECHO provides it, for example `447190: Other Gasoline Stations`.
- `hpv_status` is the air high-priority-violation status, or the equivalent significant-non-compliance signal for water (`CWPSNCStatus`), waste (`RCRASNC`) and cross-program (`FacSNCFlg`) records.
- `last_violation_date` is an ISO date, or `null` when ECHO has no violation date; the water, waste, cross-program and case services expose no last-violation date, so it is `null` there.
- `last_inspection_date` (cross-program, water and waste) and the enforcement fields `case_category`, `enforcement_agency` and `federal_penalty` are additive optional fields.

### Input example

```json
{
    "state": "NJ",
    "maxResults": 10
}
```

All media in one query (air + water + waste):

```json
{
    "service": "all",
    "state": "DE",
    "maxResults": 100
}
```

A monitor run over enforcement cases (state required):

```json
{
    "service": "enforcement",
    "state": "NJ",
    "maxResults": 100,
    "mode": "monitor"
}
```

### Pricing

Pay-per-event with the two synthetic platform events:

| Event | Charged when | Price |
| --- | --- | --- |
| `apify-actor-start` | a run starts (charged automatically) | 0.00005 USD per run (Apify-recommended default; includes 5 seconds of free compute) |
| `apify-default-dataset-item` | a validated facility or case is saved to the dataset (charged automatically) | 0.005 USD per record = **5 USD per 1,000 results** |

There is no custom per-item event and no `Actor.charge()` call: adding one on top of the synthetic dataset event would bill users twice.

The price sits in the middle of the direct ECHO competitor band on the Apify Store (roughly 2.50 to 12.50 USD per 1,000 facilities) and inside the Steadfetch 1-10 USD band.

**Measured platform cost:** a typical run is 2 HTTP calls (one search call plus one `get_qid` page for up to 1,000 results) and a few hundred milliseconds of 256 MB compute, well under **0.15 USD per 1,000 records**. The gross margin is above 97%. Large runs above 1,000 results add one `get_qid` request per additional 1,000 results.

Only validated results are pushed. API errors, empty runs and unchanged records in monitor mode are not charged.

### Data source and normalisation

- Source: US EPA ECHO REST services, documented at https://echo.epa.gov/tools/web-services (air, cross-program, water and waste facility searches plus the enforcement case search).
- Licence/legal: US federal public data, public web services with no login and no CAPTCHA; facility and case records only.
- Service parameters used in this version: `p_st`, `p_fn`, `p_ncs`, `p_sic`, `responseset`, `qid`, `pageno` for facility searches; `p_state`, `p_name`, `p_naics`, `p_sic` for the case search; `qcolumns` on the cross-program and water `get_qid` calls, because their default columns miss fields this Actor requires (RegistryID, source ID, compliance and SNC status). Column IDs were checked against each service's `metadata` endpoint on 2026-11-01; the air and waste services need no custom columns.
- Dates are converted from ECHO `MM/DD/YYYY` to ISO `YYYY-MM-DD`; impossible dates become `null`.
- Duplicate `registry_id` values inside one response are collapsed before delivery.
- The air service keeps its original mapping untouched; the expansion adds services and additive optional fields without changing existing required fields.

### Output schema validation

Nothing at Steadfetch is published without automated output-schema tests:

- `npm test` runs lint, typecheck and Vitest offline. The schema is compiled with Ajv, valid and invalid fixture items behave as expected, and extraction plus monitor dedupe run against committed ECHO JSON fixtures (no credit spent).
- `npm run publish:check` runs the Steadfetch publish gate (schema compilation, PPE price band, English listing, CI presence).
- `npm run schemas:validate` runs `apify-cli validate-schema` on the input, dataset and output schemas.
- CI executes tests, build, entrypoint verification, the publish gate and schema validation on every push.

### Development

```bash
npm install
npm test
npm run publish:check
npm run schemas:validate
npm run build && npm run verify:build
```

Fixtures live in `test/fixtures/`; tests never touch the network or the Apify platform. `npm run smoke` runs the Actor locally with `apify run` (no `APIFY_TOKEN` needed for a local run).

### FAQ

**Do I need an EPA account or API key?**
No. The ECHO web services used here are public and key-less.

**Does it scrape the ECHO website?**
No. It calls the documented JSON web services (`get_facilities`, `get_cases` and `get_qid`). The `url` field points to the public facility report (or case report service) for human verification.

**What is the difference between the services?**
`air` searches air facilities only (the original service). `all` runs the cross-program search covering air, water and waste in one query. `water` and `waste` run the single-program CWA and RCRA searches. `enforcement` returns federal enforcement cases; it requires a state because case records carry no state field.

**What is the difference between snapshot and monitor mode?**
Snapshot emits every matching record. Monitor emits only new records or records whose compliance status or high-priority-violation status changed since the previous run of the same service, using `registry_id + compliance_status + hpv_status` as the dedupe key (the case number stands in for the registry ID on enforcement cases). State is kept per service, so running different services never cross-contaminates.

**Why is `city` empty for some facilities?**
ECHO sometimes returns the street address in the city field. The Actor detects that duplicate and emits an empty city instead of repeating the street.

**How many results can I request?**
Up to 5,000 per run. ECHO serves at most 1,000 records per page and the Actor pages automatically.

**Why can't I query the whole nation?**
ECHO rejects unfiltered nationwide queries (the result set exceeds its queryset limit); at least one filter is required, and state-level queries cover the full universe.

**Does it include personal data?**
No. It emits facility- and case-level fields only.

**When am I charged?**
Once per run start and once per validated record saved to the dataset. Errors, empty runs and unchanged records in monitor mode deliver no items and incur no per-result charge.

**Is the output schema stable?**
Yes. The 0.2 expansion added the `service` field and optional enforcement/inspection fields additively; existing required fields never changed, and air items keep their original shape (plus the additive `service` field).

**Can I request another source?**
Yes. Open an issue in the Steadfetch Actors repository describing the public source you need.

### Maintenance

This Actor is **tested daily**: the official ECHO endpoints are exercised with a minimal public input, the output is validated against the published schema, and silent drift (fewer results, empty required fields) turns into an issue. The ECHO services are versioned upstream; when EPA changes a field or a column ID the Actor is repaired before any new release.

### Project structure

```
.actor/actor.json          Actor definition (payPerEvent, dataset storage)
.actor/input_schema.json   INPUT schema (service + filters + mode)
.actor/dataset_schema.json Dataset item JSON Schema (fields) + Store table view
.actor/output_schema.json  Actor output schema (default dataset results)
.actor/pay_per_event.json  PPE pricing (two synthetic events)
src/main.ts                Entry point: fetch, validate, monitor dedupe, push
src/fetch.ts               ECHO HTTP client, service registry and pagination (mocked in tests)
src/extract.ts             Pure mapping, normalization and monitor state (offline tests)
src/validate-output.ts     Dataset item schema validator (Ajv)
test/                      Offline tests and ECHO JSON fixtures (one per service)
```

# Actor input Schema

## `service` (type: `string`):

Which ECHO search to run: air = air facilities (air_rest_services); all = every medium in one query, air + water + waste (echo_rest_services); water = Clean Water Act NPDES permits (cwa_rest_services); waste = hazardous waste RCRA facilities (rcra_rest_services); enforcement = EPA enforcement cases (case_rest_services, state required).

## `state` (type: `string`):

Two-letter US postal abbreviation (for example NJ). Maps to the ECHO p_st parameter (p_state for enforcement cases, where it is required).

## `facilityName` (type: `string`):

Facility name filter (case name for the enforcement service). Maps to the documented ECHO p_fn parameter (p_name for enforcement cases).

## `naics` (type: `string`):

NAICS code prefix, 2 to 6 digits (for example 447190). Maps to the documented ECHO p_ncs parameter (p_naics for enforcement cases).

## `sic` (type: `string`):

Four-digit SIC code (for example 4471). Maps to the documented ECHO p_sic parameter.

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

Maximum number of facilities or cases to save to the dataset.

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

snapshot: emit every matching record. monitor: emit only new records or records whose compliance_status or hpv_status changed since the previous run of the same service.

## `strictSchema` (type: `boolean`):

Fail the run when an item does not match the output schema. Keep enabled: Steadfetch never publishes Actors without output-schema validation.

## Actor input object example

```json
{
  "service": "all",
  "state": "NJ",
  "facilityName": "JERFS",
  "naics": "447190",
  "sic": "4471",
  "maxResults": 100,
  "mode": "monitor",
  "strictSchema": true
}
```

# Actor output Schema

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

Dataset items validated against the item schema in dataset_schema.json (fields).

# 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 = {
    "service": "air",
    "state": "NJ",
    "maxResults": 10,
    "mode": "snapshot",
    "strictSchema": true
};

// Run the Actor and wait for it to finish
const run = await client.actor("steadfetch/epa-echo-facility-monitor").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 = {
    "service": "air",
    "state": "NJ",
    "maxResults": 10,
    "mode": "snapshot",
    "strictSchema": True,
}

# Run the Actor and wait for it to finish
run = client.actor("steadfetch/epa-echo-facility-monitor").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 '{
  "service": "air",
  "state": "NJ",
  "maxResults": 10,
  "mode": "snapshot",
  "strictSchema": true
}' |
apify call steadfetch/epa-echo-facility-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,steadfetch/epa-echo-facility-monitor"
        }
    }
}
```

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/cVgMOW8z9TTshzW4g/builds/2wYqct1NK520NkNQH/openapi.json
