Build Your Own Tariff Change Radar in ~70 Lines of Python (the Data Is Already Open)
Tariff rates don't sneak up on anyone — tariff changes do. In 2026 the US trade-policy stack has been moving roughly five times a month: we track every published change in a machine-readable changelog, and the current snapshot holds 57 entries, 54 of them dated in 2026 alone — Section 232 measures stacking up (furniture 25%, timber 10%, pharma 20%, semiconductors 25%), the global Section 301 program going live, China exclusions expiring, the FY2027 fee table resetting MPF.
If you price imports, every one of those is a real-money event you find out about either from a feed like this one — or from the customs invoice. This post builds the first kind: a change radar that runs from cron, watches an open dataset, and pings a webhook when the stack moves. No scraping, no HTML parsing, no API keys. About seventy lines of Python.
Why watch a git repo instead of scraping the source?
The raw sources are hostile to automation. Federal Register notices are prose; exclusion lists ship as PDF attachments; CBP publishes CSMS messages on a page that assumes a human is reading it. Parsing all that is a full-time job — it's literally the product we built, and the public radar is its front end.
But once the parsing is done once, the output is just data. So we publish it: github.com/invoicetariff/invoicetariff carries data/changelog.json — one JSON entry per tracked change:
{
"id": "2026-10-01-fy2027-fees", // date-prefixed, unique
"date": "2026-10-01",
"layer": "fees", // mfn | china301 | global301 | section232 | section338 | ieepa | fees
"status": "published", // or "upcoming" for scheduled changes
"title": "...", // bilingual: 中文 original
"titleEn": "In effect: FY2027 MPF min/max increase (from 2026-10-01)",
"summary": "...",
"source": { "title": "91 FR 48398", "url": "https://www.federalregister.gov/..." }
}
Three properties make this watchable:
-
Git is the transport.
git pullis a more reliable API than most APIs — resumable, cacheable, proxy-friendly, and rate-limit-free. -
statussplits the timeline.publishedentries are in effect;upcomingentries are scheduled (the China exclusions expiry on 2026-11-10, the extended truce deadline on 2027-01-10). A radar that only reports the past is half a radar. -
The repo ships the ruleset itself (
ruleset-seed.json, versioned monthly, currently v0.3.4), so the notification can also tell you which version your last analysis ran against.
The script
#!/usr/bin/env python3
"""tariff-radar.py — get notified when the open US tariff ruleset changes.
Data: https://github.com/invoicetariff/invoicetariff (data/changelog.json)
Usage: python3 tariff-radar.py [--webhook https://hooks.slack.com/services/XXX]
"""
import json
import pathlib
import subprocess
import sys
import urllib.request
REPO_URL = "https://github.com/invoicetariff/invoicetariff"
HERE = pathlib.Path(__file__).parent
REPO = HERE / "invoicetariff"
STATE = HERE / "tariff-radar.state.json"
LAYERS = sys.argv[1].split(",") if len(sys.argv) > 1 and not sys.argv[1].startswith("--") else None
WEBHOOK = sys.argv[sys.argv.index("--webhook") + 1] if "--webhook" in sys.argv else None
def sh(*args):
return subprocess.run(args, capture_output=True, text=True, check=True).stdout
def refresh_repo():
if not (REPO / ".git").exists():
sh("git", "clone", "--depth", "1", REPO_URL, str(REPO))
else:
sh("git", "-C", str(REPO), "pull", "--ff-only")
def main():
refresh_repo()
entries = json.load(open(REPO / "data" / "changelog.json"))["entries"]
version = json.load(open(REPO / "data" / "ruleset-seed.json"))["version"]
# State = the set of entry ids already seen. NOT "the last entry":
# the feed interleaves future-dated `upcoming` items with `published`
# ones, so the last row is not the newest, and a max-id state would
# silently miss entries whose id sorts below an upcoming date.
seen = set(json.loads(STATE.read_text())) if STATE.exists() else set()
fresh = sorted((e for e in entries if e["id"] not in seen),
key=lambda e: (e["date"], e["id"]))
if LAYERS:
fresh = [e for e in fresh if e["layer"] in LAYERS]
if not fresh:
print(f"ruleset v{version}: no changes since last check")
return
lines = [f"Tariff ruleset v{version} - {len(fresh)} new change(s):"]
for e in fresh:
title = e.get("titleEn") or e["title"]
lines.append(f'- [{e["date"]}] {e["layer"].upper()} ({e["status"]}): {title}')
msg = "\n".join(lines)
print(msg)
if WEBHOOK:
body = json.dumps({"content": msg[:1900]}).encode() # Discord; Slack: {"text": ...}
req = urllib.request.Request(WEBHOOK, data=body,
headers={"Content-Type": "application/json"})
urllib.request.urlopen(req)
STATE.write_text(json.dumps(sorted(seen | {e["id"] for e in entries})))
if __name__ == "__main__":
main()
First run against today's snapshot, it prints all 57 entries, tail like this:
Tariff ruleset v0.3.4 - 57 new change(s):
- [2025-10-01] FEES (published): FY2026 MPF/HMF fee schedule effective (CSMS #65741993)
...
- [2026-10-08] MFN (published): ITC institution of Section 337 investigation: certain mobile
electronic devices and components thereof (337-TA-1525, trinamiX v. Apple) (procedural)
- [2026-11-10] CHINA301 (upcoming): Upcoming: China Section 301 product exclusions expire (2026-11-10)
- [2026-11-30] MFN (upcoming): Upcoming: NHTSA SAFE Vehicles Rule III — CAFE fuel-economy
standards recalibrated (from 2026-11-30; non-tariff notice)
- [2027-01-10] CHINA301 (upcoming): Upcoming: China truce extended to 2027-01-10 (announced 2026-09-23, FR pending)
Every run after that prints only what's new, and exits quietly when nothing changed — which is what you want from a cron job.
The one bug you'd have written anyway
The tempting version of the state is "remember the id of the last entry, report everything after it." It works — until the first time it doesn't. The feed interleaves upcoming entries whose ids are future dates (2027-01-10-…) between published ones (2026-10-08-…). So:
- the last row of the file is not the newest entry, and
- once your state holds
2027-01-10-…as the high-water mark, a new published entry like2026-11-05-…sorts below it and is silently never reported.
The fix is the boring one: persist the set of seen ids instead of a cursor. It costs a few lines and turns a silent failure mode into a non-event. (This is also why the script sorts by (date, id) for display — publication order in the file is append order, not chronological.)
Filter it to the layers you actually price against — say China lists and fees:
python3 tariff-radar.py "china301,fees" --webhook https://hooks.slack.com/services/XXX
…or run it from cron, weekdays at nine:
0 9 * * 1-5 cd /opt/tariff-radar && /usr/bin/python3 tariff-radar.py >> radar.log 2>&1
Honest limits
-
Resolution is monthly. The dataset is versioned and updated about monthly (CSV rows and the ruleset carry
ruleset_version/ruleset_asofstamps), so the radar tells you what changed as of each refresh — great for pricing strategy and renewal dates, not a breaking-news wire. When the EU announces something at 14:00, you'll read it on the news; when the FY2027 MPF floor lands, you'll have it before your next quote goes out. -
It's a feed, not an oracle. Entries carry
sourcelinks (Federal Register, CSMS, USTR) — the radar tells you that Section 232 pharma went to 20% and links the notice; whether your product falls in scope is still an HTS-level question. For that, the stacked calculator runs the same ruleset per code. - Discord vs Slack payloads differ (the commented line in the script). Everything else — message shape, state, filters — is yours to fork.
The radar page on the site is this exact idea with a UI on top. But the point of publishing the data is that you don't need our UI: a git repo, a JSON array, a cron entry, and your team's chat channel is a working early-warning system for the most volatile cost input of 2026.
If you extend it — multi-region watching, per-SKU impact digests, a Grafana panel of layer counts — tell me in the comments or open an issue; the schema is versioned precisely so things like that can outlive this post.
Change data comes from InvoiceTariff changelog.json v0.3.4 (as of 2026-10-07); rate levels shown are estimates from the same snapshot, not legal advice.
The dataset behind this post — 5,689 HTS codes, the 60-country tier table, and the stacked ruleset — is MIT-licensed on GitHub; a companion post walks through analyzing and batch-pricing with it in pandas.
Update: the parser in this post now ships as an npm package — npm install us-tariff-lookup (zero dependencies, data bundled). npx us-tariff changelog prints the same feed without writing the parser, npx us-tariff meta reports which ruleset version you're on, and in code the entries arrive as a changelogEntries array (published + upcoming, date-desc) — so the seen-ids state from the bug section above is still yours to keep; the feed just gets delivered. A follow-up post wires the library into a real Express/Next API route: Add US Import Tariff Math to Your App in 5 Minutes.
Top comments (0)