The Mine Works
CourtListener API: How to Search US Court Records and Case Law Programmatically
← All posts
tutorial June 22, 2026 · 11 min read Updated September 17, 2026

CourtListener API: How to Search US Court Records and Case Law Programmatically

CourtListener exposes 10M+ court opinions and dockets via a free REST API. Here is how to query it, what the rate limits actually are, and when a scraper is faster.

Try the scraper

The actor referenced in this article. Pay only for results delivered.

View the scraper →

If you have ever tried to pull US court records programmatically, you have probably run into one of three walls: PACER’s pay-per-page model, a state court portal that returns HTML tables with no pagination, or a commercial legal database that costs $500 a month and requires a sales call to get API access.

CourtListener is a different story. It is a free, open-access platform run by the Free Law Project that gives you a proper REST API over 10 million court opinions, dockets, and oral argument recordings. You can search by keyword, filter by jurisdiction, pull full opinion text, and paginate through results with a standard cursor.

TL;DR: CourtListener’s REST API covers 10M+ federal and state court opinions. Unauthenticated requests are limited to 5,000 per day; authenticated requests (free API key) get you 5,000 per hour. The data model has three layers: dockets (the case), clusters (a group of opinions), and opinions (the actual document text). For bulk exports, scheduled pulls, or structured JSON output at scale, a managed scraper is faster than hand-rolling pagination.

What CourtListener Is

CourtListener is maintained by the Free Law Project, a 501(c)(3) nonprofit. The project started as an archive of federal circuit court opinions and has expanded to cover:

  • Federal district courts, circuit courts, and the Supreme Court
  • State supreme courts and appellate courts in most US states
  • PACER dockets (the procedural history of a case, not the underlying filings)
  • Oral argument audio recordings with transcripts

The database currently holds more than 10 million opinion documents going back to the 19th century in some jurisdictions. Coverage varies by court. Federal circuit courts are nearly complete. State trial courts have spotty coverage.

One important distinction: CourtListener contains court opinions, not the underlying case filings. If you want the actual briefs, motions, and exhibits from PACER, those require PACER credentials and the per-page fee structure. CourtListener scrapes and archives the publicly available PACER docket metadata, but the underlying PDFs still live on PACER.

The Official API

The base URL is https://www.courtlistener.com/api/rest/v4/. The API follows standard REST conventions: GET for reads, JSON responses, cursor-based pagination.

Authentication: Register at courtlistener.com/sign-in/ and generate a token from your profile. The token goes in the Authorization header: Token YOUR_TOKEN_HERE.

Rate limits:

  • Unauthenticated: 5,000 requests per day
  • Authenticated (free): 5,000 requests per hour
  • The API returns X-RateLimit-Remaining and X-RateLimit-Reset headers

Key endpoints:

EndpointWhat it returns
/opinions/Individual opinion documents with full text
/clusters/Groups of opinions from the same case instance
/dockets/Case-level metadata (parties, court, filing date)
/courts/Court metadata and identifiers
/search/Full-text search across opinions

Python: Searching by Keyword and Court

The most common starting point is a keyword search filtered to a specific court. Here is how to pull opinions mentioning “tortious interference” from the 9th Circuit:

import requests

API_TOKEN = "your_token_here"
BASE_URL = "https://www.courtlistener.com/api/rest/v4"

headers = {"Authorization": f"Token {API_TOKEN}"}

def search_opinions(keyword, court_id=None, max_results=50):
    """Search CourtListener opinions by keyword."""
    params = {
        "q": keyword,
        "type": "o",          # 'o' = opinions
        "order_by": "score desc",
        "stat_Published": "on",
    }
    if court_id:
        params["court"] = court_id
    
    results = []
    url = f"{BASE_URL}/search/"
    
    while url and len(results) < max_results:
        response = requests.get(url, headers=headers, params=params)
        response.raise_for_status()
        data = response.json()
        
        results.extend(data["results"])
        url = data.get("next")   # cursor URL for next page
        params = {}              # params are encoded in the cursor URL
    
    return results[:max_results]

# 9th Circuit court ID is 'ca9'
opinions = search_opinions("tortious interference", court_id="ca9")

for op in opinions[:5]:
    print(op["caseName"])
    print(op["dateFiled"])
    print(op["absolute_url"])
    print()

Court IDs follow a short-code convention. Federal circuits are ca1 through ca11 plus cadc and cafc. The Supreme Court is scotus. District courts follow the pattern dcd (DC District), nysd (Southern District of New York), and so on. The /courts/ endpoint returns the full list.

Python: Retrieving Full Opinion Text

The search endpoint returns summary metadata. To get the full opinion text, fetch the opinion object directly:

