# US Tariff Schedule Scraper - HTS Codes & Duty Rates (`dami_studio/hts-customs-duty-scraper`) Actor

One row per line of the US Harmonized Tariff Schedule: HTS code, description, unit of quantity, general rate of duty, special rates with the countries they cover, and the column 2 rate. Every row names the edition it came from. Keyless, no browser, no account.

- **URL**: https://apify.com/dami\_studio/hts-customs-duty-scraper.md
- **Developed by:** [Dami's Studio](https://apify.com/dami_studio) (community)
- **Categories:** Business, E-commerce, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 tariff line scrapeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## US Harmonized Tariff Schedule Scraper: HTS codes, duty rates, units of quantity

One row per published line of the US Harmonized Tariff Schedule: the HTS code, what it covers, the
unit of quantity, the general rate of duty, the special rates and the countries they apply to, and
the column 2 rate. Read straight from the USITC's own JSON, keyless, no browser, no account.

**Read this part first, because it is the part that can hurt you.** The schedule is revised several
times a year, and a rate you pulled in March may not be the rate in force in September. So every row
names the edition it came from (`2026HTSRev19`, say), and the run reads that edition name off the
publisher before it reads a single line. Only the live edition is published, so this Actor can only
give you the live one. If you are running this on a schedule and a revision landing overnight would
quietly change your numbers, put the edition you expect in `requireEdition` and the run will return
nothing rather than hand you a different one.

And the other thing worth knowing before you buy anything: **an empty duty rate does not mean
duty-free.** More on that below, because it is the single easiest way to read this data wrong.

This Actor returns tariff lines as the USITC published them. It does not tell you how to classify
goods, and nothing in the output is advice.

### What one row looks like

A real row from a real run:

```json
{
  "ok": true,
  "charged": true,
  "recordType": "tariffLine",
  "htsEdition": "2026HTSRev19",
  "htsEditionTitle": "Revision 19 (2026)",
  "htsno": "6101.20.00.10",
  "lineLevel": "statistical",
  "chapter": "61",
  "heading": "6101",
  "subheading": "6101.20",
  "headingDescription": "Men's or boys' overcoats, carcoats, capes, cloaks, anoraks (including ski-jackets), windbreakers and similar articles, knitted or crocheted, other than those of heading 6103:",
  "indent": 2,
  "description": "Men's (334)",
  "descriptionPath": "Men's or boys' overcoats, carcoats, capes, cloaks, anoraks (including ski-jackets), windbreakers and similar articles, knitted or crocheted, other than those of heading 6103: > Of cotton > Men's (334)",
  "unitOfQuantity": "doz., kg",
  "units": ["doz.", "kg"],
  "generalRate": null,
  "specialRate": null,
  "column2Rate": null,
  "generalRateEffective": "15.9%",
  "specialRateEffective": "Free (AU,BH, CL,CO,IL,JO,KR, MA,OM, P,PA,PE,S,SG)",
  "column2RateEffective": "50%",
  "ratesAreInherited": true,
  "rateSource": "6101.20.00",
  "dutyRateResolved": true,
  "rateNote": null,
  "additionalDuties": null,
  "footnotes": [],
  "matchedQuery": "6101.20.00",
  "htsUrl": "https://hts.usitc.gov/search?query=6101.20.00.10",
  "scrapedAt": "2026-09-20T10:26:26.276Z"
}
```

Look at that row again. `generalRate` is `null`, because the printed line for `6101.20.00.10` genuinely has
nothing in the general column. `generalRateEffective` is `15.9%`, and `rateSource` says where that
came from: the line above it, `6101.20.00`.

That is how the schedule is printed. A rate is stated once and the statistical lines underneath it
inherit it. In the current edition, 11,836 of the 29,860 numbered lines carry no rate of their own.
If you read the raw export and treat a blank as zero, you will price roughly two lines in five at no
duty when they are dutiable.

So each row gives you both readings, and keeps them apart:

| Field | What it is |
|---|---|
| `generalRate`, `specialRate`, `column2Rate` | exactly what **this** line printed. `null` when it printed nothing |
| `generalRateEffective`, `specialRateEffective`, `column2RateEffective` | the rate that applies, copied verbatim from wherever it was stated |
| `rateSource` | the HTS code that string was read off |
| `ratesAreInherited` | `true` when it came from a line above rather than this one |
| `dutyRateResolved` | `false` when no line anywhere above prints a general rate |
| `rateNote` | why, in plain words, when `dutyRateResolved` is `false` |

Rates are shipped as strings, byte for byte as published, and are never parsed into numbers. You
will see `15.9%`, `Free`, `1¢/kg`, `68¢/head`, `3.7¢/kg on drained weight`, and in chapter 99 things
like `The duty provided in the applicable subheading + 25%`. Turning that into a number is a
judgement call about your goods, and it is not one this Actor is going to make on your behalf.

### Input

Four ways to pick lines, and they combine:

```json
{
  "chapters": ["61", "62"],
  "hsCodes": ["8471", "6101.20.00.10"],
  "searchTerms": ["olive oil"],
  "includeLines": "withDutyRate",
  "maxItems": 500
}
```

| Field | What it does |
|---|---|
| `chapters` | Chapters to read, as numbers. `61` is knitted apparel, `84` machinery, `87` vehicles, `99` the temporary modifications. Ranges work: `61-63`. Chapter 77 is reserved in the Harmonized System and holds nothing; ask for it and the run says so instead of returning silence |
| `hsCodes` | Codes at any depth. `8471` gives the whole heading, `8471.30` the subheading, `6101.20.00.10` that one line. Dots optional. The run reads the chapter each code sits in and keeps the lines starting with it |
| `searchTerms` | Words looked up in the schedule's own index. The codes it returns come back as full rows with their rates and description path. The index is broad (searching `cotton shirt` also returns cotton seed), so read the descriptions rather than trusting the match |
| `allChapters` | Reads every chapter, 01 to 99, in order. About 29,900 numbered lines. Raise `maxItems` to match or you get the first slice |
| `includeLines` | `all` keeps every numbered line. `withDutyRate` drops the 4- and 6-digit structure lines and keeps the 8- and 10-digit ones. `statisticalOnly` keeps just the 10-digit lines, the codes that go on an entry |
| `maxItems` | How many rows to return. Ceiling 40,000 |
| `requireEdition` | Optional. Name an edition and the run refuses to return anything if the live schedule is a different one |
| `proxyUrls` | Leave empty unless you want traffic to leave through servers you already pay for |

Run it with empty input and you get one free sample row showing the output shape. Nothing is charged
for that.

### Output

Every row carries `htsEdition` and `htsEditionTitle`, so a dataset is self-dating and two pulls
taken a month apart can be told apart without keeping notes.

| Field | Notes |
|---|---|
| `htsno` | the code as published, dotted |
| `lineLevel` | `heading` (4 digit), `subheading` (6), `tariffLine` (8), `statistical` (10) |
| `chapter`, `heading`, `subheading` | the code's own leading digits, split out for grouping |
| `headingDescription` | the 4-digit heading this line sits under |
| `description` | the line's own text, which on a deep line is a fragment like `Men's (334)` |
| `descriptionPath` | the whole branch joined with `>`, which is what makes a deep line readable on its own |
| `unitOfQuantity` | the published unit, as text. Many lines carry two, like `doz., kg` |
| `units` | the same thing as an array, if you would rather split it yourself |
| `additionalDuties` | present on 512 lines in the current edition, all of them safeguard provisions in chapter 99 |
| `footnotes` | the publisher's own footnote objects, untouched |
| `matchedQuery` | which of your codes or search terms picked this row |
| `htsUrl` | the line on hts.usitc.gov, for when someone wants to check |

Three kinds of row land in the dataset and they are easy to tell apart:

- **Real rows** carry `"charged": true` and `"recordType": "tariffLine"`. One billed event each.
- **The sample row** carries `"_sample": true` and `"charged": false`. There is exactly one, only
  when the input picked nothing.
- **Diagnostic rows** carry `"_diagnostic": true`, `"charged": false` and an `errorCode`. A chapter
  that came back empty, a code that was not a code, a search term that matched nothing, a request the
  publisher refused. None of them is billed.

Filter on `charged == true` and the row count equals the number of events you paid for. The dataset
is its own invoice.

### Price

**$2.00 per 1,000 tariff lines**, plus a **$0.0005 start fee per run**. Flat on every plan, free
or paid, with no volume tiers and no minimum spend. One event per tariff line written to the dataset is
the only thing metered per row.

Nothing else is charged. The sample row is free. Diagnostic rows are free. A chapter that comes back
empty is free, a search term that matches nothing is free, and a request the publisher refuses is
free. A run that finds nothing bills you for no rows at all.

A full chapter runs a few hundred lines, so most single-chapter pulls land under half a cent.

### How it works

The USITC publishes the schedule as JSON from its own host. No key, no session, no anti-bot, no
browser. The run reads one chapter per request, which keeps its memory flat whether you asked for
one chapter or all ninety-eight, and holds the rows from a single chapter at a time rather than the
whole schedule.

Before any of that it reads the publisher's edition endpoint, so the edition on every row is the one
that was live at the moment the run started rather than something assumed. It reads it again at the
end, and if a revision landed while the run was going you get a free row saying so. A whole-schedule
pull takes the better part of an hour, which is long enough for that to happen to somebody.

Each chapter is checked before it is used. The publisher answers a range it does not recognise with
HTTP 200 and an empty list. A status code cannot see that, so the run checks that lines came back
and that they belong to the chapter that was asked for. Anything that fails produces a free
diagnostic row and no charge.

The inheritance walk carries one more check that is worth knowing about. When a rate is taken from a
line above, that line's HTS code has to be a genuine leading prefix of the row's own code. In the
current edition all 11,836 inheritances satisfy that. If one ever does not, the row ships with no
effective rate and says so, rather than showing you a number from somewhere else in the tree.

### Limitations: what this does not do

- **It does not classify anything.** It returns the schedule as published. Which line your goods
  fall under is a question this Actor has no opinion about.
- **It does not give advice**, customs, legal or otherwise, and nothing in the output should be
  read as any.
- **Only the current edition.** The publisher serves the live revision and nothing else, so there is
  no way to ask this for last year's rates. If you need history, pull on a schedule and keep what you
  get. `htsEdition` on every row is what makes that work.
- **No general notes, section notes or chapter notes.** Those are the prose parts of the schedule and
  they are published as documents, not as data. Chapter *titles* are not returned either; you get the
  chapter number and the heading text.
- **No Canadian, EU or other national tariff.** This is the US schedule. Canada publishes its
  Customs Tariff as chapter PDFs, with a different set of rate columns, so it is not something that
  can be folded into these rows honestly.
- **No duty calculation.** No landed cost, no freight, no trade-remedy stacking, no rules of origin.
  The rate strings come to you as text precisely so nobody has to trust arithmetic they did not do.
- **Chapters 98 and 99 are returned, but 1,169 of their lines resolve to no general rate at all.**
  That is how they are printed: the treatment comes from a US note attached to the subchapter rather
  than from a rate column. Those rows say `dutyRateResolved: false` and carry a `rateNote` explaining
  it, and they are still charged, because they are still the lines you came for.
- **The search index is the publisher's, not ours.** It is broad and it ranks oddly. It is good for
  finding candidates and bad for trusting blindly.
- **A whole-schedule pull is slow.** Rows land at roughly eleven a second, so all 29,860 takes about
  three quarters of an hour. Reading the source is not the slow part. Writing the rows out one at a
  time is. A single chapter is a couple of minutes at most, and most people want a chapter.

### FAQ

**What is the HTS?**
The Harmonized Tariff Schedule of the United States: the list of codes every imported good is
classified under, and the duty rate attached to each one. It is published by the US International
Trade Commission.

**Why does a row have a rate when the line itself is blank?**
Because the schedule states a rate once and lets the lines beneath it inherit. `generalRate` shows
what the line itself printed and `generalRateEffective` shows what applies, with `rateSource` naming
where it was read from.

**How many lines are there?**
29,860 numbered lines in edition 2026HTSRev19: 961 four-digit headings, 2,093 six-digit subheadings,
6,857 eight-digit lines and 19,949 ten-digit statistical lines. Set `allChapters` and raise
`maxItems` to pull the lot.

**Can I get the Section 232 and 301 duties?**
Chapter 99 holds the temporary modifications, and it is included. Read it with
`{"chapters": ["99"]}`. Be aware those lines often state the treatment as a sentence rather than a
percentage, and they come to you as that sentence.

**Which edition will I get?**
Whichever is live when the run starts. It is named on every row. Use `requireEdition` if you need
the run to refuse anything else.

**Do I need an API key for the source?**
No. The schedule is public and keyless. You need nothing beyond this Actor.

**What happens if a chapter fails?**
You get one free diagnostic row naming the chapter and saying what went wrong, the run carries on to
your other chapters, and you are not billed for the one that failed.

**Can I run it on a schedule?**
Yes. Nothing is held between runs. Diff on `htsno` plus `generalRateEffective` to see what moved,
and on `htsEdition` to see when a revision landed.

**Is the output stable enough to use as a key?**
`htsno` is stable within an edition and mostly stable across them, but lines do get added, split and
withdrawn at each revision. Keep `htsEdition` alongside it.

# Actor input Schema

## `chapters` (type: `array`):

Chapters of the tariff schedule to read, as numbers: 61 is knitted apparel, 84 is machinery, 87 is vehicles, 99 holds the temporary modifications. Ranges work too - write 61-63. Chapter 77 is reserved in the Harmonized System and has no lines.

## `hsCodes` (type: `array`):

Specific codes, at any depth. 8471 gives you the whole heading, 8471.30 the subheading, 6101.20.00.10 that one statistical line. Dots are optional. The run reads the chapter each code sits in and keeps the lines that start with it.

## `searchTerms` (type: `array`):

Words to look up in the schedule's own index, like "laptop" or "olive oil". The matching codes come back as full rows with their duty rates and the description path above them. The index is broad - searching "cotton shirt" also returns cotton seed - so check the descriptions.

## `allChapters` (type: `boolean`):

Reads every chapter from 01 to 99 in order. That is about 29,900 numbered lines, so raise the row limit to match or you will get the first slice of it. This costs you one charge per row.

## `includeLines` (type: `string`):

The schedule is a tree. "all" returns every numbered line, including the 4-digit headings and 6-digit subheadings that hold the structure but print no rate. "withDutyRate" drops those and keeps the 8- and 10-digit lines. "statisticalOnly" keeps just the 10-digit lines, the ones that go on an entry.

## `maxItems` (type: `integer`):

How many tariff lines to return. Keep it low while you are testing - you pay per row. The whole schedule is about 29,900 numbered lines, and the hard ceiling is 40,000.

## `requireEdition` (type: `string`):

Optional. Put an edition name here, like 2026HTSRev19, and the run will refuse to return anything if the live schedule is a different edition. Useful on a schedule, where a revision landing overnight would otherwise change your rates without anyone noticing. Leave it empty to take whatever is current.

## `proxyUrls` (type: `array`):

Leave this empty for a normal run. Fill it in only if you want the traffic to leave through proxy servers you already pay for, one URL per line, in the form http://user:pass@host:port.

## Actor input object example

```json
{
  "chapters": [
    "61"
  ],
  "hsCodes": [],
  "searchTerms": [],
  "includeLines": "all",
  "maxItems": 100
}
```

# Actor output Schema

## `results` (type: `string`):

Every row in the default dataset: htsno, lineLevel, chapter, heading, description, unitOfQuantity, generalRateEffective, specialRateEffective, column2RateEffective, ratesAreInherited, rateSource, dutyRateResolved, htsEdition, htsUrl, subheading, descriptionPath, headingDescription, generalRate, specialRate, column2Rate, rateNote, additionalDuties, indent, htsEditionTitle, matchedQuery. An empty, blocked or unmatched run returns a single uncharged row explaining what happened instead.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "chapters": [
        "61"
    ],
    "hsCodes": [],
    "searchTerms": [],
    "allChapters": false,
    "includeLines": "all",
    "maxItems": 100
};

// Run the Actor and wait for it to finish
const run = await client.actor("dami_studio/hts-customs-duty-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "chapters": ["61"],
    "hsCodes": [],
    "searchTerms": [],
    "allChapters": False,
    "includeLines": "all",
    "maxItems": 100,
}

# Run the Actor and wait for it to finish
run = client.actor("dami_studio/hts-customs-duty-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "chapters": [
    "61"
  ],
  "hsCodes": [],
  "searchTerms": [],
  "allChapters": false,
  "includeLines": "all",
  "maxItems": 100
}' |
apify call dami_studio/hts-customs-duty-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,dami_studio/hts-customs-duty-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/1furRdLwgK6zd3M1M/builds/zmgpr0ZTmSwfDqXoK/openapi.json
