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.

Last updated . Corrections: see About.