# EPA ECHO Facility Delta Monitor (`titan_coder/epa-echo-facility-delta`) Actor

Watches specific EPA-regulated facilities (by FRS Registry ID or name+state) via the official EPA ECHO API and reports only genuinely new enforcement cases, notices, violations, and inspections since your last check. A check with nothing new is free.

- **URL**: https://apify.com/titan\_coder/epa-echo-facility-delta.md
- **Developed by:** [Radu Furtuna](https://apify.com/titan_coder) (community)
- **Categories:** Business, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$6.00 / 1,000 new enforcement events

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?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## EPA ECHO Facility Delta Monitor

Durable monitor for new enforcement cases, notices, violations, and inspections at specific EPA-regulated
facilities, via the official, free EPA ECHO REST API (`echodata.epa.gov`). No API key needed.

### Why

Compliance teams, EHS managers, investors doing diligence, and journalists want to know the moment a
tracked facility gets a new enforcement action, inspection, or violation notice — not have to re-read its
entire multi-decade EPA history every time to spot what changed. Existing ECHO scrapers on Apify mostly
do a one-off pull of the current facility snapshot; this one durably tracks what's already been seen and
charges only for genuinely new events.

### How it works

1. Each `watch` is one facility, identified either by its EPA FRS **Registry ID** (most reliable — find it
   on the facility's ECHO detailed report page or via `echo.epa.gov/facilities/facility-search`) or by
   **facility name + US state** (resolved to a Registry ID automatically on first use, then cached).
2. Every run fetches that facility's full Detailed Facility Report (`dfr_rest_services.get_dfr`) — EPA
   ECHO returns the complete history in one response, not a paginated window — and diffs its enforcement
   cases, notices, violations (with nested enforcement actions), and site-visit inspections against a
   durable checkpoint of event ids already seen for that watch.
3. Genuinely new events are pushed to the dataset and billed once each (`new-enforcement-event`); checking
   a facility with nothing new costs nothing beyond the fixed platform run cost.

### Input

```json
{
  "monitorId": "my-facilities",
  "watches": [
    { "watchId": "3m-brownwood", "registryId": "110000599273" },
    { "watchId": "some-refinery", "facilityName": "Example Refinery", "state": "TX" }
  ],
  "notifyOn": "new_alerts",
  "webhookUrl": "https://example.com/webhook"
}
```

Add more watches later under the same `monitorId` — each watch keeps its own independent history.

### Billing

Pay-per-event: `new-enforcement-event` — charged only for an event (enforcement case, notice, violation,
enforcement action, or inspection) genuinely new since the previous check of that watch. The first check
of a new watch establishes a baseline (no charge). Failed/blocked/unresolved checks are never charged.

#### Delivery guarantee: at-most-once (not exactly-once)

The right to write a row and to charge for it is granted by a single atomic primitive — one
`addRequest(uniqueKey)` into a dedicated, named claim-journal Request Queue
(`<prefix>-<monitorId>-claims`). Exactly one run ever wins that key. Claim requests are never deleted
and never handled: the queue is a permanent journal of irreversible attempts, not a work list.

- **You will never be charged twice for the same event.** That is the guarantee.
- **It is not exactly-once.** If a run wins the claim and then dies before the row reaches the dataset
  (or before the charge completes), that event is *lost*: it closes as `dataset_unknown` /
  `charge_unknown` and is never re-delivered. We deliberately prefer losing a delivery over
  double-charging you.
- **Boundary of the guarantee:** it holds for as long as the named claim-journal queue exists. Anyone
  with account access can delete or re-create that queue through the Apify Console/API; a fresh journal
  starts empty, and previously delivered events could then be delivered and billed again. That is an
  inherent limit of any durable storage, not a defect of the protocol.
- **Migration boundary:** the guarantee applies from the build that introduced the claim gate onward.
  Older builds of this actor must not keep running against the same `monitorId`. That same build also
  had to shorten the durable storage name prefix (the old one, the full actor name, could not fit
  Apify's 63-character storage-name limit together with a 40-character `monitorId`), so a monitor that
  ran on an older build starts from a fresh baseline once. A baseline is never charged.
- `coverage.claimJournalSize` reports the journal's size each run (best-effort; `null` if the queue's
  metadata could not be read, and the value lags a few seconds because Apify's `totalRequestCount` is
  eventually consistent). Use it to watch growth, not to make decisions.

### Honest limits

- **Event coverage is intentionally scoped** to four DFR sections that map cleanly onto "enforcement /
  inspection / violation / penalty": `CaseFormalActions` (formal enforcement cases with settlements/
  penalties), `Notices` (informal and formal enforcement notices), `ViolationsEnforcementActions`
  (violations plus their nested enforcement actions — mostly Safe Drinking Water Act), and `SiteVisits`
  (inspections). We deliberately do NOT surface `TRIHistory`, `RCRAWasteHistory`, `DmrPollLoads`, air
  quality readings, or demographic sections — those are not enforcement/inspection/violation events.
- `facilityName + state` resolution is honest, not fuzzy: 0 matches → `facility_not_found`, 2+ matches →
  `facility_name_ambiguous:N`, in both cases the watch is skipped for that run (no charge, no crash) and
  reported plainly in `coverage.unresolvedFacilities`. Use `registryId` directly to avoid ambiguity.
- `seenIds` per watch is capped (FIFO by discovery order, 4000 entries) — only matters for a facility with
  an extraordinarily long enforcement history.
- We don't invent data: if the ECHO API response shape changes, or a `registryId` is invalid, the run
  reports it honestly (`permanent_error: ...` / `transient_error: ...`) instead of silently returning zero
  results.
- A facility record grouped under `MultipleFRSFacilities` in the DFR response (EPA's own facility-linking)
  is fetched and diffed as returned by the API; we do not additionally resolve or merge sibling records.

### Deviations from the original brief (see also ROADMAP.md)

The task named `get_facility_info` / `get_enforcement_summary` as example ECHO endpoints. Live testing
against `echodata.epa.gov` on 12.09.2026 showed:

- `get_facility_info` (`echo_rest_services.get_facility_info`) is a self-contained facility-summary
  endpoint — it does not return itemized enforcement case / inspection / violation history.
- The correct official endpoint for a facility's full itemized history in one response is
  `dfr_rest_services.get_dfr` (the same "Detailed Facility Report" the ECHO website itself renders) — used
  here instead, as the endpoint that actually fits the delta-monitoring task, from the same official
  ECHO REST family and requiring no API key.
- Facility search/resolution uses `echo_rest_services.get_facilities` (registers a query, returns a
  `QueryID`) followed by `echo_rest_services.get_qid` (returns the actual facility rows) — this two-step
  shape is how the ECHO "all data" search service works; there is no single-call facility-by-name lookup.

No EPA ECHO endpoint used here required an API key or authentication at any point during development.

Author: OmniCoder (https://t.me/OmniCoder)

# Actor input Schema

## `monitorId` (type: `string`):

Name of this monitor's durable history (a-z, 0-9, dash; up to 40 chars).

## `watches` (type: `array`):

1-25 objects. Either {"watchId": "...", "registryId": "..."} (EPA FRS Registry ID — most reliable, find it on the facility's ECHO detailed report page) or {"watchId": "...", "facilityName": "...", "state": "XX"} (resolved to a Registry ID automatically; fails honestly if 0 or 2+ matches are found — use registryId instead in that case). New facilities can be added later under the same monitorId.

## `notifyOn` (type: `string`):

new\_alerts — post the webhook only when new paid events were delivered; always — post it every run; never — do not call webhookUrl at all.

## `webhookUrl` (type: `string`):

Optional. Receives a digest of delivered (paid) new events as JSON. HTTPS only.

## Actor input object example

```json
{
  "monitorId": "my-facilities",
  "watches": [
    {
      "watchId": "3m-brownwood",
      "registryId": "110000599273"
    }
  ],
  "notifyOn": "new_alerts"
}
```

# Actor output Schema

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

Every row this run produced. Key fields: watchId, kind (enforcement\_case/notice/violation/enforcement\_action/inspection), title, date, amount, agency.

## `coverage` (type: `string`):

What this run actually covered and what it charged for: per-target status and reason, events delivered and events billed, unresolved facilities (not found / ambiguous name). Enough to reconcile every charge against every row.

## `digest` (type: `string`):

A short human-readable summary of what this run found, written every run.

# 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 = {
    "monitorId": "my-facilities",
    "watches": [
        {
            "watchId": "3m-brownwood",
            "registryId": "110000599273"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("titan_coder/epa-echo-facility-delta").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 = {
    "monitorId": "my-facilities",
    "watches": [{
            "watchId": "3m-brownwood",
            "registryId": "110000599273",
        }],
}

# Run the Actor and wait for it to finish
run = client.actor("titan_coder/epa-echo-facility-delta").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 '{
  "monitorId": "my-facilities",
  "watches": [
    {
      "watchId": "3m-brownwood",
      "registryId": "110000599273"
    }
  ]
}' |
apify call titan_coder/epa-echo-facility-delta --silent --output-dataset

```

## MCP server setup

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

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/872CPOzgIcGEZzesi/builds/3TGqtBC4yVXeyqY7J/openapi.json
