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.
| classification | status text (lower-cased) |
|---|---|
alleged_violation | violation identifiedviolation w/in 1 yearhigh priority violationsignificant noncompliancesignificant/category i noncompliancesignificant violationsignificant violatorserious violatorviolation unaddressed, epa has lead enforcementviolation unaddressed, state has lead enforcementviolation unaddressed, local has lead enforcementviolation addressed, epa has lead enforcementviolation addressed, state has lead enforcementviolation addressed, local has lead enforcementcompliance/permit schedule violationseffluent - monthly average limiteffluent - non-monthly average limitcompliance/permit schedule reportingfailure to report dmr - not receivedviolation |
no_violation_identified | no violation identified |
not_a_compliance_finding | terminated permitnot applicableinactiveunknownnot available |
unrecognized | any other text |
The traps, runnable
Node 18 or later, no dependencies, five calls two seconds apart. It demonstrates traps 1 to 8.
// 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:
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.