CBP CROSS Rulings API: Accessing 134K+ Customs Rulings
CBP's Customs Rulings Online Search System (CROSS) has a JSON API. No authentication, no rate limit docs. Here's how to use it, how to get full ruling text, and what to watch out for.
Base URL and endpoints
Base: https://rulings.cbp.gov/api. No API key needed.
| Endpoint | What it does |
|---|---|
/search?term=X&from=0&size=10 | Search rulings by keyword |
/ruling/NXXXXXX | Get a single ruling by number |
Searching rulings
# Search for rulings about headphones
curl -s "https://rulings.cbp.gov/api/search?term=headphones&from=0&size=3" | python3 -c "
import sys, json
data = json.load(sys.stdin)
print(f'Total: {data[\"totalHits\"]} rulings')
for r in data.get('rulings', []):
print(f' {r[\"rulingNumber\"]:10s} {r.get(\"subject\",\"\")[:60]}')"
Pagination: use from (offset) and size (limit). The response key for total count is totalHits. Search results include rulingNumber, subject, rulingDate, tariffs, and relationship fields — but NOT text. You need a separate call per ruling to get the full text.
Fetching a single ruling
# Get ruling N296889 (men's t-shirts from China)
curl -s "https://rulings.cbp.gov/api/ruling/N296889" | python3 -c "
import sys, json
r = json.load(sys.stdin)
print(f'Number: {r[\"rulingNumber\"]}')
print(f'Subject: {r.get(\"subject\",\"\")}')
print(f'Date: {r.get(\"dateIssued\",\"\")}')
print(f'Text length: {len(r.get(\"text\",\"\"))} chars')
print(f'HTS codes: {r.get(\"tariffNumbers\",[])}')
print(f'First 200 chars of text:')
print(r.get('text','')[:200])"
The response includes: rulingNumber, subject, rulingDate, text (full ruling body), tariffs (HTS codes cited), categories, collection, and relationship fields (modifiedBy, revokedBy, etc.).
Gotcha #1: No bulk download
CBP doesn't offer a bulk download endpoint. The search API only returns the most recent 30 days of rulings when searching broadly. To build a full database, you need to:
- Search for rulings by HTS chapter (01-97) to get ruling numbers
- Fetch each ruling individually via
/ruling/NXXXXXX - Handle 404s (some ruling numbers don't resolve)
- Expect ~20 concurrent requests before CBP starts throttling
We fetched all 134,050 rulings this way. It took ~7 hours with 20 concurrent workers and a residential IP (CBP blocks datacenter IPs).
Gotcha #2: Datacenter IPs are blocked
We discovered this the hard way — our Hetzner server got 100% 403s. The backfill script had to run from a local machine, or through a residential proxy routed through the server.
Gotcha #3: Full text has NUL bytes
Some ruling text fields contain \x00 (NUL) characters. PostgreSQL rejects these. Strip them before inserting:
# Python
text = ruling_text.replace('\x00', '') if ruling_text else None
Gotcha #4: Ruling text structure is inconsistent
Rulings follow a general structure but with many variations:
# Typical structure: # NY N296889 # May 28, 2018 # CLA-2-61:OT:RR:NC:N3:358 # CATEGORY: Classification # TARIFF NO.: 6109.10.00.12 # [Address block] # RE: The tariff classification of men's T-shirts from China # Dear [Name]: # In your letter dated [...] you requested a tariff classification ruling. # [Product description] ← This is the useful part # [Classification reasoning] # [Final determination]
The product description is typically 2-3 paragraphs after the "Dear" line and before the classification reasoning. But some rulings are legal modifications, revocations, or have non-standard formatting. Reliably extracting product descriptions requires handling many edge cases.
We tested keyword-based extraction (matching subject keywords against full-text sentences) and achieved 56-88% coverage depending on strictness — but decided the error rate was too high for user-facing content.
Gotcha #5: Subject lines can be truncated
Some ruling subjects are cut off in CBP's own database:
# "The tariff classification of AV-S7 CinemaStation from" # Missing the country. This is CBP's data, not a parsing error.
What the data looks like at scale
| Metric | Value |
|---|---|
| Total rulings | 134,050 |
| With full text | 134,047 (99.998%) |
| Unique HTS codes cited | ~15,000 |
| Date range | 1989 — present |
| Ruling prefixes | N (New York), H (HQ), W, F, G, etc. |
| Average text length | ~3,000 characters |
| With 404 on fetch | 3 (0.002%) |
Or just use our API
We've already ingested, cleaned, and indexed all 134,050 rulings:
# Search rulings by keyword (free, no credits needed) curl "https://htsapi.dev/v1/rulings/search?q=bluetooth+headphones" \ -H "X-API-Key: YOUR_KEY" # Search by HTS code curl "https://htsapi.dev/v1/rulings/search?hts_code=8518.30" \ -H "X-API-Key: YOUR_KEY" # Get full ruling text curl "https://htsapi.dev/v1/rulings/N296889" \ -H "X-API-Key: YOUR_KEY" # Or classify a product — rulings are included automatically curl -X POST https://htsapi.dev/v1/classify \ -H "Content-Type: application/json" \ -H "X-API-Key: YOUR_KEY" \ -d '{"description": "bluetooth headphones", "country_of_origin": "China"}'
Rulings search and lookup are free (no credits). Only /classify costs credits.