DEV Community

InvoiceTariff
InvoiceTariff

Posted on Edited on

Build Your Own Tariff Change Radar in ~70 Lines of Python (the Data Is Already Open)

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/..." }
}
Enter fullscreen mode Exit fullscreen mode

Three properties make this watchable:

  1. Git is the transport. git pull is a more reliable API than most APIs — resumable, cacheable, proxy-friendly, and rate-limit-free.
  2. status splits the timeline. published entries are in effect; upcoming entries 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.
  3. 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()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 like 2026-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
Enter fullscreen mode Exit fullscreen mode

…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
Enter fullscreen mode Exit fullscreen mode

Honest limits

  • Resolution is monthly. The dataset is versioned and updated about monthly (CSV rows and the ruleset carry ruleset_version / ruleset_asof stamps), 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 source links (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)