Tariff schedule · implementation

USITC's HTS export API: nine traps in the tariff schedule data

The U.S. International Trade Commission publishes the Harmonized Tariff Schedule as a free export in JSON, CSV and Excel. The data is exact, but its shape is not what a first parser expects, and several of the mistakes produce output that looks complete. Each trap below was measured against the live export on , with the script and its output at the end of the page.

Where the data lives

# the export every trap below is about: JSON, CSV or XLSX, no key
GET https://hts.usitc.gov/reststop/exportList?from=6101&to=6499&format=JSON&styles=false

That is the call the Export page on hts.usitc.gov makes. USITC's reference is its HTS External User Guide. usitc.gov returned HTTP 403 to our fetchers on 2026-09-14, so quotes marked "the guide" come from the Internet Archive's capture of 2025-02-16, read that day. That edition documents an older URL (trap 9). A newer guide linked from hts.usitc.gov also returned 403 to us, so check it for changes. The site's robots.txt returned text/html; charset=UTF-8 with 0 robots directives.

1. to stops at the number exactly as written

The guide: "The export will include the two article numbers at the start and the end of the range." It includes the end article as printed, and nothing under it. from=6402.99&to=6402.99.90 returned 82 records ending at 6402.99.90, with none of its statistical suffixes. to=6402.99.90.95 returned 87, including all 4: 6402.99.90.05, 6402.99.90.35, 6402.99.90.65, 6402.99.90.95.

It is worse when a line is printed only in 10-digit form. from=1701.12&to=1701.12.50 returned 4 records and no 1701.12.50.00. to=1701.12.50.00 added it, with a General rate of 35.74¢/kg. So set to beyond what you need, filter by HTS number yourself, and check the last row: from=6101&to=6499 returned chapters 61 through 64, ending at 6406.90.90.00.

2. Records with no HTS number, and a range can open on one

In chapters 61 through 64, 1,019 of 4,466 records had "htsno": "", every one with "superior": "true" and none with a General rate. They are the indented text rows ("Other:", "For men:") whose words belong to the numbered lines beneath them. Skip them as lines and keep them as description.

from=1701.12 opened on one: its first record had an empty htsno, indent 1, and the description "Raw sugar not containing added flavoring or coloring matter:". It belongs to the line after it, and the heading above it was not in the response. A download split into ranges can start mid-tree, so build descriptions from a range that begins at a chapter.

3. A blank General rate is not Free

Of 3,218 numbered 8- and 10-digit rows in chapters 61 through 64, 2,325 had "general": "". Every one of them had its rate on the nearest row above with a smaller indent. For example, 6101.90.90.60 is blank and its rate, 5.7%, sits on 6101.90.90, 4 rows up. No 8-digit row was blank. The row directly above a blank one is often a sibling or a text row with no rate either. Resolve a blank by walking up the indents, and never store it as zero.

4. Row counts are not line counts

The guide: "Nearly every good that is imported into the United States is classified in an 8-digit category," and "There are over 10,000 of these 8-digit subheadings covering goods in the HTS." In chapters 61 through 64 the export listed 3,218 numbered rows at 8 or 10 digits: 513 8-digit rows and 2,705 10-digit rows. All 513 8-digit rows were followed by their own 10-digit statistical suffixes, so a count of rows counts each of them twice. 6109.10.00, for example, is one line with 14 suffix rows under it.

The rows that carry a General rate number 893, the same as the distinct 8-digit lines. So there are three honest counts, 893 lines, 2,705 10-digit statistical reporting numbers and 3,218 rows, and a number without its unit is ambiguous. Our measurement of how often six digits fix the rate reports tariff lines and 8-digit lines on the page, and all three in its data README.

5. General is free text

The 893 General texts in chapters 61 through 64 came in 184 distinct strings. By the script's pattern, 742 were plain percentages, 66 read Free, 63 were compound like 38.6¢/kg + 10%, 1 was specific (5.5¢/kg), and 21 were sentences, such as The rate applicable to each garment in the ensemble if separately entered. The same sentence also appears as The rate applicable to each garment in the ensemble if entered separately and The rate applicable to each garment in the in the ensemble if separately entered.

7 carried a trailing space, among them 6402.99.32 "20% ", so "20%" and "20% " compare unequal. The longest rate in the raw sugar range was 135 characters, on 1701.12.05.00:

