Labor enforcement data · implementation
The Department of Labor's Open Data API: eight traps in apiprod.dol.gov
The U.S. Department of Labor publishes its enforcement datasets through a free, keyed API. Several of its behaviors produce output that looks like a clean answer when it is not. Each trap below was measured against the live API on , with the script and its output at the end of the page.
Where the data lives
# a dataset read: agency / dataset / format, the key in the query string
GET https://apiprod.dol.gov/v4/get/WHD/enforcement/json?limit=26&X-API-KEY=YOUR_KEY&filter_object={"field":"trade_nm","operator":"like","value":"%Construction%"}
A free key comes from DOL's data portal registration. The
dataset catalog at /v4/datasets answers without a key.
1. The key goes in the query string, and nowhere else
With the key as the X-API-KEY query parameter the request returned HTTP 200. The same key sent as an
X-API-KEY header returned HTTP 401 with the same body as a request with no key at all:
The API key is either incorrect or missing from your query. If you are using the API Query Builder in the Open Data Port
A key in a URL travels into places a header does not: proxy and gateway logs, and any error message that quotes the URL. Redact it from everything your own code logs.
2. A wrong path does not say "not found"
A misspelled dataset (WHD/enforcment) returned HTTP 500 with There was a server error querying the dataset. Please ensure the dataset name in the URL is correct.. A path the
gateway does not know, such as /robots.txt, returned HTTP 403 with {"message":"Missing Authentication Token"}, and so
did the legacy host api.dol.gov (HTTP 403). That message reads like an authentication problem and is
not one: check the path before the key.
3. The response is an envelope, and the envelope holds nothing else
A successful read carried exactly these top-level keys: ["data"]. There is no total, no next-page link and
no count, so nothing in a response tells you whether more rows exist.
4. limit defaults to 10
Without limit, the read returned 10 rows. The keyless catalog pages the same way: its first page
listed 10 datasets, and its meta reported 42 across 5 pages. Set
limit on every read.
5. A full page looks exactly like a complete answer
trade_nm like %Construction% returned 25 rows at limit=25 and 26 at
limit=26, with the same top-level keys (["data"]). Nothing in the 25-row answer said it was cut. Ask for one
more row than you will use: if it comes back, the list is partial, and whatever you report from it is a lower bound.
6. No match is HTTP 204 with an empty body labeled JSON
A filter that matched nothing returned HTTP 204, content-type application/json and a body of 0
bytes, so JSON.parse throws. Check the status before parsing, and
treat 204 as "no rows" only after confirming the request itself was the one you meant to send.
7. like is case-sensitive, and datasets store names differently
On OSHA's inspection data, estab_name like %Restaurant% returned 0 rows and
%RESTAURANT% returned 5: that dataset stores names in capitals.
On WHD's enforcement data, both spellings returned rows for the same word, so one case cannot be assumed for every dataset.
It is also a literal substring match: punctuation and legal suffixes must match what the agency stored. Search on the
distinctive words of a name, then score the rows you get back yourself.
8. Dates are strings with no time zone
findings_start_date arrived as {"string":26}, for example 2004-07-15T00:00:00. Parse it as a
calendar date, not as an instant in your server's time zone.
The whole thing, runnable
Node 18 or later, no dependencies. It pauses eight seconds between calls, stops on HTTP 429, and never prints a record's name, address or your key.
// dol-api.mjs -- traps in the U.S. Department of Labor's Open Data API v4 (apiprod.dol.gov).
// Node 18+, no dependencies, ~18 requests 8 s apart. Needs a free API key from https://dataportal.dol.gov/registration
// DOL_API_KEY=... node dol-api.mjs
// Prints status codes, row counts, top-level keys and field types. It never prints a record's name or
// address: some employers in these datasets are individuals. It never prints the key.
const HOST = 'https://apiprod.dol.gov';
const KEY = process.env.DOL_API_KEY;
if (!KEY) { console.error('Set DOL_API_KEY'); process.exit(1); }
const UA = 'AervikLabs-docs/1.0 (developer guide research; https://aerviklabs.com)';
const PAUSE_MS = 8000; // the gateway rate-limits per key and answered 429 at 3 s spacing on 2026-09-14
let last = 0;
const hide = (s) => String(s).split(KEY).join('<key>');
async function get(url, headers = {}, attempt = 1) {
const wait = last + PAUSE_MS - Date.now();
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
last = Date.now();
const res = await fetch(url, { headers: { 'user-agent': UA, ...headers }, redirect: 'manual' });
if ([502, 503, 504].includes(res.status) && attempt < 3) {
// T9: the gateway returns transient 5xx under ordinary use. Retry with a growing pause, at most twice.
console.log(`\n${new Date().toISOString()} T9 ${res.status} on attempt ${attempt}; retrying`);
await new Promise((r) => setTimeout(r, 5000 * attempt));
return get(url, headers, attempt + 1);
}
const text = await res.text();
let json = null;
try { json = JSON.parse(text); } catch { /* not JSON */ }
const shown = hide(url.replace(HOST, '')).replace(/X-API-KEY=[^&]+/, 'X-API-KEY=<key>');
console.log(`\n${new Date().toISOString()} GET ${shown} -> ${res.status}`);
if (res.status === 429) {
// The gateway rate-limits per key. Production code backs off and retries with a cap; this script stops,
// so it never adds load to a drained window.
console.log(`T9 429, Retry-After: ${res.headers.get('retry-after')}; stopping`);
process.exit(2);
}
return { status: res.status, type: res.headers.get('content-type') ?? '', text, json };
}
const q = (agency, dataset, params) => `${HOST}/v4/get/${agency}/${dataset}/json?${new URLSearchParams(params)}`;
const like = (field, value) => JSON.stringify({ field, operator: 'like', value });
const rows = (r) => (Array.isArray(r.json?.data) ? r.json.data.length : null);
const body = (r) => JSON.stringify(hide(r.text.replace(/\s+/g, ' ').trim().slice(0, 120)));
// T0: robots.txt, and the keyless dataset catalog, which pages by ten.
const robots = await get(`${HOST}/robots.txt`);
console.log(`T0 robots.txt ${robots.status} ${robots.type}; body ${body(robots)}`);
const cat = await get(`${HOST}/v4/datasets`);
const page1 = Array.isArray(cat.json?.datasets) ? cat.json.datasets : [];
console.log(`T0 /v4/datasets ${cat.status} keyless; top-level keys ${JSON.stringify(cat.json ? Object.keys(cat.json) : null)}; datasets on page ${page1.length}; meta ${JSON.stringify(cat.json?.meta ?? null)}`);
const freqOf = (d) => `${d?.agency?.abbr ?? '?'}/${d?.api_url ?? '?'} ${JSON.stringify(d?.frequency ?? null)}`;
if (page1[0]) console.log(`T0 first dataset: ${freqOf(page1[0])}; keys ${JSON.stringify(Object.keys(page1[0]))}`);
// T1: the key goes in the query string. The same key as a header is treated as no key.
const okParams = { limit: '1', 'X-API-KEY': KEY };
const t1q = await get(q('WHD', 'enforcement', okParams));
console.log(`T1 key as query parameter -> ${t1q.status}, rows ${rows(t1q)}`);
const t1h = await get(q('WHD', 'enforcement', { limit: '1' }), { 'X-API-KEY': KEY });
console.log(`T1 key as X-API-KEY header -> ${t1h.status}, body ${body(t1h)}`);
const t1n = await get(q('WHD', 'enforcement', { limit: '1' }));
console.log(`T1 no key -> ${t1n.status}, body ${body(t1n)}`);
// T1b: a wrong path answers like a missing key.
const t1w = await get(q('WHD', 'enforcment', okParams));
console.log(`T1b misspelled dataset (enforcment) -> ${t1w.status}, body ${body(t1w)}`);
// T2: the response is an envelope, and it carries no total.
console.log(`T2 top-level keys ${JSON.stringify(t1q.json ? Object.keys(t1q.json) : null)}`);
// T3: limit defaults to 10.
const t3 = await get(q('WHD', 'enforcement', { 'X-API-KEY': KEY }));
console.log(`T3 no limit -> ${t3.status}, rows ${rows(t3)}`);
// T4: a full page looks exactly like a complete answer. Ask for one more row than you will use.
const t4a = await get(q('WHD', 'enforcement', { limit: '25', filter_object: like('trade_nm', '%Construction%'), 'X-API-KEY': KEY }));
const t4b = await get(q('WHD', 'enforcement', { limit: '26', filter_object: like('trade_nm', '%Construction%'), 'X-API-KEY': KEY }));
console.log(`T4 WHD trade_nm like %Construction%: limit=25 -> ${rows(t4a)} rows, keys ${JSON.stringify(t4a.json ? Object.keys(t4a.json) : null)}; limit=26 -> ${rows(t4b)} rows`);
// T5: no match is 204 with an empty body, not an empty list.
const t5 = await get(q('WHD', 'enforcement', { limit: '5', filter_object: like('trade_nm', '%Zzqxv Nonexistent%'), 'X-API-KEY': KEY }));
console.log(`T5 no match -> ${t5.status}, content-type ${JSON.stringify(t5.type)}, body length ${t5.text.length}, JSON.parse ${t5.json === null ? 'throws' : 'ok'}`);
// T6: like is case-sensitive, and two datasets store names in different case.
for (const [agency, dataset, field] of [['OSHA', 'inspection', 'estab_name'], ['WHD', 'enforcement', 'trade_nm']]) {
for (const v of ['%Restaurant%', '%RESTAURANT%']) {
const r = await get(q(agency, dataset, { limit: '5', filter_object: like(field, v), 'X-API-KEY': KEY }));
console.log(`T6 ${agency}/${dataset} ${field} like ${v} -> ${r.status}, rows ${rows(r) ?? 0}`);
}
}
// T7: amounts arrive as numbers, dates as strings with no time zone.
const sample = t4b.json?.data ?? [];
const typeOf = (v) => (v === null ? 'null' : v === '' ? '""' : typeof v);
for (const f of ['bw_atp_amt', 'cmp_assd', 'findings_start_date', 'findings_end_date']) {
const t = {};
for (const row of sample) { const k = f in row ? typeOf(row[f]) : 'absent'; t[k] = (t[k] ?? 0) + 1; }
const ex = sample.find((row) => typeof row[f] === 'string' && row[f] !== '')?.[f];
console.log(`T7 ${f} ${JSON.stringify(t)} e.g. ${JSON.stringify(ex ?? null)}`);
}
// T8: the legacy host.
const t8 = await get('https://api.dol.gov/V1/WHD/Enforcement?KEY=x');
console.log(`T8 api.dol.gov legacy -> ${t8.status}, content-type ${JSON.stringify(t8.type)}, body ${body(t8)}`);
Its output on :
$ node --version
v24.20.0
$ DOL_API_KEY=... node dol-api.mjs
2026-09-15T10:47:28.190Z GET /robots.txt -> 403
T0 robots.txt 403 application/json; body "{\"message\":\"Missing Authentication Token\"}"
2026-09-15T10:47:36.193Z GET /v4/datasets -> 200
T0 /v4/datasets 200 keyless; top-level keys ["datasets","meta"]; datasets on page 10; meta {"current_page":1,"next_page":2,"prev_page":null,"total_pages":5,"total_count":42}
T0 first dataset: ILAB/Child_Labor_Report__2016_to_2022 "Static"; keys ["id","name","description","tablename","published_at","dataset_type","agency_id","agency","frequency","api_url","tablename_alias","category_id","category","category_name","status","tag_list","created_at","updated_at"]
2026-09-15T10:47:47.422Z GET /v4/get/WHD/enforcement/json?limit=1&X-API-KEY=<key> -> 200
T1 key as query parameter -> 200, rows 1
2026-09-15T10:47:51.942Z GET /v4/get/WHD/enforcement/json?limit=1 -> 401
T1 key as X-API-KEY header -> 401, body "The API key is either incorrect or missing from your query. If you are using the API Query Builder in the Open Data Port"
2026-09-15T10:48:00.221Z GET /v4/get/WHD/enforcement/json?limit=1 -> 401
T1 no key -> 401, body "The API key is either incorrect or missing from your query. If you are using the API Query Builder in the Open Data Port"
2026-09-15T10:48:07.972Z GET /v4/get/WHD/enforcment/json?limit=1&X-API-KEY=<key> -> 500
T1b misspelled dataset (enforcment) -> 500, body "There was a server error querying the dataset. Please ensure the dataset name in the URL is correct."
T2 top-level keys ["data"]
2026-09-15T10:48:19.314Z GET /v4/get/WHD/enforcement/json?X-API-KEY=<key> -> 200
T3 no limit -> 200, rows 10
2026-09-15T10:48:28.385Z GET /v4/get/WHD/enforcement/json?limit=25&filter_object=%7B%22field%22%3A%22trade_nm%22%2C%22operator%22%3A%22like%22%2C%22value%22%3A%22%25Construction%25%22%7D&X-API-KEY=<key> -> 200
2026-09-15T10:48:35.198Z GET /v4/get/WHD/enforcement/json?limit=26&filter_object=%7B%22field%22%3A%22trade_nm%22%2C%22operator%22%3A%22like%22%2C%22value%22%3A%22%25Construction%25%22%7D&X-API-KEY=<key> -> 200
T4 WHD trade_nm like %Construction%: limit=25 -> 25 rows, keys ["data"]; limit=26 -> 26 rows
2026-09-15T10:48:42.744Z GET /v4/get/WHD/enforcement/json?limit=5&filter_object=%7B%22field%22%3A%22trade_nm%22%2C%22operator%22%3A%22like%22%2C%22value%22%3A%22%25Zzqxv+Nonexistent%25%22%7D&X-API-KEY=<key> -> 204
T5 no match -> 204, content-type "application/json", body length 0, JSON.parse throws
2026-09-15T10:48:52.586Z GET /v4/get/OSHA/inspection/json?limit=5&filter_object=%7B%22field%22%3A%22estab_name%22%2C%22operator%22%3A%22like%22%2C%22value%22%3A%22%25Restaurant%25%22%7D&X-API-KEY=<key> -> 204
T6 OSHA/inspection estab_name like %Restaurant% -> 204, rows 0
2026-09-15T10:48:59.654Z GET /v4/get/OSHA/inspection/json?limit=5&filter_object=%7B%22field%22%3A%22estab_name%22%2C%22operator%22%3A%22like%22%2C%22value%22%3A%22%25RESTAURANT%25%22%7D&X-API-KEY=<key> -> 200
T6 OSHA/inspection estab_name like %RESTAURANT% -> 200, rows 5
2026-09-15T10:49:07.394Z GET /v4/get/WHD/enforcement/json?limit=5&filter_object=%7B%22field%22%3A%22trade_nm%22%2C%22operator%22%3A%22like%22%2C%22value%22%3A%22%25Restaurant%25%22%7D&X-API-KEY=<key> -> 200
T6 WHD/enforcement trade_nm like %Restaurant% -> 200, rows 5
2026-09-15T10:49:14.504Z GET /v4/get/WHD/enforcement/json?limit=5&filter_object=%7B%22field%22%3A%22trade_nm%22%2C%22operator%22%3A%22like%22%2C%22value%22%3A%22%25RESTAURANT%25%22%7D&X-API-KEY=<key> -> 200
T6 WHD/enforcement trade_nm like %RESTAURANT% -> 200, rows 5
T7 bw_atp_amt {"number":26} e.g. null
T7 cmp_assd {"number":26} e.g. null
T7 findings_start_date {"string":26} e.g. "2004-07-15T00:00:00"
T7 findings_end_date {"string":26} e.g. "2006-07-14T00:00:00"
2026-09-15T10:49:19.904Z GET https://api.dol.gov/V1/WHD/Enforcement?KEY=x -> 403
T8 api.dol.gov legacy -> 403, content-type "application/json", body "{\"message\":\"Missing Authentication Token\"}"
exit 0
What this does not cover
What any record means for any employer, which datasets apply to a question, and the dataset-by-dataset field definitions in DOL's own documentation. This page is about reading the API correctly.
Aervik Labs' EPA Compliance Risk API reads WHD, MSHA and OSHA data through this API for its company screen.