EPA ECHO · implementation

EPA ECHO API: nine traps in the facility search services

EPA's Enforcement and Compliance History Online publishes free REST services for facility data. They are useful and lightly documented, and several of their failures look exactly like success. Each trap below was hit on a live call on 2026-09-13.

How a facility search works

get_facilities validates a search and returns counts and a QueryID. The rows come from a second call — get_download (CSV) or get_qid (JSON, paged) — using that ID. EPA's Swagger reference lists the parameters, and says QueryIDs "are time sensitive and will be valid for approximately 30 minutes."

1. "Too broad" is an HTTP 200, and retrying cannot fix it

get_facilities?output=JSON&p_fn=inc returned status 200 with {"Results":{"Error":{"ErrorMessage":"Rows Returned would be 5668461. Queryset Limit would be exceeded - please make search parameters more selective."}}}. Check Results.Error before QueryRows. The same query gets the same answer, so narrow the search instead of retrying.

2. A parameter ECHO doesn't know is ignored, not rejected

A misspelled filter does not fail; it disappears, and your search widens. For p_fn=EXXONMOBIL&p_st=TX ECHO counted 171 rows. Adding p_ci=BAYTOWN: still 171. Adding p_ct=BAYTOWN: 18. With a street address in Texas, p_add left the search too broad to run (345,457 rows), while p_sa narrowed it to 7. Swagger names the city filter p_ct ("Facility City Filter") and the address filter p_sa ("Facility street address"); p_ci and p_add are not among its parameters.

Fix: use only documented names, and check that a filter changed the count. The one-call get_facility_info also echoes the parameters it accepted in Results.QueryParameters.

3. get_facilities never returns facilities

It returns counts and a QueryID, all as strings — "QueryRows":"18", "TotalPenalties":"$2,509,978" — and the ID is new on every call. There is no facilities key. Swagger also documents a one-call get_facility_info, which "Returns either an array of Facilities or an array of Clusters."

4. A failed download is also an HTTP 200, and a CSV parser reads it as rows

get_download with an unknown QueryID returned 200, text/html, three lines: <b>FILE OUTPUT FAILED</b> … QueryID 987654321 not found in ECHO. A parser that takes the first line as a header yields two "rows". Check that the content type is text/csv and that the header row holds the columns you asked for before parsing a single line.

5. The rows are CSV with CRLF line endings

Strings are quoted, numbers are not, and empty fields are bare (,,). Penalties are plain digits in the CSV (2204913) but currency text in the summary call. Column order follows qcolumns, whose numeric IDs come from echo_rest_services.metadata (202 columns on 2026-09-13).

6. Flags come in two dialects, and blank is a third value

In one download, AIRFlag held Y/N, CurrVioFlag held 1/0, and FacMajorFlag held Y or blank, with no N in 18 rows. The metadata service types them differently (VARCHAR2(1), CHAR(1), NUMBER) and gives no description for what blank means, so neither does this page. Read each flag by its own dialect, and keep blank distinct from no.

7. The Detailed Facility Report link has spaces in it

DfrUrl is served as https://echo.epa.gov/detailed-facility-report?fid = 110000463178. A URL parser finds no fid in that; strip the whitespace first.

8. Row 0 is not "the" facility

p_fn=EXXONMOBIL&p_st=TX&p_ct=BAYTOWN returned 18 rows. The first was BAYTOWN OLEFINS PLANT, and one row was MONT BELVIEU PLASTICS PLANT in Mont Belvieu. Swagger's name-match parameter p_fntype (EXACT, BEGINS, ALL, CONTAINS) documents no default, and why those rows matched is not stated. Compare the fields you care about on every row before treating any one as the answer, and report how many matched.

9. The ID parameters are not uniform

RegistryID in one download held both 12-digit (110000502901) and 10-digit values. Swagger documents p_frs as the FRS identifier filter and p_pid as "Nine-digit permit IDs" — yet on 2026-09-13 each returned one row for the 12-digit FRS ID 110000502901. Use the documented one; undocumented behavior can change without notice.

The compliance status columns are text, and not every value is a finding