3.6606¢/kg less 0.020668¢/kg for each degree under 100 degrees (and fractions of a degree in proportion) but not less than 3.143854¢/kg

Trim before comparing, keep the verbatim text at its full length, parse only the patterns you recognize, and mark the rest as unparsed rather than zero. After a bulk pull, check that your copy still holds a General text as long as that one, and that no field has many different values ending at exactly the same length: a cut text is still a non-empty string, so nothing downstream notices.

6. Chapter 99 rates are sentences, and additionalDuties is empty

The guide: "many shipments may be eligible for different duty treatment under U.S. chapters 98 or 99." from=9903.88.01&to=9903.88.99 returned 66 records. Their General texts:

  1 × The duty provided in the applicable subheading plus 25%
  3 × The duty provided in the applicable subheading + 25%
 59 × The duty provided in the applicable subheading
  1 × The duty provided in the applicable subheading + 10%
  1 × The duty provided in the applicable subheading + 7.5%
  1 × The duty provided in the applicable subheading + 15%

The same addition is written + and plus (The duty provided in the applicable subheading plus 25%), so a pattern for one misses the other. The export also has an additionalDuties field. It was empty or null on all 66 of these records and on all 4,466 in chapters 61 through 64, so an empty value is not a statement that nothing applies. The guide says the change record "and chapters 98 and 99, should be consulted to locate any actions that may apply to specific goods."

7. Types drift, one key is misspelled, and JSON arrives as a download

Every record carried the same 12 keys, including both additionalDuties and addiitionalDuties, which was null on all of them. indent is a string, so compare it as a number. The rest change shape by row type: superior is "true" or null; footnotes is [] on 3,400, a filled array on 47 and null on 1,019; quotaQuantity is "" on 239 and null on 4,227. Normalize each field when you read it.

The JSON came back as content-type: application/octet-stream with content-disposition: attachment; filename=htsdata.json, so a client that checks the content type before parsing rejects valid data.

8. styles is required, and true returns markup

The guide lists styles as not required: "If value is “false” then all styling information such as underline, bold, italics, subscript, superscript is removed from the output." Leaving it out returned HTTP 400 (content-type application/json), with no message naming the parameter:

{ "timestamp" : 1789433394651, "status" : 400, "error" : "Bad Request", "path" : "/reststop/exportList" }

On the same three lines, styles=true returned <i>o</i>-Xylene, <i>m</i>-Xylene, <i>p</i>-Xylene and styles=false returned o-Xylene, m-Xylene, p-Xylene. Send styles=false unless you render the tags, and strip them before matching text.

9. The documented base URL answers 200 with a web page

The guide: "The base URL for all calls to the HTS REST API (HRA) is: http://hts.usitc.gov/api," with the export at /api/export. That URL, with the guide's own example parameters, returned HTTP 200, content-type text/html; charset=UTF-8, and a body starting <!DOCTYPE html>. That is the site's HTML, not data. A client that checks only the status code will try to parse it. Call /reststop/exportList, and confirm the body parses as a JSON array before using it.

The whole thing, runnable

Node 18 or later, no dependencies, no key. It reads robots.txt, pauses 3.5 seconds between calls, and prints counts, field shapes, HTS numbers and rate texts.

// hts-export.mjs -- traps in the USITC Harmonized Tariff Schedule export (hts.usitc.gov/reststop/exportList).
// Node 18+, no dependencies, no key. 11 requests including robots.txt, at least 3.5 s apart.
//   node hts-export.mjs
// Prints counts, field shapes, HTS numbers and rate texts. The schedule is a U.S. government work.
const HOST = 'https://hts.usitc.gov';
const UA = 'AervikLabs-docs/1.0 (developer guide research; https://aerviklabs.com)';
let pauseMs = 3500;
let last = 0;

