# EPA Superfund NPL Status Monitor (`titan_coder/epa-superfund-npl-status-monitor`) Actor

Tracks official EPA Superfund/NPL site status by state/county and alerts on real changes: new EPA Superfund site records, or NPL status shifts (Proposed, Final NPL, Deleted, NFRAP). For CRE investors, developers, banks, title companies and ESG due-diligence teams. Free when nothing changes.

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

## Pricing

$10.00 / 1,000 npl status changeds

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 Superfund NPL Status Monitor

Durable, informational monitor of the official U.S. EPA **Superfund/NPL site registry** — the free,
public REST feed EPA Envirofacts publishes at
`https://data.epa.gov/dmapservice/sems.envirofacts_site/fk_ref_state_code/equals/<STATE>`. No API key,
no account, no proxy, no browser.

Most Superfund/NPL tools are one-shot lookups by address. This actor is different: it is a **recurring
watch** — "tell me if the status of any of the Superfund sites I'm tracking changes" — not a one-time
screen. Built for CRE investors, developers, banks/title companies and ESG/environmental
due-diligence teams who need to know when a site near their portfolio moves from *Proposed for NPL* to
*Currently on the Final NPL*, gets *Deleted from the Final NPL*, or otherwise changes status.

**This is a mirror-with-diff of a public government registry, not an environmental-risk opinion.** It
is not legal advice, not an estimate of contamination or property value impact, and not a substitute
for a Phase I Environmental Site Assessment (ASTM E1527) before a real-estate transaction. It tells
you, reliably and cheaply, when EPA's own site records for the states/counties you name gain a new
entry or change NPL/non-NPL status.

### Why

EPA already publishes every Superfund/NPL site record for free. What it doesn't give you is a durable,
per-organization "what changed since I last looked" — you either poll the whole state list yourself and
diff it client-side, or you don't watch at all. This actor keeps that diff for you: a list of watches
(state + optional county), a durable memory of every matching site's status between runs, and a bill
only for sites that are genuinely new to the watch or have a genuinely changed status.

### How it works

1. Each `watch` fetches every `sems.envirofacts_site` record for its `state` (required, one two-letter
   USPS state/territory code) via the source's own path-segment filter — one GET per watch per run, no
   pagination (the source has none; the largest state observed, Pennsylvania, returns ~3,500 records /
   \~2.9 MB in one response). An optional `county` further narrows the result **on the client side**
   (case-insensitive substring match against the source's `county_name` field) — the source's
   `sems.envirofacts_site` table does **not** support a server-side county filter (confirmed live:
   `.../fk_ref_county_name/equals/...` returns HTTP 500 "column does not exist").
2. The **first** run for a watch establishes a baseline: every matching site's `site_id` and current
   status fingerprint (NPL status code, non-NPL status code, non-NPL status date, archived flag,
   archived date) are stored, nothing is billed or delivered.
3. Every later run compares the fresh set against the stored index:
   - a `site_id` never seen before under this watch is **new**;
   - a `site_id` seen before whose status fingerprint differs from what was stored is **status\_changed**
     (the row carries both the new and the previous NPL/non-NPL status for context);
   - a site whose name/address/coordinates changed but whose status fingerprint did not is **not**
     treated as a billable delta (administrative record corrections, not a status change).
4. A hard per-watch cap (`maxResultsPerWatch`) protects you from a single run billing an unbounded
   backlog — anything over the cap is picked up cleanly on the next run, nothing is lost or
   double-billed.

### Input

```json
{
  "monitorId": "my-superfund-watch",
  "watches": [
    { "watchId": "connecticut-sites", "state": "CT", "county": "Hartford" }
  ],
  "maxResultsPerWatch": 200,
  "notifyOn": "new_alerts",
  "webhookUrl": "https://example.com/webhook"
}
```

- `watches` — 1-25 objects, each `{watchId, state, county?}`. `state` is one two-letter USPS
  state/territory code (e.g. `CA`, `TX`, `PR`, `DC`, `VI`, `GU`, `AS`, `MP`). `county` is optional
  (case-insensitive substring match against `county_name`; omit for every county in the state). A
  `watchId` is bound to its `state`/`county` on first use — reusing the same `watchId` with a different
  `state`/`county` later fails the run honestly (`watch_config_mismatch`) instead of silently hiding
  records under a stale index.
- `maxResultsPerWatch` — 1-2000, default 200.

### Output

One row per new/changed site: `watchId`, `status` (`new`/`status_changed`), `siteId`, `epaId`, `name`,
`state`, `county`, `nplStatusCode`/`nplStatusName`, `nonNplStatusCode`/`nonNplStatusName`, and (for
`status_changed`) `previousNplStatusCode`/`previousNplStatusName`,
`previousNonNplStatusCode`/`previousNonNplStatusName`. A run that finds nothing new still writes an
honest `run_summary` row to the default dataset (never silently empty).

### Billing

Pay-per-event, one named event: `npl-status-changed` — covers both a site newly appearing under a
watch and an already-known site's NPL/non-NPL status changing. Deduplicated by `site_id` + a hash of
its current status fields (the same site can be billed again later if its status genuinely changes
again, but never twice for the same status content). The baseline run establishes history without
charging. Failed/blocked runs (source fetch failed, run timed out) 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.

What this buys you, stated honestly:

- **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` — they predate the
  journal and would not see the claims it holds.
- `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 by a few seconds because Apify's
  `totalRequestCount` is eventually consistent). Use it to watch growth, not to make decisions.