CAAComplianceStatus, CWAComplianceStatus, RCRAComplianceStatus and SDWAComplianceStatus hold words. EPA's All Media Programs Search Results Help lists values that allege nothing, such as "Terminated Permit" and "Not Applicable" for the Clean Water Act and "Inactive" for the Safe Drinking Water Act. It also says that for a Clean Air Act high priority violation "the specific type of HPV will be displayed", and for a Clean Water Act significant noncompliance "the specific type of SNC/Category I will be displayed". So "anything other than No Violation Identified" is not a violation test, and a short list of violation words will not match every violation. Classify each value explicitly and keep a bucket for text you do not recognize.

For reference, this is the explicit table the EPA Compliance Risk API uses, matched after trimming, collapsing whitespace and ignoring case. Any other text is unrecognized: returned verbatim in compliance_statuses and counted as neither a violation nor clean.

classificationstatus text (lower-cased)
alleged_violationviolation identified
violation w/in 1 year
high priority violation
significant noncompliance
significant/category i noncompliance
significant violation
significant violator
serious violator
violation unaddressed, epa has lead enforcement
violation unaddressed, state has lead enforcement
violation unaddressed, local has lead enforcement
violation addressed, epa has lead enforcement
violation addressed, state has lead enforcement
violation addressed, local has lead enforcement
compliance/permit schedule violations
effluent - monthly average limit
effluent - non-monthly average limit
compliance/permit schedule reporting
failure to report dmr - not received
violation
no_violation_identifiedno violation identified
not_a_compliance_findingterminated permit
not applicable
inactive
unknown
not available
unrecognizedany other text

The traps, runnable

Node 18 or later, no dependencies, five calls two seconds apart. It demonstrates traps 1 to 8.

