HTS Classification API: Developer Integration Guide
One POST request. Describe a product in plain English, get the HTS code as deep as your facts support (10 digits when they are enough), with the schedule rate, Census figures for duty actually collected on past imports (including Section 301/232 and FTA program usage), and CBP ruling evidence. JSON in, JSON out. A classification typically takes 15–35 seconds.
Quick start
1. Get an API key
Buy a credit pack. The key is shown once on the page after purchase (it is not emailed), so save it. Each successful /classify call costs 1 credit. All other endpoints are free with a key.
2. Make your first call
# Classify a product curl -X POST https://htsapi.dev/v1/classify \ -H "Content-Type: application/json" \ -H "X-API-Key: YOUR_API_KEY" \ -d '{ "description": "wireless bluetooth headphones with noise cancellation", "country_of_origin": "China" }'
3. Parse the response
Duty figures below are illustrative; yours will differ.
{
"answer": {
"code": "8518.30.20.00",
"level": 10,
"options": [],
"question": null
},
"confident_to": "8518.30.20.00",
"candidates": [
{
"hts_code": "8518.30.20.00",
"description": "Headphones and earphones...",
"general_duty": "Free",
"importers_paid": {
"status": "ok",
"rate_pct": 10.1,
"month": "2026-07",
"months": [ { "month": "2026-05", "rate_pct": 10.0 }, { "month": "2026-06", "rate_pct": 10.0 }, { "month": "2026-07", "rate_pct": 10.1 } ],
"applies_to": "this line",
"country": "CHINA",
"note": "Importers from China paid 10.1% of value in duty on line 8518.30.20.00 in 2026-07 (10-10.1% over 2026-05..2026-07), base rate plus Chapter 99 duties (anti-dumping and countervailing duties are not included). (Census.) Monthly averages of past entries; ..."
},
"census_duties": {
"country": "CHINA",
"period": "2025-06..2026-05",
"total_effective_rate_pct": 17.3,
"total_import_value_usd": 61910940,
"total_duty_collected_usd": 10463040,
"breakdown": [
{ "rp_code": "61", "rp_label": "Dutiable at the General (MFN) rate", "effective_rate_pct": 0.0 },
{ "rp_code": "69", "rp_label": "Dutiable under HTS chapter 99 (e.g. Section 301, 232), duty reported", "effective_rate_pct": 25.9 }
],
"aggregate_import_value_usd": 443182895,
"recent_months": [
{ "month": "2026-05", "level": "HS10", "commodity": "8518.30.20.00", "effective_rate_pct": 10.0, "chapter99_rate_pct": 10.0, ... },
{ "month": "2026-06", ... "effective_rate_pct": 10.0 },
{ "month": "2026-07", ... "effective_rate_pct": 10.1, "chapter99_rate_pct": 10.3 }
],
"recent_basis": "what importers paid in 2026-05..2026-07 at HS10 8518.30.20.00, CHINA: ..."
},
"confidence": "high",
"rationale": "This code covers headphones...",
"rulings": [
{
"ruling_number": "N302512",
"subject": "The tariff classification of audio headphones from China",
"source_url": "https://rulings.cbp.gov/ruling/N302512"
}
],
"legal_notes": "--- SECTION XVI ---\n Notes\n1 This section does not cover..."
}
],
"clarification": null
}
Code examples
Python
import requests resp = requests.post( "https://htsapi.dev/v1/classify", headers={"X-API-Key": "YOUR_API_KEY"}, json={ "description": "stainless steel water bottle 500ml insulated", "country_of_origin": "China" } ) data = resp.json() answer = data["answer"] def paid(card): # What importers from your country paid on this code, base rate plus Chapter 99 duties (anti-dumping and countervailing duties are not included) (Census, past months). p = card.get("importers_paid") if not p or p["status"] != "ok": return "no figure" # null: no trade; "unavailable": Census did not answer in time, ask again months = ", ".join(f"{m['month']} {m['rate_pct']}%" for m in p["months"]) return f"{months} ({p['applies_to']})" if answer["level"] == 10: top = data["candidates"][0] print(answer["code"], "base", top["general_duty"], "| paid", paid(top)) # 7323.93.00.50 base 2% | paid ... else: # Not decided: one option per answer to the question, each with its own duty. for o in answer["options"]: print(o["hts_code"], o["condition"], "| base", o["general_duty"], "| paid", paid(o)) print("Question:", answer["question"]) # add the answer to the description, call again
Node.js
const resp = await fetch("https://htsapi.dev/v1/classify", { method: "POST", headers: { "Content-Type": "application/json", "X-API-Key": "YOUR_API_KEY" }, body: JSON.stringify({ description: "ceramic coffee mug handmade", country_of_origin: "Japan" }) }); const data = await resp.json(); const { answer } = data; console.log(answer.code, answer.level); // "6912.00" 6: not decided yet for (const o of answer.options) { console.log(o.hts_code, o.condition, o.general_duty, o.importers_paid?.rate_pct); } console.log(answer.question); // the one fact that decides; add it and call again
MCP (Claude, Claude Code, Cursor)
The API is also a remote MCP server at https://htsapi.dev/mcp (Streamable HTTP). The same key works; send it as the X-API-Key header. Tools: lookup_hts_code, search_rulings, get_ruling (free) and classify_product (1 credit).
Claude: the dashboard has a one-click Add to Claude button with your key filled in. Or add it to Claude here and, under Request headers, add X-API-Key with your key.
claude mcp add --transport http htsapi https://htsapi.dev/mcp --header "X-API-Key: YOUR_API_KEY"
Other clients:
{"mcpServers": {"htsapi": {"url": "https://htsapi.dev/mcp", "headers": {"X-API-Key": "YOUR_API_KEY"}}}}
Key concepts
answer: the code, or the choice and the question that settles it
Start here. answer.code is the deepest code the facts you gave support: 10 digits when
they are enough. When the last digits turn on a fact your description does not state, the answer
stops at the level it is sure of and lists the codes that fact decides between, each with the
condition that leads to it:
{
"code": "4419",
"level": 4,
"options": [
{ "hts_code": "4419.20.10.00", "description": "Of tropical wood › Forks and spoons",
"general_duty": "5.3%", "condition": "If the acacia species is tropical wood", "likely": true },
{ "hts_code": "4419.90.11.00", "description": "Other › Forks and spoons",
"general_duty": "5.3%", "condition": "If the acacia species is not tropical wood", "likely": false }
],
"options_total": 8,
"question": "What is the botanical species of the acacia?"
}
Answer the question (add the fact to your description) and call again for the full code. Options can
sit in different headings (a silicone baking mat is 3924 if it is a finished mat and 3921 if it is
a flat sheet), and then code is only what they share ("39"), or null
when they share no chapter.
likelymarks the option the agent would pick on the facts given. It can befalseon every option, when the agent's own pick is not a full 10-digit line.optionsare the lines the question decides between.options_totalcounts every 10-digit line undercode, so it can be larger (20 lines under 4407.19.00, 5 of them in play); the others are not offered because the facts already rule them out.candidateskeeps the full detail for each code the agent put forward (rationale, the CBP rulings it relied on, duties). A candidate can be shorter than 10 digits ("4407.19.00") when the agent could not go further; readanswer.optionsthen. With a question and options but no candidate,candidatesis empty andanswerstill has them.confidenceishigh,mediumorlow, the agent's own label. At 10 digits,mediumhas been right about half the time: treat it as a lead, read the rulings it cites, and if it matters run the same product two or three times. Answers that agree across runs are settled; where they differ, that is the uncertain part.
confident_to: how deep to trust
The same as answer.code: the deepest level no open question can change, checked against the schedule and against the codes returned. Kept for existing integrations.
| Value | Meaning | What you can do |
|---|---|---|
6109.10.00.12 | Full 10-digit agreement | Full code; review before filing |
6109.10.00 | 8-digit agreement | Duty rate is known, statistical suffix varies |
6109.10 | 6-digit agreement | Product type is clear, specifics needed |
6109 | 4-digit (heading) | Broad category only |
null | No agreement | Description is too vague, add detail |
importers_paid: what to budget with
On every candidate and every option. general_duty is only the schedule's base rate; additional duties (Section 301, 232 and others) are not in it. importers_paid is what importers from your country_of_origin actually paid on that code, base rate plus Chapter 99 duties (anti-dumping and countervailing duties are not included), in each of the latest 3 months Census has published (months), with the latest in rate_pct and month. For a plastic tray (3924.10.30.00) from China: base 5.3%, paid 20.8% in July 2026.
applies_tosays whose figures they are:"this line", or, when the 10-digit line had too little trade,"the 6-digit group 1902.19 (this line had too little trade)". Thenrate_pctisnullandmonthshold the group's figures.status: "unavailable"means Census did not answer within 10 seconds: ask again a minute later.importers_paid: nullmeans Census has no trade for that code and country.- It is history: averages of past entries, about two months behind. Tariff rules change, so it is not a quote for your shipment.
census_duties: the detail behind it
From the US Census Bureau: duty collected divided by dutiable value for the candidate's 6-digit subheading, summed over the latest 12 months Census has published (about two months behind). Broken down by Census rate provision: dutiable at the MFN rate, at a special (preferential) rate, under Chapter 99 (Section 301/232 and other additional duties actually paid), free under a program, and so on. It is a historical collection average, not the duty a specific shipment will owe. Give country_of_origin for figures from that country; without it, they cover all countries.
To price a shipment, use recent_months. It is the latest 3 published months, each on its own, at the exact 10-digit line when Census has enough trade in it (level: "HS10"; otherwise the 6-digit subheading, and recent_basis says why). Per month: the rate paid on all entries, chapter99_rate_pct (entries that paid Chapter 99 duties, rate provision 69) and general_rate_only_pct (MFN only, provision 61). The 12-month average mixes products and tariff regimes: for headphones (8518.30.20.00) from China it is 17.3%, while importers paid 10.0%, 10.0% and 10.1% in May, June and July 2026. Census is about two months behind, so changes since then are not in either figure.
legal_notes: official classification guidance
Section and chapter notes from the tariff schedule, trimmed to what bears on the code: the legal text that includes or excludes products from a heading. Customs brokers read them together with the General Rules of Interpretation (GRI).
clarification: when the API needs more info
The same text as answer.question, kept for existing integrations. null when the facts given decide the code.
All endpoints
| Method | Endpoint | Cost | Description |
|---|---|---|---|
POST | /v1/classify | 1 credit | AI classification with full enrichment |
GET | /v1/hts/search?q= | Free | Search HTS codes by keyword or prefix |
GET | /v1/hts/{code} | Free | Single code detail with hierarchy and full_description (the schedule's unnumbered rows in place) |
GET | /v1/rulings/precedent?q= | Free | CBP rulings for a product by meaning: all codes, HQ/NY, status (current / modified / superseded), product facts, and a tally of where precedent points |
GET | /v1/rulings/search?q= | Free | Keyword search of 220K+ CBP rulings, with an HTS-code filter |
GET | /v1/rulings/{ruling_number} | Free | Full ruling text, HQ/NY, status (and what revoked or modified it), what it revoked or modified itself, and CBP's related rulings with their status |
GET | /v1/billing/balance | Free | Check remaining credits |
GET | /v1/status | Free | System health + data freshness |
Only /classify costs credits, and only when it succeeds: a 422 or 503 costs nothing. Everything else is free with a key, within the rate limit.
Request fields
| Field | Type | Required | Notes |
|---|---|---|---|
description | string | Yes | 3-2000 chars. More detail = better results. |
material | string | No | Primary material (e.g., "cotton", "stainless steel") |
category | string | No | Product category hint |
country_of_origin | string | No | Gives country-specific Census duty figures (which include Section 301/232 duties collected) |
country_of_origin. Without it, the Census duty figures cover all countries combined, which can hide country-specific duties such as Section 301. Country names work (e.g., "China", "Vietnam"); no need for ISO codes.
What to send
The answer is only as good as the description. The same product from China, two ways:
"pho spice bag"comes back with a question and leans to 2103.90.80.00 (mixed seasoning): base 6.4%, importers paid 41.2% in July 2026."Pho spice bag: a cotton muslin drawstring bag filled with whole dried spices (star anise, cinnamon stick, cloves, black cardamom, coriander seeds, fennel seeds), nothing else added, sold as a single retail pack"comes back 0910.91.00.00 (mixtures of spices) with high confidence: base 1.9%, importers paid 11.0%.
- One product per call: the article you import.
- Say what it is, what each part is made of, what it does, and how it is sold (single item, set, or kit and what's in it). Facts beat marketing words; give percentages when materials are mixed.
- Always send
country_of_origin. It selects whose dutyimporters_paidreports. - Links are not read; pasted listings are. Several retail listings of one product are read as versions of it; where they differ in a fact that decides the code (usually material), the answer gives an option for each value of that fact, with the question that asks it. A short summary of them into the version you will make gives one answer.
- Answer the question. When
answer.questionis set, add that fact to the description and call again.
Error handling
| Status | Code | Meaning |
|---|---|---|
| 401 | MISSING_API_KEY | No X-API-Key header |
| 401 | INVALID_API_KEY | Key not found or revoked |
| 402 | NO_CREDITS | Credits exhausted; buy more |
| 403 | ACCOUNT_INACTIVE | Account disabled |
| 422 | Validation error | Description too short/long |
| 503 | CLASSIFY_UNAVAILABLE | Model or DB not ready |
Rate limits
10 requests per minute per API key. A classification typically takes 15–35 seconds and sometimes up to about 60, so set your HTTP client timeout well above a minute.
Data freshness
GET /v1/status shows the freshness of the data we store: the HTS schedule and CBP rulings, each refreshed weekly. For rulings it gives the newest ruling we hold, and marks the source stale when that is more than 21 days old. Census figures are not stored; each response fetches the latest 3 published months (about two months behind) and says so in importers_paid.