EPA Superfund NPL Status Monitor avatar

EPA Superfund NPL Status Monitor

Pricing

$10.00 / 1,000 npl status changeds

Go to Apify Store
EPA Superfund NPL Status Monitor

EPA Superfund NPL Status Monitor

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.

Pricing

$10.00 / 1,000 npl status changeds

Rating

0.0

(0)

Developer

Radu Furtuna

Radu Furtuna

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

a day ago

Last modified

Share

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

{
"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)