Denied-party screening · implementation
trade.gov's Consolidated Screening List API: nine traps in the search endpoint
The International Trade Administration's Consolidated Screening List (CSL) API searches the export-restriction lists of Commerce, State and the Treasury in one call. A screening loop written the obvious way can report a name as checked when most of the matching records were never read. 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
# search (the endpoint every trap below is about) GET https://data.trade.gov/consolidated_screening_list/v1/search?name=...&fuzzy_name=true # the lists behind it, with their dates GET https://data.trade.gov/consolidated_screening_list/v1/sources # auth: a free subscription key from developer.trade.gov, as a header or a query parameter subscription-key: <your key>
ITA's reference is the developer portal. Quotes below marked "the portal" were read there on 2026-09-14.
1. You get one page, and the response says so
A fuzzy search for technology returned "total": 900 and
10 results. The response's sources array counts all 900
matches, not the page: EL 568, SDN 252, UVL 29, DPL 18, CMIC 15, MEU 7, ISN 6, SSI 3, DTC 2. Compare
results.length with total on every call. If they differ, the rest were not
screened, and a "no match" built from the first page is not a result for them.
List abbreviations here are the ones in each record's source field — EL for the Entity
List, SDN for the Specially Designated Nationals List, DPL for the Denied Persons List;
the CSL page names them all.
2. A size above 50 does not fail. You get 10.
The portal: "The size parameter allows you to configure the number of results to be returned up to
a maximum of 50." Asking for size=100 returned HTTP 200 with 10 results and no error.
Send 50 or fewer, and check the page length against what you asked for.
3. offset stops at 1000
The portal: "This value cannot be greater than 1000." Filtering to the Entity List reported
"total": 3419. offset=1000&size=50 returned 50 records;
offset=1050 returned HTTP 400 with no content-type header and this body:
{ "statusCode": 400, "message": "Maximum offset is 1000. Please add additional search parameters and try again." }
offset=1001 returned 400 too. So one query reaches at most 1,050 of
3,419 records, and a paging loop that treats any non-200 as "no more pages" stops there
without saying so. Narrow the query with sources, countries or the address
filters, or use ITA's downloadable files, linked from the CSL page.
4. type is null on whole lists, and filtering by it drops them
Every record carries a type key, but on the page above the 7
EL records had "type": null, while the SDN and CMIC
records said "Entity". Adding types=Entity cut the query from 900 to
270 (SDN 252, CMIC 15, SSI 3);
types=Individual returned 0. So 630 records
(EL 568, UVL 29, DPL 18, MEU 7, ISN 6, DTC 2) match neither filter.
The portal lists the values Aircraft, Entity, Individual and
Vessel and says only "Searches only entries specified by the type." It does not say what
null means. That cuts both ways: types=Entity silently removes lists, and treating "not
Individual" as "not a person" keeps untyped records from lists ITA describes as
"Individuals and entities that have been denied export privileges" (the Denied Persons List, on
the CSL page). Decide per list what an untyped record is, and do not use the
type filter as a boundary.
5. Fuzzy mode deletes words from your query
The portal: "Fuzzy search filters out the following common words: co, company, corp, corporation,
inc, incorporated, limited, ltd, mrs, ms, mr, organization, sa, sas, llc, university, and univ."
Fuzzy water and fuzzy water corporation both returned
"total": 436; the same
water corporation without fuzzy_name returned 0. A fuzzy search for a
legal name ending in "Corporation" searches the shorter name, which makes the result set wider and
trap 1 more likely. The portal also says "The fuzzy_name parameter only works in tandem with name."
6. score: 100 is not an exact match
ITA: "In Fuzzy Name mode, the CSL returns a “score” for results that exactly or nearly match the
searched name" (the CSL page). The scores on the technology page
were 100, and none of the 10 names is exactly technology. The scale is not
documented. Compare the returned names yourself before calling anything a match.
7. The search response has no list dates. /sources does, with no time zone.
The search response's top-level keys were ["total","sources","results"], and it carried no
Last-Modified header. GET /sources returned 13 rows, each
with import_rate, last_imported and source_last_updated — for
example the Entity List's source_last_updated read "8/21/2026 8:19:33 PM" and the
Nonproliferation list's "4/21/2025 5:32:27 PM". The portal documents /sources with a
null description and no parameters or responses.
Every last_imported read "9/14/2026 9:15:00 AM" when we asked at 2026-09-14T10:32:15.378Z. Read as Eastern time, it is 163 minutes in the future, so it is not Eastern. Read as UTC, it is 77 minutes before we asked. Neither is stated. Store these strings as ITA's labels, not as timestamps you can compare with your own clock, and do not report either one as the date a list last changed.
8. trade.gov gives two update schedules
trade.gov/data: "This data set is updated hourly." The CSL page: "All the tools are updated automatically every day at 5:00 AM EST/EDT." The same page: "It is the responsibility of the federal agency to make available updated file(s) in order for the CSL to be up-to-date." /sources says import_rate=Hourly for all 13 lists. An hourly import is not an hourly-changed list, and neither date field is defined by ITA, so treat them as what they are named and nothing more.
9. The rate limit is undocumented, and a 200 gives no warning
The portal lists one response for search, a 200. No rate figure appears on trade.gov/data, the CSL
page or the portal, and a 200 carried no rate-limit headers ([]). When we measured it on
2026-09-05, 17 consecutive searches returned 200, the 18th returned 429 with
Retry-After: 6, and the header counted down as the window drained. Read
Retry-After, cap the wait, and report a name whose check never ran as not screened —
never as clean.
The whole thing, runnable
Node 18 or later, no dependencies. It pauses 6.5 seconds between calls, prints counts, list
abbreviations and field presence, and never prints a record's name. On a 429 it prints
Retry-After and stops.
// csl-search.mjs -- the traps in trade.gov's Consolidated Screening List search endpoint.
// Node 18+, no dependencies. Needs a free subscription key from https://developer.trade.gov/
// TRADE_GOV_API_KEY=... node csl-search.mjs
// Prints counts, list names and field presence only. It never prints a record's name: the CSL
// lists individuals as well as companies.
const BASE = 'https://data.trade.gov/consolidated_screening_list/v1';
const KEY = process.env.TRADE_GOV_API_KEY;
if (!KEY) { console.error('Set TRADE_GOV_API_KEY'); process.exit(1); }
const PAUSE_MS = 6500; // trade.gov publishes no rate limit, so pace well under the one we measured
let last = 0;
async function csl(path) {
const wait = last + PAUSE_MS - Date.now();
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
last = Date.now();
const res = await fetch(BASE + path, { headers: { 'subscription-key': KEY, accept: 'application/json' } });
const text = await res.text();
console.log(`\n${new Date().toISOString()} GET ${path} -> ${res.status}`);
if (res.status === 429) {
// T7: the wait is in Retry-After (seconds). Production code waits and retries, with a cap;
// this script stops instead, so it never adds load to a drained window.
console.log(`T7 429, Retry-After: ${res.headers.get('retry-after')}`);
process.exit(2);
}
let json = null;
try { json = JSON.parse(text); } catch { /* reported by the caller */ }
return { status: res.status, headers: res.headers, json, text };
}
function ok(r) {
if (r.status !== 200 || !Array.isArray(r.json?.results)) throw new Error(`unexpected: ${r.status} ${r.text.slice(0, 200)}`);
return r.json;
}
const abbr = (s) => (String(s ?? '').match(/\(([^)]+)\)/) ?? [, String(s)])[1];
const facet = (j) => j.sources.map((s) => `${abbr(s.value)} ${s.count}`).join(', ');
// T1: one page, and a true total that is larger.
const a = await csl('/search?name=technology&fuzzy_name=true');
const j1 = ok(a);
console.log(`T1 total=${j1.total} results.length=${j1.results.length}`);
console.log(`T1 sources facet sums to ${j1.sources.reduce((n, s) => n + s.count, 0)}: ${facet(j1)}`);
// T4: `type` by list, on the page we got.
const types = {};
for (const h of j1.results) { const k = `${abbr(h.source)}:${h.type === null ? 'null' : h.type ?? 'absent'}`; types[k] = (types[k] ?? 0) + 1; }
console.log(`T4 type by list on this page: ${JSON.stringify(types)}`);
// T5 and T7: what the response does NOT carry.
console.log(`T5 top-level keys: ${JSON.stringify(Object.keys(j1))}; last-modified header: ${a.headers.get('last-modified')}`);
console.log(`T7 rate-limit headers on a 200: ${JSON.stringify([...a.headers.keys()].filter((k) => /rate|retry|limit|quota/i.test(k)))}`);
// T8: a fuzzy score of 100 is not an exact name match.
const exact = j1.results.filter((h) => String(h.name ?? '').trim().toLowerCase() === 'technology').length;
console.log(`T8 scores: ${JSON.stringify([...new Set(j1.results.map((h) => h.score))])}; names equal to the query: ${exact} of ${j1.results.length}`);
// T4: types=Entity drops every record whose list leaves `type` null -- and types=Individual finds none of them either.
const j2 = ok(await csl('/search?name=technology&fuzzy_name=true&types=Entity'));
console.log(`T4 types=Entity total=${j2.total} (unfiltered ${j1.total}): ${facet(j2)}`);
const j2i = ok(await csl('/search?name=technology&fuzzy_name=true&types=Individual'));
console.log(`T4 types=Individual total=${j2i.total}`);
// T2: size above the documented maximum.
const c = await csl('/search?name=technology&fuzzy_name=true&size=100');
if (c.status !== 200) console.log(`T2 size=100 -> ${c.status} ${c.text.replace(/\s+/g, ' ')}`);
else {
const sc = c.json.results.map((h) => h.score);
console.log(`T2 size=100 -> results.length=${sc.length}; scores non-increasing: ${sc.every((v, i) => i === 0 || v <= sc[i - 1])}; min score ${Math.min(...sc)}`);
}
// T3: offset stops at 1000.
const j4 = ok(await csl('/search?sources=EL&size=50&offset=1000'));
console.log(`T3 sources=EL total=${j4.total}; offset=1000&size=50 -> results.length=${j4.results.length}`);
const e = await csl('/search?sources=EL&size=50&offset=1050');
console.log(`T3 offset=1050 -> ${e.status}, content-type: ${e.headers.get('content-type')}, body: ${e.text.replace(/\s+/g, ' ')}`);
const e1 = await csl('/search?sources=EL&size=1&offset=1001');
console.log(`T3 offset=1001 -> ${e1.status}`);
// T6: fuzzy matching drops common words before it searches.
const w1 = ok(await csl('/search?name=water&fuzzy_name=true&size=1'));
const w2 = ok(await csl('/search?name=water%20corporation&fuzzy_name=true&size=1'));
const w3 = ok(await csl('/search?name=water%20corporation&size=1'));
console.log(`T6 fuzzy "water" total=${w1.total}; fuzzy "water corporation" total=${w2.total}; non-fuzzy "water corporation" total=${w3.total}`);
// T5: the list dates live at /sources, with no time zone.
const s = ok(await csl('/sources'));
for (const r of s.results) console.log(`T5 ${r.id.padEnd(4)} source_last_updated="${r.source_last_updated}" last_imported="${r.last_imported}" import_rate=${r.import_rate}`);
Its output on (totals move whenever the lists change):
$ node --version
v24.20.0
$ TRADE_GOV_API_KEY=... node csl-search.mjs
run at 2026-09-14T10:31:10Z
2026-09-14T10:31:10.971Z GET /search?name=technology&fuzzy_name=true -> 200
T1 total=900 results.length=10
T1 sources facet sums to 900: EL 568, SDN 252, UVL 29, DPL 18, CMIC 15, MEU 7, ISN 6, SSI 3, DTC 2
T4 type by list on this page: {"SDN:Entity":2,"CMIC:Entity":1,"EL:null":7}
T5 top-level keys: ["total","sources","results"]; last-modified header: null
T7 rate-limit headers on a 200: []
T8 scores: [100]; names equal to the query: 0 of 10
2026-09-14T10:31:16.973Z GET /search?name=technology&fuzzy_name=true&types=Entity -> 200
T4 types=Entity total=270 (unfiltered 900): SDN 252, CMIC 15, SSI 3
2026-09-14T10:31:23.452Z GET /search?name=technology&fuzzy_name=true&types=Individual -> 200
T4 types=Individual total=0
2026-09-14T10:31:29.970Z GET /search?name=technology&fuzzy_name=true&size=100 -> 200
T2 size=100 -> results.length=10; scores non-increasing: true; min score 100
2026-09-14T10:31:36.495Z GET /search?sources=EL&size=50&offset=1000 -> 200
T3 sources=EL total=3419; offset=1000&size=50 -> results.length=50
2026-09-14T10:31:42.903Z GET /search?sources=EL&size=50&offset=1050 -> 400
T3 offset=1050 -> 400, content-type: null, body: { "statusCode": 400, "message": "Maximum offset is 1000. Please add additional search parameters and try again." }
2026-09-14T10:31:49.392Z GET /search?sources=EL&size=1&offset=1001 -> 400
T3 offset=1001 -> 400
2026-09-14T10:31:55.954Z GET /search?name=water&fuzzy_name=true&size=1 -> 200
2026-09-14T10:32:02.488Z GET /search?name=water%20corporation&fuzzy_name=true&size=1 -> 200
2026-09-14T10:32:08.915Z GET /search?name=water%20corporation&size=1 -> 200
T6 fuzzy "water" total=436; fuzzy "water corporation" total=436; non-fuzzy "water corporation" total=0
2026-09-14T10:32:15.378Z GET /sources -> 200
T5 CAP source_last_updated="9/10/2026 6:31:56 PM" last_imported="9/14/2026 9:15:00 AM" import_rate=Hourly
T5 CMIC source_last_updated="9/10/2026 6:31:56 PM" last_imported="9/14/2026 9:15:00 AM" import_rate=Hourly
T5 DPL source_last_updated="4/28/2026 12:39:28 PM" last_imported="9/14/2026 9:15:00 AM" import_rate=Hourly
T5 DTC source_last_updated="7/24/2026 12:00:00 AM" last_imported="9/14/2026 9:15:00 AM" import_rate=Hourly
T5 EL source_last_updated="8/21/2026 8:19:33 PM" last_imported="9/14/2026 9:15:00 AM" import_rate=Hourly
T5 FSE source_last_updated="9/10/2026 6:31:56 PM" last_imported="9/14/2026 9:15:00 AM" import_rate=Hourly
T5 ISN source_last_updated="4/21/2025 5:32:27 PM" last_imported="9/14/2026 9:15:00 AM" import_rate=Hourly
T5 MBS source_last_updated="9/10/2026 6:31:56 PM" last_imported="9/14/2026 9:15:00 AM" import_rate=Hourly
T5 MEU source_last_updated="2/20/2026 6:34:52 PM" last_imported="9/14/2026 9:15:00 AM" import_rate=Hourly
T5 PLC source_last_updated="9/10/2026 6:31:56 PM" last_imported="9/14/2026 9:15:00 AM" import_rate=Hourly
T5 SDN source_last_updated="9/10/2026 6:31:54 PM" last_imported="9/14/2026 9:15:00 AM" import_rate=Hourly
T5 SSI source_last_updated="9/10/2026 6:31:56 PM" last_imported="9/14/2026 9:15:00 AM" import_rate=Hourly
T5 UVL source_last_updated="2/10/2026 11:02:53 PM" last_imported="9/14/2026 9:15:00 AM" import_rate=Hourly
exit 0
What ITA asks you to tell your users
From trade.gov/data, read 2026-09-14: "When using this API, developers must provide the following information to their users to help them stay compliant with exporting law. If a party to a user’s transaction matches the name of a party on the consolidated list, the user must check the official publication of restricted parties in the Federal Register or the official lists of restricted parties maintained on the websites of the Departments of Commerce, State and the Treasury to ensure full compliance with all of the terms and conditions of the restrictions placed on the parties on this list."
And: "In the event that a company, entity or person on the list appears to match a party potentially involved in an export transaction, additional due diligence should be conducted before proceeding." The API "only contains active entities on the list. For historical research, please reference the sources directly."
What this does not cover
Which lists apply to a transaction, what to do about a match, and anything about the downloadable files beyond where they are. This page is about reading the API correctly, not export-compliance advice.
One thing it does not cover that will cost you a match: a company name sent without its spaces. We measured six spellings of each of 97 real CSL entity names — a short name finds its record 98% of the time with spaces and 4% without, and half the misses score below any near-miss floor. The data and the method: a company name without spaces misses the screening list.
Aervik Labs operates the Import Risk Screening API, which queries the Consolidated Screening List.