### Honest limits

- **The durable dataset is a delivery-attempt log, not a guaranteed mirror of the default dataset.**
  Each row is written to the durable dataset first, then mirrored to the run's default dataset before
  billing proceeds for that row. If the durable write succeeds but the default-dataset mirror write
  fails (e.g. transient Apify storage error), the item is marked `dataset_unknown`, billing for it is
  permanently blocked (fail-closed — we never charge for a row we can't confirm was delivered), and the
  run is not retried into re-creating that exact row. The durable dataset can therefore end up with a
  small number of orphan rows that were never mirrored and never billed. The **default dataset is the canonical log of rows successfully written to this
  run's output** (see its `run_summary` row) — but a default-dataset row does not by itself prove the
  row was billed: the row is written before `Actor.charge()` runs, so if charging then fails or comes
  back `charge_unknown`, the row is present but not confirmably paid. **`run_summary.eventsBilled` and
  Apify's own billing ledger are the source of truth for confirmed payment**, not the presence of a row
  in either dataset.
- We mirror EPA's registry as published; we do not offer an opinion on contamination severity,
  cleanup cost, or property value impact, and we are not a substitute for a Phase I ESA — this is an
  informational monitor of official NPL status records, nothing more.
- **The source gives no way to verify a response isn't truncated.** Unlike some other government feeds
  in this product line (FEMA, CISA KEV), `sems.envirofacts_site` returns a bare JSON array with no
  declared total count and no `Content-Length` header (chunked transfer). We do not invent an integrity
  check the source can't support. The one real failure mode observed live (13.09.2026): the server
  occasionally closes the response stream before the JSON is complete, which surfaces as a JSON parse
  error on an otherwise-200 response — we treat that as transient and retry once. See ROADMAP.md for
  the full write-up.
- A site's name/address/coordinates can change without its NPL status changing (administrative
  record corrections). Only a change in the status fingerprint (NPL code, non-NPL code, non-NPL status
  date, archived flag/date) is billed; other field changes update our internal cache silently.
- The source has no server-side county filter for this table — `county` is a client-side, best-effort
  substring match, not an authoritative EPA filter.
- If the feed is temporarily unavailable or its shape changes, the affected watch reports that
  honestly instead of silently returning zero results.

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: {"watchId": "connecticut-sites", "state": "CT", "county": "Hartford"}. state is required (one two-letter USPS state/territory code, e.g. CA, TX, PR, DC, VI, GU, AS, MP) — one watch = one state, one request. county is optional (a plain county/parish name, matched as a case-insensitive substring against EPA's county\_name field on the client side — the source API has no server-side county filter for this table; omit for every county in the state). New watches can be added later under the same monitorId.

## `maxResultsPerWatch` (type: `integer`):

Caps how many new/changed sites are delivered per watch in a single run (the rest are picked up on the next run). Protects against runaway bills on a watch's very first baseline-adjacent run.

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

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

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

Optional. Receives a digest of delivered (billed) site changes as JSON. HTTPS only.

## Actor input object example

```json
{
  "monitorId": "example-monitor",
  "watches": [
    {
      "watchId": "connecticut-sites",
      "state": "CT",
      "county": ""
    }
  ],
  "maxResultsPerWatch": 200,
  "notifyOn": "new_alerts"
}
```

# Actor output Schema

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

Every row this run produced. Key fields: watchId, status (new|status\_changed), siteId, epaId, name, state, county, nplStatusCode/Name, nonNplStatusCode/Name, previousNplStatusCode/Name (for status\_changed). If nothing new was found, a single run\_summary row explains why the dataset is otherwise empty. Informational only — not legal, environmental-risk or Phase I ESA due-diligence advice.

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

What this run actually covered and what it charged for: per-watch status/reason, records delivered and records billed, requested/attempted/succeeded/failed watch counts. 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": "example-monitor",
    "watches": [
        {
            "watchId": "connecticut-sites",
            "state": "CT",
            "county": ""
        }
    ],
    "maxResultsPerWatch": 200,
    "notifyOn": "new_alerts"
};

// Run the Actor and wait for it to finish
const run = await client.actor("titan_coder/epa-superfund-npl-status-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 = {
    "monitorId": "example-monitor",
    "watches": [{
            "watchId": "connecticut-sites",
            "state": "CT",
            "county": "",
        }],
    "maxResultsPerWatch": 200,
    "notifyOn": "new_alerts",
}

# Run the Actor and wait for it to finish
run = client.actor("titan_coder/epa-superfund-npl-status-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 '{
  "monitorId": "example-monitor",
  "watches": [
    {
      "watchId": "connecticut-sites",
      "state": "CT",
      "county": ""
    }
  ],
  "maxResultsPerWatch": 200,
  "notifyOn": "new_alerts"
}' |
apify call titan_coder/epa-superfund-npl-status-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,titan_coder/epa-superfund-npl-status-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/qIxojigUvEkGKczSF/builds/F8J4iloa3RKwpxffc/openapi.json