async function get(path) {
  const wait = last + pauseMs - Date.now();
  if (wait > 0) await new Promise((r) => setTimeout(r, wait));
  last = Date.now();
  const res = await fetch(HOST + path, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(120_000) });
  const text = await res.text();
  console.log(`\n${new Date().toISOString()} GET ${path} -> ${res.status}`);
  return { status: res.status, type: res.headers.get('content-type'), disposition: res.headers.get('content-disposition'), url: res.url, text };
}
async function rows(path) {
  const r = await get(path);
  let json = null;
  try { json = JSON.parse(r.text); } catch { /* reported below */ }
  if (r.status !== 200 || !Array.isArray(json)) throw new Error(`not a JSON array: ${r.status} ${r.type} ${r.text.slice(0, 120)}`);
  return { ...r, rows: json };
}
const X = (range) => `/reststop/exportList?${range}&format=JSON&styles=false`;
const digits = (h) => String(h ?? '').replace(/\D/g, '');
const tally = (list, key) => list.reduce((o, x) => { const k = String(key(x)); o[k] = (o[k] ?? 0) + 1; return o; }, {});
const under = (rs, line) => rs.filter((x) => x.htsno.startsWith(line + '.')).map((x) => x.htsno);
const lastNumbered = (rs) => [...rs].reverse().find((x) => x.htsno)?.htsno;
const shape = (v) => (v === null ? 'null' : Array.isArray(v) ? (v.length ? 'array' : '[]') : v === '' ? '""' : typeof v);