javascript
// EPA ECHO REST services: the traps, shown on a real query. Node 18+, no dependencies. 5 calls, 2s apart.
const BASE = 'https://echodata.epa.gov/echo/echo_rest_services.';
const HEADERS = { 'User-Agent': 'AervikLabs-docs/1.0 (research; contact via aerviklabs.com)' };
let calls = 0;
async function get(path) {
  if (calls++) await new Promise((r) => setTimeout(r, 2000));
  const res = await fetch(BASE + path, { headers: HEADERS });
  return { status: res.status, type: res.headers.get('content-type') ?? '', body: await res.text() };
}
const search = async (q) => JSON.parse((await get('get_facilities?output=JSON&' + q)).body).Results;
// Minimal CSV field splitter: quoted fields, "" escapes, commas inside quotes. No line breaks inside fields.
const splitCsv = (line) =>
  [...line.matchAll(/(?:^|,)("(?:[^"]|"")*"|[^,]*)/g)].map((m) => m[1].replace(/^"|"$/g, '').replace(/""/g, '"'));
console.log('run at', new Date().toISOString());

// Trap 1: a search that is too broad is HTTP 200 with Results.Error, not a 4xx.
const broad = await search('p_fn=inc');
console.log('T1 too broad ->', JSON.stringify(broad));

// Trap 2: an unrecognized parameter is ignored, not rejected. City is p_ct; p_ci silently widens to the whole state.
const ignored = await search('p_fn=EXXONMOBIL&p_st=TX&p_ci=BAYTOWN');
const found = await search('p_fn=EXXONMOBIL&p_st=TX&p_ct=BAYTOWN');
console.log('T2 QueryRows with p_ci:', ignored.QueryRows, '| with p_ct:', found.QueryRows);

// Trap 3: get_facilities returns a QueryID and counts (all strings), never the facility rows.
console.log('T3 keys:', Object.keys(found).join(','), '| QueryID:', JSON.stringify(found.QueryID));

// Trap 4: a failed download is HTTP 200 text/html, and a naive CSV parser reads it as rows.
const bad = await get('get_download?qid=987654321&qcolumns=1');
console.log('T4 bad qid ->', bad.status, bad.type, JSON.stringify(bad.body), '| lines:', bad.body.trim().split('\n').length);

// Trap 5: the rows come from get_download as CSV with CRLF endings. Check type and header before parsing.
const dl = await get(`get_download?qid=${found.QueryID}&qcolumns=1,3,6,73,98,137,155`);
if (!dl.type.startsWith('text/csv') || !dl.body.startsWith('"FacName"')) throw new Error('not ECHO CSV: ' + dl.body.slice(0, 120));
const [header, ...rows] = dl.body.replace(/\r\n$/, '').split('\r\n').map(splitCsv);
const col = (row, name) => row[header.indexOf(name)];
console.log('T5', dl.status, dl.type, '| CRLF:', dl.body.includes('\r\n'), '| header:', header.join(','), '| rows:', rows.length);

// Trap 6: two flag dialects plus blank. Y/N columns and 0/1 columns sit side by side.
for (const name of ['FacMajorFlag', 'AIRFlag', 'CurrVioFlag']) console.log('T6', name, 'values:', JSON.stringify([...new Set(rows.map((r) => col(r, name)))]));

// Trap 7: the Detailed Facility Report link has spaces around "=", so a URL parser finds no fid.
const dfr = col(rows[0], 'DfrUrl');
console.log('T7', JSON.stringify(dfr), '| fid as served:', new URL(dfr).searchParams.get('fid'), '| whitespace stripped:', new URL(dfr.replace(/\s+/g, '')).searchParams.get('fid'));

// Trap 8: rows are not all literal name/city matches, so row 0 is not "the" facility.
console.log('T8 row 0:', col(rows[0], 'FacName'), '|', col(rows[0], 'FacCity'));
const loose = rows.filter((r) => !col(r, 'FacName').includes('EXXONMOBIL') || col(r, 'FacCity') !== 'BAYTOWN');
console.log('T8 not literal matches:', loose.map((r) => `${col(r, 'FacName')} (${col(r, 'FacCity')}, ${col(r, 'RegistryID')})`).join('; '));

Run against live ECHO on 2026-09-14 (UTC), it printed:

output
run at 2026-09-14T01:36:35.545Z
T1 too broad -> {"Error":{"ErrorMessage":"Rows Returned would be 5668461. Queryset Limit would be exceeded - please make search parameters more selective."}}
T2 QueryRows with p_ci: 171 | with p_ct: 18
T3 keys: Message,Version,QueryRows,IndianCountryRows,SVRows,CVRows,V3Rows,FEARows,InfFEARows,INSPRows,TotalPenalties,CAARows,CWARows,RCRRows,TRIRows,BadSystemIDs,QueryID | QueryID: "830"
T4 bad qid -> 200 text/html;charset=UTF-8 "<b>FILE OUTPUT FAILED</b>\n<br />\n<b>ERROR MESSAGE:</b> QueryID 987654321 not found in ECHO.\n" | lines: 3
T5 200 text/csv;charset=UTF-8 | CRLF: true | header: FacName,FacCity,RegistryID,FacMajorFlag,AIRFlag,DfrUrl,CurrVioFlag | rows: 18
T6 FacMajorFlag values: ["Y",""]
T6 AIRFlag values: ["Y","N"]
T6 CurrVioFlag values: ["1","0"]
T7 "https://echo.epa.gov/detailed-facility-report?fid = 110000463178" | fid as served: null | whitespace stripped: 110000463178
T8 row 0: BAYTOWN OLEFINS PLANT | BAYTOWN
T8 not literal matches: BAYTOWN OLEFINS PLANT (BAYTOWN, 110000463178); MONT BELVIEU PLASTICS PLANT (MONT BELVIEU, 110000463702)

Fair use and freshness, in EPA's words

The Web Services page: the services "allow developers to design custom applications utilizing a live feed of data from ECHO," and "To download a large volume of data, please use the ECHO Data Downloads." About the Data: "ECHO is not designed for large scale data transfers or robotic queries. EPA reserves the right to disable users that initiate robotic, programmed queries." No numeric rate limit is published; pace your calls, and use the downloads for bulk.

The same page: "Data are typically refreshed on a weekly schedule from EPA source databases." And ECHO's FAQ says its violation characterizations "should be considered alleged violations" where there has been no final adjudication — so describe them that way.

What this does not cover

Program-specific services (air, water, waste, drinking water), map services, and the bulk downloads. Nothing here says anything about any facility's compliance. Aervik Labs operates the EPA Compliance Risk API, which queries ECHO.

Last updated . Corrections: see About.