If you build commerce software — a storefront, a shipping quotes page, a margin calculator — US import tariffs are the cost input most likely to make your numbers wrong. Not because any single rate is complicated, but because the answer is never one rate. A cotton T-shirt from China in October 2026 stacks four duty layers plus two entry fees: a 16.5% MFN base, 7.5% from a Section 301 list, 12.5% from the new global Section 301 tier, then MPF and HMF on top. Do the math with one lookup table and your quote is off by half.
Getting the right number usually means an API key, a per-call bill, and a network dependency. We took a different route with us-tariff-lookup: the versioned ruleset ships inside the package (zero dependencies, no API key, no network calls), and every duty line carries its legal basis and source. This post walks from npm install to a working landed-cost endpoint.
Install, then look around
npm install us-tariff-lookup # the library
npx us-tariff meta # or kick the tires without installing anything
meta tells you what's inside:
{
"rulesetVersion": "0.3.4",
"dataAsOf": "2026-10-07",
"coverage": {
"mfnCuratedCodes": 484,
"china301Lines": 25,
"global301Tiers": 60,
"section232Measures": 8,
"changelogEntries": 57
},
"disclaimer": "Estimates for planning only — not legal advice. …"
}
Two numbers to notice: rulesetVersion (the ruleset ships with the package, so results are deterministic and reproducible — the same input gives the same answer until you upgrade) and dataAsOf (the snapshot date).
The CLI covers the whole surface. Find a code by English or Chinese keyword:
$ npx us-tariff search t-shirt
{
"rulesetVersion": "0.3.4",
"dataAsOf": "2026-10-07",
…
"count": 1,
"hits": [
{
"hts8": "61091000",
"description": "T-shirts, singlets and other vests, knitted, of cotton",
"mfnGeneral": "16.5%",
"matchedBy": "en"
}
]
}
$ npx us-tariff search 保温杯 # works in Chinese too
{ "count": 1, "hits": [ { "hts8": "96170010", "mfnGeneral": "10%", "matchedBy": "zh", … } ] }
…a stacked calculation:
$ npx us-tariff calc --hts 61091000 --value 20000 --origin CN --mode sea --units 5000
…recent rule changes (changelog), and which post-IEEPA refund window applies to a given entry date (refund --entry-date 2026-03-01). Everything prints JSON with the version and disclaimer attached.
What "stacked" actually looks like
Here's the calc result for the T-shirt order — $20,000 declared value, sea freight, 5,000 units — trimmed to the parts that matter:
{
"totalDuty": 7394.28,
"landedTotal": 27394.28,
"effectiveTaxRate": 36.97, // percent of declared value, all-in
"layers": [
{ "kind": "mfn", "rateLabel": "16.5%", "amount": 3300, "legalBasis": "HTS Column 1 general rate" },
{ "kind": "china301", "rateLabel": "7.5%", "amount": 1500, "chapter99": "9903.88" },
{ "kind": "global301","rateLabel": "12.5%", "amount": 2500, "status": "litigation" }
],
"fees": [
{ "id": "hmf", "amount": 25.0, "basis": "26 U.S.C. §4461 … (sea & water shipments only)" },
{ "id": "mpf", "amount": 69.28, "basis": "19 U.S.C. §58c, FY2027 (from 2026-10-01)" }
],
"warnings": [
"The fee schedule for this date (FY2027) is pending verification against the Federal Register; CBP bulletins prevail.",
"The global 301 layer is in litigation — CBP enforcement prevails."
]
}
More than half of that total — $4,000 of the $7,394.28 — is the two Section 301 layers, and neither of them touches the "duty rate" column of most product catalogs. layers is an array, not a number, because that's what the law is. Each layer carries legalBasis, a source URL (USITC / USTR / Federal Register), and a status — note litigation on the global tier. If your UI flattens this to a single percentage, you're not wrong yet, but you can't explain why when a customer asks, and you can't react when one layer changes while the others hold.
Wire it into an API (Express)
This is a complete, runnable server — it's the exact code I tested against the published package:
// server.mjs — node server.mjs
import express from 'express';
import { rulesetSeed, searchHts, calculateLandedCost } from 'us-tariff-lookup';
const app = express();
const DISCLAIMER = 'Estimates for planning only — not legal advice. Your customs broker signs the entry, not this tool.';
const todayISO = () => new Date().toISOString().slice(0, 10);
// GET /api/hts/search?q=t-shirt
app.get('/api/hts/search', (req, res) => {
const q = String(req.query.q ?? '');
const hits = searchHts(rulesetSeed, q, 10).map((h) => ({
hts8: h.entry.hts8,
description: h.entry.description,
general: h.entry.general,
matchedBy: h.matchedBy,
}));
res.json({ dataAsOf: rulesetSeed.asOf, query: q, count: hits.length, hits });
});
// GET /api/landed-cost?hts=61091000&value=20000&origin=CN&mode=sea&units=5000
app.get('/api/landed-cost', (req, res) => {
const input = {
hts: String(req.query.hts ?? ''),
value: Number(req.query.value ?? 0),
originCountry: String(req.query.origin ?? 'CN'),
entryDate: todayISO(),
mode: String(req.query.mode ?? 'sea'),
units: Number(req.query.units ?? 1),
locale: 'en',
};
try {
const r = calculateLandedCost(rulesetSeed, input);
res.json({
rulesetVersion: rulesetSeed.version,
dataAsOf: rulesetSeed.asOf,
disclaimer: DISCLAIMER,
input,
totalDuty: r.totalDuty,
effectiveTaxRate: Number(r.effectiveTaxRate.toFixed(2)),
landedTotal: r.landedTotal,
perUnit: Number(r.perUnit.toFixed(2)),
warnings: r.warnings,
layers: r.layers.map((l) => ({ kind: l.kind, rate: l.rateLabel, amount: l.amount, source: l.source?.url })),
});
} catch (err) {
res.status(400).json({ error: err.message ?? 'invalid request', disclaimer: DISCLAIMER });
}
});
app.listen(8787, () => console.log('listening on :8787'));
Real responses, first the happy path:
$ curl "http://localhost:8787/api/landed-cost?hts=61091000&value=20000&origin=CN&mode=sea&units=5000"
{
"rulesetVersion": "0.3.4",
"dataAsOf": "2026-10-07",
"totalDuty": 7394.28,
"effectiveTaxRate": 36.97,
"landedTotal": 27394.28,
"perUnit": 5.48,
"warnings": ["The fee schedule …", "The global 301 layer is in litigation …"],
"layers": [ … ]
}
…and the failure path, which is where most tariff integrations go wrong quietly. An unknown HTS code doesn't return zeros — it throws a descriptive error you can surface:
$ curl "http://localhost:8787/api/landed-cost?hts=99999999&value=100"
{"error":"HTS 99999999 was not found in the local tariff library — try an English
keyword or paste an 8/10-digit code","disclaimer":"…"} // HTTP 400
The same call works in a Next.js App Router route with no other changes to the engine part:
// app/api/landed-cost/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { rulesetSeed, calculateLandedCost } from 'us-tariff-lookup';
export async function GET(req: NextRequest) {
const p = req.nextUrl.searchParams;
try {
const r = calculateLandedCost(rulesetSeed, {
hts: p.get('hts') ?? '',
value: Number(p.get('value') ?? 0),
originCountry: p.get('origin') ?? 'CN',
entryDate: new Date().toISOString().slice(0, 10),
mode: (p.get('mode') ?? 'sea') as 'sea' | 'air' | 'express' | 'postal',
units: Number(p.get('units') ?? 1),
locale: 'en',
});
return NextResponse.json({ rulesetVersion: rulesetSeed.version, dataAsOf: rulesetSeed.asOf, ...r });
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 400 });
}
}
Three API notes that will save you an afternoon:
-
The package is ESM-only (
"type": "module", Node ≥ 18) — useimport, notrequire. -
entryDateis part of the calculation, not metadata. The engine is bitemporal: the same HTS code answers differently depending on when it entered the port. It must be withindataAsOf + 90 days, and the error tells you the exact ceiling (Entry date cannot be later than 2027-01-05 (today + 90 days)). Monthly package updates slide that window forward. -
modeissea | air | express | postal— HMF applies to sea only, and postal shipments ≤ $2,500 use informal-entry flat fees. Passing the transport mode isn't cosmetic; it changes the fees.
Sanity-check it against real SKUs
Three real-ish order lines (all China origin, October 2026), run through the same calculateLandedCost:
| SKU | HTS | Value | Mode | Duty+fees | Effective rate |
|---|---|---|---|---|---|
| TEE-BLK-M (cotton tees) | 61091000 | $20,000 | sea | $7,394.28 | 36.97% |
| LAP-14 (laptops) | 84713001 | $50,000 | air | $6,423.20 | 12.85% |
| BATT-PACK (Li-ion packs) | 85076000 | $18,000 | air | $7,424.35 | 41.25% |
The middle row is the one people don't believe. Laptops have a Free MFN rate and sit on no China list — and they still cost 12.85% all-in, purely from the global tier plus MPF. If your pricing engine models "China = 301 list or nothing," laptop SKUs are where it silently leaks. (We go deeper on the pandas side in the datasets post.)
Show the version — your users will thank you
The most important field in every response is the pair rulesetVersion + dataAsOf, and the worst thing you can do is swallow it. Trade rules move about five times a month right now; when a customer compares your quote to their customs broker's entry, the first question is "which rules were you on?" Three habits that cost nothing:
-
Render the disclaimer where the number is, not in a footer: the package hands you the exact string (
Estimates for planning only — not legal advice…) — pass it through. -
Surface
warningsnext to the total. Two of the current layers carry live caveats (the FY2027 fee table is pending Federal Register verification; the global 301 tier is in litigation). Hiding them turns your app into the weak link when the case resolves. -
Log the version with every quote. When rates change and someone audits last quarter's numbers,
rulesetVersionis the difference between a five-minute answer and an archaeology project.
Honest limits
-
The bundled search covers 484 curated high-frequency codes, not the full 5,689-line HTS — the full table ships separately in the hs-codes-database repo, and
searchHtsreturning nothing means "outside the curated set," not "doesn't exist." - Most v0.3.x rate lines carry
sample/unverifiedflags: fine for pricing scenarios, quotes, and demos; your customs broker still signs the actual entry. - Multi-word search queries ("cotton t-shirt") don't tokenize yet — single tokens ("t-shirt") are the reliable form until the next release.
If you'd rather watch the feed than do the math, the radar post builds the change-monitoring side in Python. And if you ship something with the package — a Shopify margin app, an ERP plugin — I'd genuinely like to hear what it needed that the schema doesn't have.
All output above is from us-tariff-lookup@0.1.0 (ruleset v0.3.4, as of 2026-10-07). Estimates for planning only — not legal advice; your customs broker signs the entry, not this package.
npm install us-tariff-lookup — zero dependencies, no API key, data bundled. MIT licensed, source on GitHub; the web calculator is at invoicetariff.com. Prefer a tutorial you can keep open while you code? The same walkthrough lives on our site at invoicetariff.com/tutorials/npm-package.
Top comments (0)