// T0: robots.txt. Stop if it disallows the export path for every agent.
const robots = await get('/robots.txt');
const directives = robots.text.split(/\r?\n/).map((l) => l.replace(/#.*/, '').trim()).filter((l) => /^(user-agent|allow|disallow|crawl-delay)\s*:/i.test(l));
let forAll = false;
for (const l of directives) {
  const [k, ...v] = l.split(':'); const val = v.join(':').trim(); const key = k.trim().toLowerCase();
  if (key === 'user-agent') forAll = val === '*';
  else if (forAll && key === 'disallow' && val && ('/reststop/exportList'.startsWith(val) || '/api/export'.startsWith(val))) { console.log(`T0 robots.txt disallows ${val}; stopping`); process.exit(3); }
  else if (forAll && key === 'crawl-delay') pauseMs = Math.max(pauseMs, Number(val) * 1000 || 0);
}
console.log(`T0 robots.txt content-type: ${robots.type}; robots directives: ${directives.length}`);

// T1: `to` stops at the article number exactly as written.
const a = await rows(X('from=6402.99&to=6402.99.90'));
console.log(`T1 content-type: ${a.type}; content-disposition: ${a.disposition}`);
console.log(`T1 from=6402.99&to=6402.99.90 -> ${a.rows.length} records; last ${lastNumbered(a.rows)}; lines under 6402.99.90: ${under(a.rows, '6402.99.90').length}`);
const b = await rows(X('from=6402.99&to=6402.99.90.95'));
console.log(`T1 from=6402.99&to=6402.99.90.95 -> ${b.rows.length} records; lines under 6402.99.90: ${under(b.rows, '6402.99.90').length} ${JSON.stringify(under(b.rows, '6402.99.90'))}`);
const c = await rows(X('from=1701.12&to=1701.12.50'));
console.log(`T1 from=1701.12&to=1701.12.50 -> ${c.rows.length} records; htsno: ${JSON.stringify(c.rows.map((x) => x.htsno))}`);
const d = await rows(X('from=1701.12&to=1701.12.50.00'));
const dLast = d.rows.filter((x) => x.htsno).at(-1);
console.log(`T1 from=1701.12&to=1701.12.50.00 -> ${d.rows.length} records; last ${dLast.htsno} general ${JSON.stringify(dLast.general)}`);
// T2: a range can open on a row with no HTS number.
console.log(`T2 first record of from=1701.12: htsno ${JSON.stringify(c.rows[0].htsno)} indent ${JSON.stringify(c.rows[0].indent)} superior ${JSON.stringify(c.rows[0].superior)} description ${JSON.stringify(c.rows[0].description)}`);
// T5 (from this range): the longest General rate text.
const longest = d.rows.reduce((m, x) => (x.general.length > m.general.length ? x : m));
console.log(`T5 longest general in from=1701.12: ${longest.htsno} ${longest.general.length} chars ${JSON.stringify(longest.general)}`);

// Chapters 61-64 in one request, for T2-T5 and T7.
const s = await rows(X('from=6101&to=6499'));
const R = s.rows;
const numbered = R.filter((x) => x.htsno !== '');
console.log(`T2 from=6101&to=6499 -> ${R.length} records; chapters ${[...new Set(numbered.map((x) => digits(x.htsno).slice(0, 2)))].join(',')}; last ${lastNumbered(R)}`);
const blankNo = R.filter((x) => x.htsno === '');
console.log(`T2 htsno "": ${blankNo.length}; superior on those ${JSON.stringify(tally(blankNo, (x) => x.superior))}; superior on numbered ${JSON.stringify(tally(numbered, (x) => x.superior))}; unnumbered rows with a general rate: ${blankNo.filter((x) => x.general.trim()).length}`);

// T3: a blank General rate belongs to the nearest ancestor line that has one.
const lines = R.filter((x) => digits(x.htsno).length >= 8);
let blank = 0, found = 0, example = null;
R.forEach((x, i) => {
  if (digits(x.htsno).length < 8 || x.general.trim()) return;
  blank++;
  let ind = Number(x.indent);
  for (let j = i - 1; j >= 0; j--) {
    if (Number(R[j].indent) >= ind) continue;
    ind = Number(R[j].indent);
    if (R[j].general.trim()) { found++; if (!example && i - j >= 4) example = `${x.htsno} "${x.general}" -> ${R[j].htsno} ${JSON.stringify(R[j].general)} (${i - j} rows up)`; break; }
  }
});
console.log(`T3 numbered 8/10-digit rows: ${lines.length}; general "": ${blank}; rate on nearest ancestor: ${found}; 8-digit rows with general "": ${lines.filter((x) => digits(x.htsno).length === 8 && !x.general.trim()).length}`);
console.log(`T3 example: ${example}`);

// T4: 8-digit lines are listed alongside their 10-digit statistical suffixes.
const d8 = lines.filter((x) => digits(x.htsno).length === 8);
const d10 = lines.filter((x) => digits(x.htsno).length === 10);
const parents = new Set(d10.map((x) => x.htsno.slice(0, 10)));
const listedTwice = d8.filter((x) => parents.has(x.htsno)).length;
console.log(`T4 rows ${lines.length} = 8-digit ${d8.length} + 10-digit ${d10.length}; 8-digit rows with suffix rows: ${listedTwice}; distinct 8-digit lines: ${new Set(lines.map((x) => digits(x.htsno).slice(0, 8))).size}; rows with their own general rate: ${lines.filter((x) => x.general.trim()).length}; most detailed rows: ${d10.length + d8.length - listedTwice}`);
console.log(`T4 6109.10.00 suffix rows: ${under(R, '6109.10.00').length}`);

// T5: General is free text.
const kind = (t) => (/^free$/i.test(t) ? 'Free' : /^\d+(\.\d+)?%$/.test(t) ? 'percent' : /[¢$]/.test(t) && /%/.test(t) ? 'compound' : /[¢$]/.test(t) ? 'specific' : 'other');
const rates = R.map((x) => x.general).filter((g) => g.trim());
console.log(`T5 general texts: ${rates.length}; ${JSON.stringify(tally(rates, (g) => kind(g.trim())))}; distinct: ${new Set(rates).size}`);
for (const k of ['specific', 'compound', 'other']) console.log(`T5 ${k}: ${JSON.stringify([...new Set(rates.filter((g) => kind(g.trim()) === k))].slice(0, 4))}`);
console.log(`T5 leading/trailing whitespace: ${JSON.stringify(R.filter((x) => x.general !== x.general.trim()).map((x) => [x.htsno, x.general]))}`);

// T6: Chapter 99 rates are sentences, and additionalDuties stays empty.
const n = await rows(X('from=9903.88.01&to=9903.88.99'));
console.log(`T6 from=9903.88.01&to=9903.88.99 -> ${n.rows.length} records; additionalDuties non-empty: ${n.rows.filter((x) => x.additionalDuties).length} (chapters 61-64: ${R.filter((x) => x.additionalDuties).length}); addiitionalDuties non-null: ${[...n.rows, ...R].filter((x) => x.addiitionalDuties != null).length}`);
for (const [t, k] of Object.entries(tally(n.rows.filter((x) => x.general), (x) => x.general))) console.log(`T6 general x${k}: ${JSON.stringify(t)}`);

// T7: field types drift, and one key is misspelled.
const keys = [...new Set(R.flatMap((x) => Object.keys(x)))];
console.log(`T7 keys: ${JSON.stringify(keys)}`);
for (const k of keys) console.log(`T7 ${k} ${JSON.stringify(tally(R, (x) => (k in x ? shape(x[k]) : 'absent')))}`);

// T8: `styles` -- omitted, true and false, on the same range.
for (const st of [null, 'true', 'false']) {
  const r = await get(`/reststop/exportList?from=2902.41.00.00&to=2902.43.00.00&format=JSON${st === null ? '' : `&styles=${st}`}`);
  const label = `T8 styles=${st ?? '(omitted)'} -> ${r.status}`;
  if (r.status !== 200) { console.log(`${label}, content-type: ${r.type}, body: ${r.text.replace(/\s+/g, ' ')}`); continue; }
  console.log(`${label} ${JSON.stringify(JSON.parse(r.text).filter((x) => x.htsno).map((x) => x.description))}`);
}

// T9: the base URL in USITC's older external guide.
const old = await get('/api/export?format=json&from=0100&to=0200&styles=false');
console.log(`T9 /api/export -> ${old.status}, content-type: ${old.type}, final url: ${old.url}, body starts: ${JSON.stringify(old.text.trimStart().slice(0, 15))}`);

Its output on (counts change when USITC revises the schedule):

$ node --version
v24.20.0
$ node hts-export.mjs

2026-09-15T00:49:30.615Z GET /robots.txt -> 200
T0 robots.txt content-type: text/html; charset=UTF-8; robots directives: 0

2026-09-15T00:49:33.650Z GET /reststop/exportList?from=6402.99&to=6402.99.90&format=JSON&styles=false -> 200
T1 content-type: application/octet-stream; content-disposition: attachment; filename=htsdata.json
T1 from=6402.99&to=6402.99.90 -> 82 records; last 6402.99.90; lines under 6402.99.90: 0

2026-09-15T00:49:37.155Z GET /reststop/exportList?from=6402.99&to=6402.99.90.95&format=JSON&styles=false -> 200
T1 from=6402.99&to=6402.99.90.95 -> 87 records; lines under 6402.99.90: 4 ["6402.99.90.05","6402.99.90.35","6402.99.90.65","6402.99.90.95"]

2026-09-15T00:49:40.609Z GET /reststop/exportList?from=1701.12&to=1701.12.50&format=JSON&styles=false -> 200
T1 from=1701.12&to=1701.12.50 -> 4 records; htsno: ["","1701.12","1701.12.05.00","1701.12.10.00"]

2026-09-15T00:49:44.203Z GET /reststop/exportList?from=1701.12&to=1701.12.50.00&format=JSON&styles=false -> 200
T1 from=1701.12&to=1701.12.50.00 -> 5 records; last 1701.12.50.00 general "35.74¢/kg"
T2 first record of from=1701.12: htsno "" indent "1" superior "true" description "Raw sugar not containing added flavoring or coloring matter:"
T5 longest general in from=1701.12: 1701.12.05.00 135 chars "3.6606¢/kg less 0.020668¢/kg for each degree under 100 degrees (and fractions of a degree in proportion) but not less than 3.143854¢/kg"

2026-09-15T00:49:47.945Z GET /reststop/exportList?from=6101&to=6499&format=JSON&styles=false -> 200
T2 from=6101&to=6499 -> 4466 records; chapters 61,62,63,64; last 6406.90.90.00
T2 htsno "": 1019; superior on those {"true":1019}; superior on numbered {"null":3447}; unnumbered rows with a general rate: 0
T3 numbered 8/10-digit rows: 3218; general "": 2325; rate on nearest ancestor: 2325; 8-digit rows with general "": 0
T3 example: 6101.90.90.60 "" -> 6101.90.90 "5.7%" (4 rows up)
T4 rows 3218 = 8-digit 513 + 10-digit 2705; 8-digit rows with suffix rows: 513; distinct 8-digit lines: 893; rows with their own general rate: 893; most detailed rows: 2705
T4 6109.10.00 suffix rows: 14
T5 general texts: 893; {"percent":742,"compound":63,"Free":66,"other":21,"specific":1}; distinct: 184
T5 specific: ["5.5¢/kg"]
T5 compound: ["38.6¢/kg + 10%","61.7¢/kg + 16%","55.9¢/kg + 16.4%","64.4¢/kg + 18.8%"]
T5 other: ["The rate applicable to each garment in the ensemble if separately entered","The rate applicable to each garment in the ensemble if entered separately","The rate applicable to each garment in the in the ensemble if separately entered"]
T5 leading/trailing whitespace: [["6401.10.00.00","37.5% "],["6401.92.90","37.5% "],["6401.99.30.00","25% "],["6401.99.60.00","37.5% "],["6402.91.05.00","6% "],["6402.91.50","37.5% "],["6402.99.32","20% "]]

2026-09-15T00:49:51.174Z GET /reststop/exportList?from=9903.88.01&to=9903.88.99&format=JSON&styles=false -> 200
T6 from=9903.88.01&to=9903.88.99 -> 66 records; additionalDuties non-empty: 0 (chapters 61-64: 0); addiitionalDuties non-null: 0
T6 general x1: "The duty provided in the applicable subheading plus 25%"
T6 general x3: "The duty provided in the applicable subheading + 25%"
T6 general x59: "The duty provided in the applicable subheading"
T6 general x1: "The duty provided in the applicable subheading + 10%"
T6 general x1: "The duty provided in the applicable subheading + 7.5%"
T6 general x1: "The duty provided in the applicable subheading + 15%"
T7 keys: ["htsno","indent","description","superior","units","general","special","other","footnotes","quotaQuantity","additionalDuties","addiitionalDuties"]
T7 htsno {"string":3447,"\"\"":1019}
T7 indent {"string":4466}
T7 description {"string":4466}
T7 superior {"null":3447,"string":1019}
T7 units {"[]":1761,"array":2705}
T7 general {"\"\"":3573,"string":893}
T7 special {"\"\"":3639,"string":827}
T7 other {"\"\"":3573,"string":893}
T7 footnotes {"[]":3400,"null":1019,"array":47}
T7 quotaQuantity {"\"\"":239,"null":4227}
T7 additionalDuties {"\"\"":239,"null":4227}
T7 addiitionalDuties {"null":4466}

2026-09-15T00:49:54.733Z GET /reststop/exportList?from=2902.41.00.00&to=2902.43.00.00&format=JSON -> 400
T8 styles=(omitted) -> 400, content-type: application/json, body: { "timestamp" : 1789433394651, "status" : 400, "error" : "Bad Request", "path" : "/reststop/exportList" }

2026-09-15T00:49:58.192Z GET /reststop/exportList?from=2902.41.00.00&to=2902.43.00.00&format=JSON&styles=true -> 200
T8 styles=true -> 200 ["<i>o</i>-Xylene","<i>m</i>-Xylene","<i>p</i>-Xylene"]

2026-09-15T00:50:01.679Z GET /reststop/exportList?from=2902.41.00.00&to=2902.43.00.00&format=JSON&styles=false -> 200
T8 styles=false -> 200 ["o-Xylene","m-Xylene","p-Xylene"]

2026-09-15T00:50:05.094Z GET /api/export?format=json&from=0100&to=0200&styles=false -> 200
T9 /api/export -> 200, content-type: text/html; charset=UTF-8, final url: https://hts.usitc.gov/api/export?format=json&from=0100&to=0200&styles=false, body starts: "<!DOCTYPE html>"
exit 0

What USITC says about using it

From the guide: "Other resources provided by the Commission, such as the online trade Data Web or the HTS search tool, can assist in classifying goods in trade, but importers should rely primarily on the legally authoritative HTS itself in preparing Customs documents." And on footnotes: they "are merely informational, their presence or absence has no legal effect."

What this does not cover

Which line a product belongs in, what duty applies to a shipment, the chapter and U.S. notes that are not in the export, and the CSV and Excel formats beyond their names. This page is about reading the export correctly, not classification or customs advice.

Aervik Labs operates the Landed Cost API, which is built on a snapshot of this export. Our 2026-09-02 snapshot carried each General rate text longer than 40 characters cut to 40, and each description longer than 180 characters cut to 180, and our own tests did not notice; the script that pulls our snapshot now refuses a copy that fails the length check above. The duty-spread measurement records the correction.

Last updated . Corrections: see About.