def get_opinion_text(opinion_id):
    """Retrieve full text for a single opinion."""
    url = f"{BASE_URL}/opinions/{opinion_id}/"
    response = requests.get(url, headers=headers)
    response.raise_for_status()
    opinion = response.json()
    
    # Text comes in multiple formats; prefer plain text
    text = (
        opinion.get("plain_text") or
        opinion.get("html_with_citations") or
        opinion.get("html") or
        ""
    )
    
    return {
        "id": opinion["id"],
        "case_name": opinion.get("cluster_id"),
        "date_filed": opinion.get("date_filed"),
        "author": opinion.get("author_str"),
        "text": text,
        "download_url": opinion.get("download_url"),
    }

# Pull the first opinion from your search results
first_opinion_id = opinions[0]["id"]
full_opinion = get_opinion_text(first_opinion_id)
print(f"Characters in opinion: {len(full_opinion['text'])}")

The plain_text field is pre-extracted. The html_with_citations field includes hyperlinked citations to other opinions, which is useful for building citation graphs.

The Data Model: Dockets, Clusters, and Opinions

CourtListener uses a three-layer hierarchy that confuses most first-time users.

Docket is the case. A docket represents a lawsuit from filing to disposition. It has metadata: parties, attorneys, court, filing date, cause of action. One docket can generate multiple rulings at different stages.

Cluster is a decision instance. When a court issues a ruling, all documents from that ruling event group together under one cluster. A cluster has a date, the presiding judges, and the citation if one has been assigned (e.g., 123 F.3d 456).

Opinion is the actual document. One cluster can contain multiple opinions: the majority, concurrences, and dissents. Each opinion has its own text, author, and type code.

The relationship is: Docket 1:N Clusters 1:N Opinions.

When building a legal dataset, you typically want to work at the opinion level for text and the docket level for case metadata. The cluster ties them together.

Limitations Worth Knowing

PACER filings are not here. CourtListener has PACER docket metadata (entries, filing dates, party lists), but the underlying PDFs for motions, briefs, and exhibits are paywalled on PACER. If you need those, you need PACER credentials and the pay-per-page system.

Coverage varies significantly. Federal circuit court coverage goes back decades and is nearly complete. State court coverage depends on whether the state publishes machine-readable opinions. Some state trial courts have almost no coverage.

Text quality is inconsistent. Older opinions were OCR-scanned from PDFs. OCR errors appear in documents from the 1980s and earlier. The plain_text field is usable for modern opinions; older ones may need additional cleaning.

Citations are not always resolved. The html_with_citations field links to other CourtListener opinions, but not every citation in a document points to a record that exists in the system.

When to Use the Scraper vs the Raw API

The raw API handles well when you need a targeted keyword search with under a few hundred results, or when you want to pull a specific docket or opinion by ID. Authentication is straightforward and the response structure is consistent.

The managed CourtListener scraper is faster in four situations:

Bulk exports. The API cursor pagination works, but iterating through tens of thousands of results in a single script means handling retries, rate-limit backoff, and cursor state yourself. The scraper handles all of this and writes results to a structured dataset.

Scheduled monitoring. If you want to watch for new opinions mentioning a company name or legal doctrine, you need a scheduled job that polls for new results since a given date. That is infrastructure you would otherwise build and maintain yourself.

Structured JSON without cleanup. The scraper normalizes the docket/cluster/opinion hierarchy into flat rows that are immediately usable in a dataframe or database without traversing nested objects.

No-code environments. If your legal team wants to pull data into Airtable or Google Sheets without writing Python, the scraper’s dataset output connects directly via Apify’s HTTP API.

Python: Calling the Scraper Instead of the API

The scraper takes a query plus optional court and date filters and returns one flat row per result: case_name, court, court_id, date_filed, docket_number, judge, citations, cite_count, cluster_id, docket_id and a full url back to CourtListener. The cursor handling and retries shown earlier happen inside the run.

import os
from apify_client import ApifyClient

apify = ApifyClient(os.environ["APIFY_TOKEN"])

def search_case_law(query, result_type="opinions", court="", date_from="", max_results=50):
    """Run the CourtListener scraper and return flat result rows."""
    run = apify.actor("themineworks/courtlistener-court-records").call(run_input={
        "query": query,
        "resultType": result_type,   # "opinions" or "dockets"
        "court": court,              # same short codes as the API, e.g. "ca9"
        "dateFrom": date_from,       # YYYY-MM-DD, filters on filing date
        "maxResults": max_results,
    })
    rows = apify.dataset(run["defaultDatasetId"]).iterate_items()
    # Each run also writes one summary row tagged with _type; skip it
    return [r for r in rows if "_type" not in r]

# How have courts treated non-compete enforceability recently?
opinions = search_case_law("non-compete agreement enforceability", date_from="2024-01-01")
for o in opinions[:5]:
    print(f"{o['case_name']} ({o['court']}, {o['date_filed']})")
    print(f"  {o['url']}")

Switch resultType to "dockets" to search active litigation instead of published opinions. Searching dockets by party name is how you find out whether a competitor, supplier or counterparty has been pulled into a new case:

