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.

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.

ValueMeaningWhat you can do
6109.10.00.12Full 10-digit agreementFull code; review before filing
6109.10.008-digit agreementDuty rate is known, statistical suffix varies
6109.106-digit agreementProduct type is clear, specifics needed
61094-digit (heading)Broad category only
nullNo agreementDescription 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.

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

MethodEndpointCostDescription
POST/v1/classify1 creditAI classification with full enrichment
GET/v1/hts/search?q=FreeSearch HTS codes by keyword or prefix
GET/v1/hts/{code}FreeSingle code detail with hierarchy and full_description (the schedule's unnumbered rows in place)
GET/v1/rulings/precedent?q=FreeCBP 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=FreeKeyword search of 220K+ CBP rulings, with an HTS-code filter
GET/v1/rulings/{ruling_number}FreeFull 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/balanceFreeCheck remaining credits
GET/v1/statusFreeSystem 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

FieldTypeRequiredNotes
descriptionstringYes3-2000 chars. More detail = better results.
materialstringNoPrimary material (e.g., "cotton", "stainless steel")
categorystringNoProduct category hint
country_of_originstringNoGives country-specific Census duty figures (which include Section 301/232 duties collected)
Tip: Always provide 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:

Error handling

StatusCodeMeaning
401MISSING_API_KEYNo X-API-Key header
401INVALID_API_KEYKey not found or revoked
402NO_CREDITSCredits exhausted; buy more
403ACCOUNT_INACTIVEAccount disabled
422Validation errorDescription too short/long
503CLASSIFY_UNAVAILABLEModel 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.

Ready to integrate? Buy credits and start classifying. Questions? [email protected]

Related guides