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.

EndpointWhat it does
/search?term=X&from=0&size=10Search rulings by keyword
/ruling/NXXXXXXGet 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:

  1. Search for rulings by HTS chapter (01-97) to get ruling numbers
  2. Fetch each ruling individually via /ruling/NXXXXXX
  3. Handle 404s (some ruling numbers don't resolve)
  4. 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

CBP returns 403 Forbidden for requests from datacenter IP ranges (AWS, Hetzner, Railway, etc.). You can only fetch rulings from residential or office IPs. If you're running on a server, you need a residential proxy.

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

MetricValue
Total rulings134,050
With full text134,047 (99.998%)
Unique HTS codes cited~15,000
Date range1989 — present
Ruling prefixesN (New York), H (HQ), W, F, G, etc.
Average text length~3,000 characters
With 404 on fetch3 (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.

Building a trade compliance tool? We've done the painful parts: bulk fetching (7 hours), NUL byte stripping, datacenter IP workarounds, full-text indexing, HTS code linkage. See the developer guide or get an API key.

Sources

Related data source guides