dockets = search_case_law("Acme Robotics Inc", result_type="dockets", max_results=25)
for d in dockets:
    print(d["case_name"], d["docket_number"], d["date_filed"])

If you have a CourtListener token, pass it as apiToken in the run input to get the higher authenticated rate limit.

Only process new records on each run

A scheduled search returns many of the same records every time. Keep a set of stable ids you have already handled and drop anything in it before it reaches a report or a model. cluster_id identifies a decision, docket_id identifies a case, and the URL is a fallback:

import json
import pathlib

SEEN = pathlib.Path("seen_cases.json")
seen = set(json.loads(SEEN.read_text())) if SEEN.exists() else set()

def only_new(rows):
    fresh = []
    for r in rows:
        key = str(r.get("cluster_id") or r.get("docket_id") or r.get("url"))
        if key not in seen:
            seen.add(key)
            fresh.append(r)
    SEEN.write_text(json.dumps(sorted(seen)))
    return fresh

new_opinions = only_new(search_case_law("tortious interference", court="ca9"))

On cadence: opinions show up as courts release them and CourtListener indexes them, which can trail the decision date by a few days. A weekly run is enough for doctrine and topic monitoring. For live litigation you care about, run the docket search daily.

Grounding an LLM in Case Law

Invented citations are the main failure in any legal AI workflow. The fix is to give the model the scraper as a tool and require it to cite only records that came back from a tool call, with the source URL. A human can then click through and check every claim.

import json
import anthropic

claude = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY

tools = [{
    "name": "search_case_law",
    "description": (
        "Search US court opinions or dockets on CourtListener. Returns case name, "
        "court, filing date, docket number and a source URL for each result."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "query": {"type": "string"},
            "result_type": {"type": "string", "enum": ["opinions", "dockets"]},
            "court": {"type": "string", "description": "Optional court id, e.g. ca9"},
            "date_from": {"type": "string", "description": "YYYY-MM-DD"},
        },
        "required": ["query"],
    },
}]

SYSTEM = (
    "You are a legal research assistant. Use the tool to pull live court records. "
    "Cite every claim with the case name and the source URL from a tool result. "
    "Never cite a case that did not come back from the tool. If the tool returns "
    "nothing, say so. Your output is not legal advice."
)

def ask(question):
    messages = [{"role": "user", "content": question}]
    while True:
        resp = claude.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=4096,
            system=SYSTEM,
            tools=tools,
            messages=messages,
        )
        if resp.stop_reason != "tool_use":
            return "".join(b.text for b in resp.content if b.type == "text")
        results = []
        for block in resp.content:
            if block.type == "tool_use":
                rows = search_case_law(**block.input)
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": json.dumps(rows[:8], default=str),
                })
        messages.append({"role": "assistant", "content": resp.content})
        messages.append({"role": "user", "content": results})

print(ask("How have federal appeals courts treated non-compete enforceability since 2024?"))

Court opinions are public records, published for public access, so pulling and summarising them is fine. What comes out of this loop is research, not legal advice. Anything that feeds a real decision still goes to a qualified attorney.

Pairing Court Records with Rulemaking and Contract Data

Case law tells you how courts read existing rules. Two other public sources fill in the rest of a regulatory picture. The Federal Register Scraper covers proposed rules, final rules and notices by agency and date, so you can see what a regulator is about to require (final rules carry effective dates, proposed rules carry comment deadlines). The USASpending Federal Awards scraper shows which companies are winning federal contracts in a sector. Run all three on the same weekly schedule and hand the new records to the model above as extra tools, and you have a standing legal and regulatory brief. Note that the Federal Register covers federal rulemaking only, while CourtListener also includes many state courts.

Use Cases

Litigation intelligence. Track all new opinions mentioning a competitor, a patent number, or a regulatory topic. Set up scheduled runs and pipe results into a Slack notification or CRM.

Contract clause research. Pull opinions where courts have interpreted specific contract language. Feed the opinion text into an LLM to extract how courts rule on particular clause types across jurisdictions.

Legal RAG systems. Build a retrieval-augmented generation system over case law. Index opinion text into a vector database, then let users query in plain English and retrieve relevant precedents with citations.

Regulatory monitoring. Track how federal district and circuit courts are ruling on a specific regulatory regime. Useful for compliance teams watching litigation trends in their industry.

Academic research. CourtListener is used by legal scholars for empirical analysis: how sentencing patterns shift over time, how often circuit courts reverse district courts, how citation networks evolve.

The combination of free access, a documented REST API, and 10 million documents makes CourtListener one of the most underused data sources in the legal intelligence space. The data model takes 30 minutes to internalize. After that, the limits are computational, not access-related.

Related Actor

Explore the scraper referenced in this article: inputs, outputs, and pricing, then run it on Apify.

Apify Store

Find a ready-made scraper for your job

Apify has over 80,000 scrapers and automations, ours included. Start free with $5 of platform credit every month.