Why many 10-digit HTS codes have a blank duty rate in the USITC API
When an 8-digit line is split, its rate stays on that row, which a request for the 10-digit code does not return

Start with the most extreme case. On 2026-09-14 I asked the USITC's public Harmonized Tariff Schedule API for one line, 6109.10.00.04 (men's all-white cotton T-shirts), with exportList?from=6109.10.00.04&to=6109.10.00.04&format=JSON. It answered HTTP 200 with that line, and its rate columns were empty strings:
{
"htsno" : "6109.10.00.04",
"indent" : "3",
"description" : "T-shirts, all white, short hemmed sleeves, ... without pockets, trim or embroidery (352)",
"units" : [ "doz.", "kg" ],
"general" : "",
"special" : "",
"other" : "",
"footnotes" : [ ],
...
}
(Description shortened; everything else is as received.) The duty rate for that line is not zero and not missing from the schedule. In that response it was 16.5%, written on a different row that this request did not return.
If you came here to get the base US import duty rate for an HTS code from the USITC API, this is the whole answer:
- Request the whole 4-digit heading, not the code:
https://hts.usitc.gov/reststop/exportList?from=6109&to=6110&format=JSON&styles=false. Settoto the next heading;from=6109&to=6109returns only the heading's title row. - Find your line. If its
generalis empty, use the nearest 8-digit line above it whose number equals the first eight digits of yours. That row holds the General, Special and Column 2 rates. - Keep your line's own footnotes, and keep the parent's footnotes in a separate field instead of merging or dropping them. A parent footnote can apply to only some of the lines under it, or to all of them.
- Rebuild the description from the
indentlevels. The line's own text can be just"Men's (338)". - Parse the rate text only when every part of it is readable. Keep the text and return no number when it says something like
"The duty provided in the applicable subheading + 25%".
That gives you the rates printed in the schedule for that line. It does not tell you whether Section 301 or any other chapter 99 duty applies to your goods; that is a separate problem, covered below. Unless noted, the figures on this page come from 14 requests to hts.usitc.gov made on 2026-09-14 between 15:41 and 15:46 UTC, when the API reported the current edition as "2026HTSRev18". Tariff rates change, sometimes several times a year, so treat every number here as a dated observation, not as the rate on the day you read this.
120 of 148 ten-digit lines in four headings had no rate
The single-line request is not unusual. I exported four whole headings and counted the 10-digit lines whose general column was empty:
| Heading | 10-digit lines | general empty |
|---|---|---|
| 6109 (T-shirts, singlets, tank tops, knitted) | 35 | 35 |
| 0901 (coffee) | 31 | 29 |
| 8541 (diodes, transistors, photovoltaic cells) | 36 | 32 |
| 2931 (other organo-inorganic compounds) | 46 | 24 |
The pattern behind the table is how the schedule is built. The legal rate is set at eight digits; the last two digits are a statistical suffix for reporting. When an 8-digit subheading is split into several statistical lines, the rate sits on the 8-digit row and every 10-digit row under it is blank. When it is not split, there is no separate 8-digit row, and the single 10-digit line ending in 00 carries the rate itself. That is why the lines with a rate in the table are ones like 0901.90.20.00 ("1.5¢/kg") and 8541.41.00.00 (LEDs, "Free").
The 10-digit number is the one importers put on entry paperwork, so it is the number people look up. Code that reads general from the row it asked for got an empty string for 120 of the 148 lines above, and the dangerous reading of an empty string is "no duty".
The rate sits on the 8-digit row above, so request the heading
This is the row that holds the rate for all fourteen lines under 6109.10.00, from the heading export:
{"htsno":"6109.10.00","indent":"1","description":"Of cotton",
"general":"16.5%","special":"Free (AU,BH,CL,CO,IL,JO,KR,MA,OM,P,PA,PE,S,SG)","other":"90%", ...}
Two details of the API decide how you fetch it. First, a range request with the same from and to heading returns one row: from=0901&to=0901 came back as 386 bytes containing only the title row of heading 0901, with every rate column empty. from=0901&to=0902 returned 56 rows, the whole coffee heading plus the first row of 0902. Second, a request for the 10-digit code alone does not include its 8-digit parent, as the opening example shows. It returned the wording row directly above ("Men's or boys':") and the line itself, nothing else.
So fetch the heading once, then walk it in order. This runs in Node 18 or later with no dependencies; I ran it against the saved responses for this article, and it found a rate for every one of the 148 ten-digit lines in the table above:
function lookup(rows, code) {
const digits = (s) => String(s ?? '').replace(/\./g, '');
const notes = (row) => (row.footnotes ?? []).map((f) => ({ columns: f.columns ?? [], text: String(f.value).trim() }));
const want = digits(code);
const path = [];
let rateLine = null;
for (const r of rows) {
const d = digits(r.htsno);
const depth = Number(r.indent);
path.length = depth;
path[depth] = String(r.description ?? '').trim().replace(/:$/, '');
if (d.length === 8 && String(r.general ?? '').trim()) rateLine = r;
if (d !== want) continue;
const own = String(r.general ?? '').trim();
const parent = !own && rateLine && digits(rateLine.htsno) === want.slice(0, 8) ? rateLine : null;
if (!own && !parent) return { htsno: r.htsno, general: null };
const src = parent ?? r;
return {
htsno: r.htsno,
description: path.filter(Boolean).join(' > '),
general: String(src.general).trim(),
special: String(src.special ?? '').trim() || null,
other: String(src.other ?? '').trim() || null,
rateFrom: parent ? parent.htsno : r.htsno,
footnotes: notes(r),
parentFootnotes: parent ? notes(parent) : [],
};
}
return null;
}
Given the heading 6109 export and "6109.10.00.04", it returns general: "16.5%", other: "90%" and rateFrom: "6109.10.00". Given "0901.90.20.00" it returns "1.5¢/kg" from the line itself.
The check on the first eight digits matters. Without it, a 10-digit line whose own parent has no rate would silently take the rate of whatever 8-digit line happened to come before it. When nothing matches, the function returns general: null rather than guessing, and rateFrom keeps the fact that the number was carried down visible to whoever uses it.
Two smaller points. The export's general can carry whitespace: 2931.90.90 came back as "3.7% " with a trailing space, so trim before comparing. And a range with nothing in it is a valid answer, not a failure: from=0077&to=0078 returned HTTP 200 with the three bytes [ ].
Parent footnotes belong in their own field
It is tempting to copy the whole parent row onto the child. For rates that is what the schedule means. For footnotes it can reverse the meaning, and dropping them loses information. This is one of the footnotes on 2931.90.90 (rate 3.7%), received on 2026-09-14:
"Pursuant to U.S. note 20(g) to subchapter III, chapter 99, heading 9903.88.04 covers
certain goods of this rate line, as indicated by the footnote; please note that statistical
reporting descriptions without a footnote are not covered by that heading."
Under that 8-digit row, 2931.90.90.10, .21, .25 and .29 carry the same footnote on their own rows. 2931.90.90.30, .35, .40 and .52 have an empty footnotes array. By the footnote's own words, the second group is not covered by 9903.88.04. Copy the parent's footnote onto every child and your data claims the opposite for four lines.
Dropping the parent's footnotes is wrong in the other direction. 2931.49.00, 2931.59.00 and 2931.90.90 also carry the footnote "See 9903.90.08.", attached to their Column 2 rate ("columns":["other"]). None of the 24 statistical lines under those three rows repeats it. Keep only the line's own footnotes and that pointer to chapter 99 disappears for those lines. So the function above returns the line's own footnotes and the parent's footnotes as two separate lists, each with the columns it applies to, and leaves the reading of the note to a person.
"Men's (338)" appears twice in one heading
The description field is only the text printed on that row of the schedule. In heading 6109 the exact string "Men's (338)" appears on two different lines, 6109.10.00.12 and 6109.10.00.18, and so do "Boys' (338)", "Women's (339)", "Girls' (339)" and the four equivalents with codes 638 and 639. The coffee heading has "Certified organic" and "Other" over and over.
The meaning is in the rows above, tied together by indent. Many of those rows have no number at all: "htsno" : "", with text such as "Other T-shirts:" or "Arabica:". Filter out rows without a number before walking the heading and the descriptions lose their middle levels. Walking all rows, the function above produces:
6109.10.00.12 T-shirts, singlets, tank tops and similar garments, knitted or crocheted
> Of cotton > Men's or boys' > Other T-shirts > Men's (338)
6109.10.00.18 T-shirts, singlets, tank tops and similar garments, knitted or crocheted
> Of cotton > Men's or boys' > Tank tops and other singlets > Men's (338)
Indent levels can skip: in heading 8541 the 8-digit row 8541.60.00 is at indent 1 and its first five statistical lines are at indent 3, with an unnumbered wording row ("Quartz designed for operating frequencies of") at indent 2 between them. Truncating the path to the current depth before writing each row, as the function does, keeps an old sibling's text from leaking into the next line.
Rate text: parse it fully or keep it as text
The rate columns are text written for people. In the responses for this article they took these shapes, among others:
"Free","16.5%","1.5¢/kg", and elsewhere in the schedule compound forms such as"33.9¢/kg + 5.1%"and dollar amounts such as"$1.104/kg + 14.9%".- A Special column that lists trade programs in parentheses, with stray spaces inside the list:
"Free (A+,AU,BH,CL,CO,D,E, IL,JO,KR,MA,OM,P,PA,PE,S, SG)"on0901.90.20.00. Split on commas without trimming and" IL"will not match"IL". The column can also hold more than one clause with no separator between them, a free list followed by a per-unit rate for a different program. - In chapter 99, often sentences. Of the 637 numbered rows in the export of heading 9903, 245 had a
generalstarting"The duty provided in the applicable subheading +", and 204 said only"The duty provided in the applicable subheading". One row,9903.88.01, said"The duty provided in the applicable subheading plus 25%", with the word instead of the symbol, and9903.89.55said"The duty provided inthe applicable subheading+ 25%". Another 95 rows printed only a rate such as"15%"or"Free", 64 were empty and 26 said"No change".
The failure to avoid is a parser that reads the part it recognises and ignores the rest. Pull 25% out of "applicable subheading + 25%" and store it as the rate, and the base duty disappears. Read "The duty provided in the applicable subheading" as "no percentage, so zero", and an ordinary line becomes duty free. A parser that splits only on + also treats the "plus 25%" row differently from the 245 others. And a bare "15%" on a chapter 99 row parses cleanly, but it is the additional duty on its own, not the total, so store chapter 99 numbers apart from the base rate. A safer rule is to accept the text only when every part matches a known form:
function parseRate(text) {
const t = String(text ?? '').trim();
if (!t) return null;
if (/^free$/i.test(t)) return { percent: 0, perUnit: [], unparsed: null };
const out = { percent: null, perUnit: [], unparsed: null };
for (const part of t.split('+').map((p) => p.trim())) {
let m;
if ((m = part.match(/^(\d+(?:\.\d+)?)%$/))) out.percent = Number(m[1]);
else if ((m = part.match(/^(\d+(?:\.\d+)?)¢\/(.+)$/))) out.perUnit.push({ cents: Number(m[1]), per: m[2] });
else return { percent: null, perUnit: [], unparsed: t };
}
return out;
}
It turns "33.9¢/kg + 5.1%" into 5.1 percent plus 33.9 cents per kg, and returns all three chapter 99 sentences above, and the dollar form it does not know, as unparsed with no number. Extend the forms it accepts as you meet them; do not loosen the rule that one unreadable part voids the numbers.
What the line's JSON does not tell you: additional duties
The API does not give you this from the line. The rows for 6109.10.00 and 8541.10.00 have empty footnotes arrays, and neither 6109.10 nor 8541.10 appears anywhere in the 679 rows of the heading 9903 export. The 9903.88 rows describe their scope by reference instead, for example "articles the product of China, as provided for in U.S. note 20(e) to this subchapter and as provided for in the subheadings enumerated in U.S. note 20(f)" on 9903.88.03. The lists of subheadings those notes enumerate were not in any of the JSON responses I received. Other chapter 99 rows do name ordinary subheadings in their description (70 of the 679 rows did), so a text search of heading 9903 can find some links, but not finding your number there proves nothing.
Some lines do point outward. 8541.41.00.00 (LEDs) has a footnote on its Column 2 rate reading "See 9903.90.08.", and the 2931.90.90 footnote above names 9903.88.04. But an empty footnote array is not evidence that no chapter 99 duty applies, and whether one applies can depend on the country of origin and on exclusions that change over time.
So keep two answers apart in your code and in whatever you show users: the rates printed on the line, which the steps above give you, and any additional duties, which need the chapter 99 notes and current notices from USTR and CBP, and for real shipments a licensed customs broker. Classification itself is a legal determination. Nothing on this page is legal or customs advice.
Two requests that look right and return the wrong thing
Keyword search matches any of your words. /reststop/search?keyword=t-shirts returned 405 rows in 115 headings. The first was 1212.91.00.00, "Sugar beet". Its unit is t (metric tons), and 282 of the 405 rows had t as a unit. Only 54 rows had shirt in their own description. The search also returned just some lines under a matching wording row: for heading 6109 it returned six rows, including 6109.10.00.12 ("Men's (338)") but not 6109.10.00.14 ("Boys' (338)") directly below it. Use search to find candidate headings, then export those headings and filter the rebuilt descriptions yourself. The search rows also have a different shape from the export rows, with extra fields such as statisticalSuffix and selector.
Endpoints from some guides may be gone. One developer guide on the first page of results for this topic uses paths under /reststop/api/details/. On 2026-09-14 both returned HTTP 404 with a JSON body:
{
"timestamp" : 1789400497202,
"status" : 404,
"error" : "Not Found",
"path" : "/reststop/api/details/htsnoJSON/0101.30.00.00"
}
Paths outside /reststop/ fail more quietly. https://hts.usitc.gov/api/search?keyword=coffee, a path I tried by guessing, returned HTTP 200 with 11,051 bytes of HTML, the site's own page titled Harmonized Tariff Schedule. Check that the body starts with [ or { before parsing, and never record an HTML answer as "no tariff lines".
If you would rather not maintain the walk-up code
These rules are what our Apify Actor, US Tariff Scraper - US Import Tariff API, HTS Codes & Duty, implements. I checked each point below against its published source code on 2026-09-14, and ran its parsing functions locally on the same responses used above. It exports the whole heading for each HTS number you give (4, 6, 8 or 10 digits) and returns 8- and 10-digit lines with the rate carried down from the 8-digit parent only when the first eight digits match, recording the parent in rateInheritedFrom. Each line keeps its own footnotes, so 2931.90.90.10 comes back with the 9903.88.04 footnote and 2931.90.90.30 without it. It does not return the parent's footnotes or the column a footnote applies to: 2931.49.00.05 comes back without the parent's "See 9903.90.08.", so if you need those, also request the 8-digit number (here 2931.49.00), which returns that row with its own footnotes. Each row has fullDescription rebuilt from the indent levels, the rate as text and as numbers (generalAdValoremPercent, generalSpecificRates) with every number left null and the text in generalUnparsed when any part cannot be read, and specialFreePrograms as a sorted list of the program codes that enter free. A keyword search reads the headings the USITC search touched (up to 150) and returns only lines whose rebuilt description contains every word. An HTML answer becomes an error row, not an empty result.
It does not do the part the previous section says the JSON cannot do. It does not decide which Section 301, 232 or other chapter 99 duties apply to a line, and it does not calculate the total duty for a country of origin. If you request chapter 99 numbers directly, lines whose text refers to the applicable subheading come back with the text kept and every number null, while lines that print only a rate such as "15%" or "Free" are parsed, and that number is the chapter 99 duty on its own, not the total. At the time of writing it charges per tariff line delivered ($0.015 each on Apify's free plan, less on paid plans); rows that report a problem or say a number has no lines are not charged.
Published by GRAMSHIFT. All figures come from 14 GET requests to hts.usitc.gov on 2026-09-14 between 15:41 and 15:46 UTC, sent one at a time at least seven seconds apart from a single machine, with currentRelease reporting "2026HTSRev18". Each figure rests on that single response; none were repeated. The four headings in the table were chosen as examples, not sampled, so the share of blank 10-digit lines across the whole schedule was not measured. The compound rate, dollar-amount and multi-clause Special column examples in the rate section were recorded from the schedule on 2026-09-01 while the Actor was being built and were not re-requested for this article. The developer guide is not named because its author may since have updated it. The chapter 99 notes and CBP guidance were not consulted for this article, and no statement here says which additional duties apply to any product. This page is not affiliated with the USITC, USTR or CBP. On the use of AI: the measurement scripts, the sample code, this article and the Actor were written by Claude, an AI model, working for GRAMSHIFT; the figures were compared against the saved responses, and both code samples were run against them.