# Case Repository API: Complete Guide Case Repository is a REST API over the complete corpus of published U.S. court opinions (~10 million cases from ~3,400 courts, 1600s–present), including full opinion text and citation counts. Data is public domain, sourced from the Free Law Project's CourtListener bulk data. Base URL: https://caserepository.com/api/v1 All responses are JSON. All endpoints require an API key. A machine-readable OpenAPI description of every endpoint, parameter and error code is at https://caserepository.com/openapi.json, and a browsable rendering of it at https://caserepository.com/reference.html. This guide remains the authoritative account of what the fields mean. ## Contents Search this file for a heading to jump to it; error responses carry the exact heading of the section that explains them in their `section` field. Authentication Quick start Core concepts (read this first) How to find cases, and the one trap to avoid. READ THIS Choosing between /search, /cases and /exports. IMPORTANT GET /api/v1/meta: what is in the corpus GET /api/v1/courts: list courts GET /api/v1/courts/:id: one court GET /api/v1/cases: list and filter cases GET /api/v1/cases/:id: case detail GET /api/v1/cases/:id/text: full opinion text GET /api/v1/cases/:id/parentheticals: what a case holds GET /api/v1/cases/:id/similar: cases that read alike GET /api/v1/search: ranked search with snippets POST /api/v1/exports: bulk export (async job) GET /api/v1/exports/estimate: size an export before creating it GET /api/v1/stats: cases per year, for any selection Caching Rate limits Errors Recipes Field reference Common mistakes Notes and limitations ## Authentication Send your API key (starts with `ck_`) on every request, either way: Authorization: Bearer ck_yourkeyhere (preferred) ?api_key=ck_yourkeyhere (query param fallback) The header is preferred for a reason: a key in a URL ends up in places a header does not. This API keeps `api_key` out of its own logs, but it cannot keep it out of yours: query strings land in shell history, access and proxy logs, and browser history. Use the query param for a one-off `curl`; do not build it into a tool's plumbing, where it gets logged by something you did not think of. Test your key: GET /api/v1/whoami → {"name": "your-key-name", "requests_count": 123, "key_active_since": "...", "expires_at": null} `expires_at` is null for a key that never expires, which is most of them. Some keys (typically ones issued for a short-lived project) carry an expiry, and then it is an ISO 8601 timestamp. Check it before starting a long collection rather than discovering the wall partway through; expiry cannot be extended, so a key past it needs replacing. Keys are issued personally. To request one, replace one, or report a problem anywhere in the API, email nick@caserepository.com. A 401 says which of three things went wrong, because they have different fixes: `unauthorized` (no key, or one nothing recognizes; check how you are sending it), `key_expired` (a real key, past its expiry; ask for a new one; re-sending will not help), and `key_revoked` (a real key, deactivated, ask for it to be reactivated). Every response carries your current rate-limit state, so you can pace a long job instead of discovering the limit by hitting it: X-RateLimit-Limit: 300 requests allowed in the window X-RateLimit-Remaining: 288 how many you have left X-RateLimit-Reset: 1785536024 Unix seconds when the window resets Any HTTP client works: `requests`, `httpx`, `urllib`, `fetch`, `curl`. No particular User-Agent is required, though setting one that identifies your program is good manners and makes your traffic easier to recognize if you ever need support. **Do not set `Accept-Encoding` yourself.** `requests`, `httpx`, browsers and `curl --compressed` negotiate compression and decompress for you, so the correct number of lines to write about it is zero. That warning leads, rather than following an explanation, because the explanation did not work. Two separate tools built against this guide added `"Accept-Encoding": "gzip"` to the same dict as `Authorization`, as a courtesy, and neither inflated anything. Both failed on every call. The second did it with the inflate snippet below already in front of it; proximity is not the fix, so the advice is now simply: don't send it. **If you set that header by hand, every response can come back gzipped, not just export downloads.** Ordinary JSON is compressed too: `/meta`, `/whoami`, `/cases` and even a 404 body arrive as gzip bytes. Which responses get compressed is decided at the CDN and is not predictable from the endpoint or the size: measured on the live API, a 228-byte 404 was compressed while a 261-byte 422 was not. So there is one rule, and it is the same rule as for exports: **read the `Content-Encoding` response header** rather than assuming. This bites Python's built-in `urllib` specifically, because it sends nothing and decompresses nothing. Adding the header without adding the inflate is worse than leaving it off: the body arrives as gzip bytes and `json.loads(raw.decode("utf-8"))` dies with `UnicodeDecodeError: 'utf-8' codec can't decode byte 0x8b in position 1`, which names neither gzip nor this API. Either drop the header, or inflate: import gzip, json, urllib.request req = urllib.request.Request(url) req.add_header("Authorization", f"Bearer {KEY}") req.add_header("Accept-Encoding", "gzip") with urllib.request.urlopen(req) as r: raw = r.read() if r.headers.get("Content-Encoding", "").lower() == "gzip": raw = gzip.decompress(raw) data = json.loads(raw.decode("utf-8")) One more reason to test against this host rather than a local one: the Rails origin does not compress anything. Compression is added in front of it, so a client that works perfectly against a development server can fail on every single request here. ## Quick start Copy, paste, replace the key. This is a complete working program. curl: curl -H "Authorization: Bearer ck_yourkeyhere" \ "https://caserepository.com/api/v1/cases?court=scotus&min_citations=5000&per_page=3" Python (needs `pip install requests`): import requests KEY = "ck_yourkeyhere" BASE = "https://caserepository.com/api/v1" H = {"Authorization": f"Bearer {KEY}"} r = requests.get(f"{BASE}/cases", params={"court": "scotus", "min_citations": 5000, "per_page": 5}, headers=H) r.raise_for_status() for case in r.json()["cases"]: print(case["id"], case["date_filed"], case["case_name"]) JavaScript: const H = { Authorization: "Bearer ck_yourkeyhere" }; const r = await fetch( "https://caserepository.com/api/v1/cases?court=scotus&min_citations=5000&per_page=5", { headers: H }); const { cases } = await r.json(); cases.forEach(c => console.log(c.id, c.date_filed, c.case_name)); ## Core concepts (read this first) - A **case** is a court decision: name, court, date, citation count. One case can contain multiple **opinions** (majority, concurrence, dissent). - Cases may include BOTH a `010combined` opinion (the full document with everything in it) AND separate individual opinions (`020lead`, `030concurrence`, `040dissent`). The combined version usually CONTAINS the individual ones. Do not concatenate all opinions or you will double-count text. Prefer the combined document when present; otherwise use the individual opinions. - Opinion `type` codes sort in reading order as strings: `010combined` < `020lead` < `030concurrence` < `040dissent`. - **Courts** are identified by lowercase slugs: `scotus` (U.S. Supreme Court), `ca1`–`ca11` + `cadc` + `cafc` (federal circuits), state slugs like `mo`, `cal`, `ny`, `tex`. GET /api/v1/courts lists all valid IDs. Call it instead of guessing. - **Citation counts** measure how often other opinions cite this one, a good proxy for importance. (Highest in the corpus: Ashcroft v. Iqbal at ~150k.) Opinions, not cases, so `citation_count` runs a little above the number of cases `cites=` returns. See /cases. ## How to find cases, and the one trap to avoid. READ THIS Five ways in: - `name=` finds cases whose NAME contains your text. - `cite=` finds THE case by its reporter citation: `cite=559 U.S. 356`. Variant abbreviations are normalized (US, U. S., S.Ct. all work), and Westlaw/LEXIS cites (`2008 WL 305025`) resolve too. - `text=` finds cases by what their opinions SAY, with real query syntax: quoted phrases, OR, -exclusions, stemming (details under /cases). - `GET /search?q=` ranks the BEST matches for a query and returns a snippet of each: the preview form of `text=` (details under /search). - Everything else narrows by metadata: court, dates, citations, status, the citation graph. The trap: **unrecognized parameters do nothing.** None of these exist on /cases or /exports: ?q= ?search= ?case_name= ?query= ?keyword= ?order= A request carrying one is not rejected: the filters it does recognize still apply, and the response names what was dropped in a top-level `warnings` array, present only when something was ignored. Read it. It is the one line between the selection you meant and a 200 that quietly means something broader. `?sort=` is the one exception: it is real on GET /search (`sort=citation_count`), and on /cases, /stats and /exports it returns a 422 pointing you there rather than being ignored. It earned the exception by being the parameter people reach for most, and because ignoring it produced confidently wrong answers. `GET /cases?q=roe+v+wade` returns 200 with the first cases in the corpus and a warning naming `q=`: not an error, and nothing about Roe. The reflex that reaches for `q=` wants GET /search (where `q=` is real and ranked), or `name=` (a case you can name), or `text=` (words in the opinions, as a filter). Only parameters listed under each endpoint below do anything. Find a specific case by its name: GET /cases?name=roe+v.+wade See the best matches for a doctrine, phrase, or topic: GET /search?q="right of privacy" -abortion&court=scotus Select every case matching one, filterable and exportable: GET /cases?text="right of privacy" -abortion&court=scotus And to acquire everything matching, in bulk: the same `text=` works on POST /exports, so a search IS an export selection. ## Choosing between /search, /cases and /exports. IMPORTANT - "What are the best cases about X?" → GET /search (ranked top matches with snippets; bounded depth, no pagination) - Interactive lookup, filtering, browsing, displaying cases → GET /cases - Collecting MORE THAN ~500 cases, or full text for more than a handful of cases → POST /exports (an async bulk job that produces one file) NEVER write a loop that follows `next_url` through many pages to collect bulk data. You will hit rate limits and it is much slower than one export. Rule of thumb: if your code follows next_url more than a few times, use an export instead. ## GET /api/v1/meta: what is in the corpus Snapshot facts about the data itself. Use this instead of hardcoding a date: research that needs to be reproducible should record the exact snapshot it queried. GET /api/v1/meta → { "corpus": { "cases": 10070727, "courts": 3361, "snapshot_date": "2026-06-30", "coverage": {"earliest": "1658-07-01", "latest": "2026-06-30"}, "implausible_dates": 5, "source": "CourtListener bulk data (Free Law Project), public domain" }, "api": {"version": "v1", "docs": "https://caserepository.com/doc.txt"}, "generated_at": "2026-08-18T02:27:46Z" } `snapshot_date` is the snapshot's name, the value to record in a method section. `coverage.latest` is the newest real case under it, which is usually but not always the same day. `generated_at` is the moment the answer was built. Everything under `corpus` is a property of the snapshot, and **two requests against the same snapshot always agree**, however far apart. That is what makes this citable: record it in your method section and the numbers reproduce. `coverage` deliberately excludes records whose `date_filed` cannot be real: dated before 1600, or dated after the snapshot was taken. There are 5, upstream data errors, and they remain queryable: they are counted in `implausible_dates` rather than hidden, and are just not allowed to misreport the corpus as spanning year 19 to 2028. If you filter on dates, be aware those rows exist. The 17th-century reports are NOT among them: the corpus really does start in 1658, and those cases are coverage. The upper bound is the snapshot, not today's date, and the difference is not academic. Four of those five are dated in the future, the nearest 2026-07-01 and the furthest 2028-04-13. Bounded by the current date they would each be promoted to "the corpus's newest case" on the day they came around, so a mirror that had not changed since June would have reported its coverage ending 2026-07-01, then 2026-08-18, then 2026-10-20, each with a different `implausible_dates`. Bounded by the snapshot it reports 2026-06-30 today and in 2029. If you hold a coverage statement from this API dated before 2026-08-18, it was measured the old way; re-fetch it. ## GET /api/v1/courts: list courts Optional filters: `?jurisdiction=F` (F=federal appellate, S=state supreme, SA=state appellate, FD=federal district; the corpus carries other codes too, and an unrecognized one is an empty result rather than an error), and `?in_use=true` for courts still hearing cases. `in_use=false` gives the retired ones; omit it for all 3,361. Both take true/false/1/0 and reject anything else, rather than quietly ignoring it. 3,361 is every court the corpus can name, not every court it has cases from: 2,197 of them have at least one opinion, and the other 1,164 are valid slugs (mostly small state courts) that return an empty page from /cases. GET /api/v1/stats?court= tells you which kind you have before you build on it. GET /api/v1/courts?jurisdiction=S → {"count": 55, "courts": [ {"id": "mo", "short_name": "Supreme Court of Missouri", "full_name": "Supreme Court of Missouri", "jurisdiction": "S", "citation_string": "Mo.", "in_use": true, "start_date": "1821-01-01", "end_date": null}, ...]} ## GET /api/v1/courts/:id: one court GET /api/v1/courts/scotus → {"id": "scotus", "short_name": "Supreme Court", "full_name": "Supreme Court of the United States", ...} Court IDs are case-insensitive (SCOTUS works) but canonical form is lowercase. ## GET /api/v1/cases: list and filter cases Filters (all optional, combine freely): - `court=scotus`: court slug - `name=padilla`: case-insensitive substring match on the case name. At least 3 characters; `%` and `_` are matched literally. This is a filter, not relevance-ranked search: `name=padilla+v.+kentucky` finds the case, `name=miranda` finds every case with Miranda as a party. It is a **literal** substring match, and unlike `cite=` it normalizes nothing. Captions come from the source as printed, and many spell initials with spaces, so the obvious spelling of a famous case can miss it completely: GET /cases?name=chevron u.s.a. → three unrelated Chevron cases GET /cases?cite=467 U.S. 837 → Chevron U. S. A. Inc. v. NRDC The corpus stores that caption as `Chevron U. S. A. Inc.`, `u.s.a.` and `U. S. A.` are different strings and only `cite=` knows they mean the same thing. Captions are also sometimes truncated at the source ("v. Jennings" with no first party), so a search for the full caption can find nothing while a search for one distinctive word finds it. So: **if you know the citation, use `cite=`.** It is exact, it is one request, and it normalizes reporter spellings. Use `name=` to browse, to find a party, or when a citation is what you are missing, and give it one distinctive word rather than a full caption. - `cite=559 U.S. 356`: exact lookup by reporter citation, volume, reporter, page. Reporter abbreviations are normalized against Free Law Project's reporters-db (1,236 reporters, 2,369 variant spellings), so `550 US 1`, `550 U. S. 1` and `128 S.Ct. 999` all resolve; matching is case-insensitive. Westlaw and LEXIS cites work (`2008 WL 305025`). Give the citation's FIRST page: a pincite after a comma is ignored. Parallel citations find the same case, and a case's own `citations` array (on /cases/:id) round-trips back through this filter. A malformed cite or unrecognized reporter is rejected as `invalid_cite`, never silently empty. - `text=deliberate indifference`: full-text search over the opinions themselves. websearch syntax: `"quoted phrases"`, `OR` between alternatives, `-word` to exclude, plain terms AND-ed and stemmed (royalties matches royalty). Set semantics, not relevance ranking: it combines with every other filter, pages by cursor, and works on exports. For the BEST matches instead of ALL matches, put the same query in GET /search?q=. Warm queries answer in milliseconds regardless of how common the words are; a first cold touch of a large composed result can take a few seconds. Cases with no digitized text never match. Needs at least one non-stopword (`invalid_query` otherwise), max 256 characters (`query_too_long`); a snapshot built without search tables answers 503 `search_unavailable`. - `filed_after=2020-01-01` / `filed_before=2023-12-31`: date range - `min_citations=100`: only cases cited at least this many times - `max_citations=5`: only cases cited at most this many times. Combine with min_citations to select a salience band, e.g. routine cases nobody cites. - `status=Published`: precedential status. `Published` and `Unpublished` cover almost everything; `Unknown`, `Errata`, `Separate`, `Relating-to` and `In-chambers` also exist. An unrecognized value is rejected rather than silently matching nothing. - `cites=1723`: only cases that cite case 1723. This is how a case spread: 4,760 later cases cite Padilla v. Kentucky. - `cited_by=1723`: only cases that case 1723 itself cites, i.e. what it relied on. Padilla cites 58. These two are opposites and easy to swap by mistake. Both describe the case being *returned*: `cites=N` returns cases that cite N, `cited_by=N` returns cases cited by N. Getting them backwards returns plausible results rather than an error, so check the direction against a case you know. Both work in exports, so "every case citing X, with full text" is one export job. Neither filter returns case N itself. The underlying graph links *opinions*, so a case reaches itself whenever two of its own opinions are linked: usually the combined document citing the lead opinion it already contains, occasionally a dissent citing the majority. Collapsed to case level that would say a case cites itself, in both directions at once, so it is excluded. If you are comparing counts against a raw CourtListener extract, this is where a difference of one comes from. **`citation_depth` weights the edge.** Every result carries it: the number of times the citing opinion actually cites the other case. An edge is not an edge, roughly half of the 77 million in the corpus are a single passing mention, and the deepest run to several hundred. This is the field to rank by when you want to know what a decision was built on. Ranking those ancestors by their own `citation_count` instead measures fame across ten million cases rather than relevance to this one. The two disagree completely. District of Columbia v. Heller cited 111 cases; here they are ranked both ways: by citation_count (fame) by citation_depth (reliance) Miranda v. Arizona 58,288 d=3 United States v. Miller d=75 Mathews v. Eldridge 16,859 d=3 Presser v. Illinois d=20 NYT Co. v. Sullivan 8,531 d=3 United States v. Cruikshank d=19 The left column is a Second Amendment opinion's "leading influences" coming out as Miranda and a due-process case, each cited three times in passing. The right column is the Second Amendment line Heller actually argued from. 32 of those 111 ancestors sit at depth 1. It is the deepest single edge, not the sum over a case's opinions. A case reaches another through every opinion it contains, and the `010combined` document usually duplicates the `020lead` opinion inside it, so summing would double-count exactly the cases you care about most. It is absent, not null, when there is no single edge to describe: with no graph filter at all, and with BOTH `cites=` and `cited_by=` set, where a row sits on two different edges to two different cases. There is no total on the paginated response, but `GET /api/v1/stats` takes both filters and returns the count for free, usually in under a second. Ask it *before* ranking or summarizing a page of results, or you will not know whether you have the whole set: GET /api/v1/stats?cited_by=145777 → {"total": 111, ...} This matters because these results are ordered by `id`, not by importance. The first 100 rows of a 111-row set are an arbitrary slice, and nothing in the response says so. `cites=` does not agree with `citation_count`, and neither is wrong. `citation_count` counts citing OPINIONS; `cites=` returns distinct CASES, because a court whose majority and dissent both cite you is two opinions but one case. So Padilla reports `citation_count` 5,017 while `cites=1723` selects 4,760 cases. Measured across a dozen well-known cases the gap ran 0-20%, widest for the ones that draw separate opinions: Roe v. Wade is 5,581 against 4,568. `citation_count` is never the smaller of the two. Rank by `citation_count`; select with `cites=`. - `per_page=20`: page size, max 100 - `after_id` / `after_date`: pagination cursor (see below) GET /api/v1/cases?court=scotus&filed_after=2020-01-01&per_page=5 → { "cases": [ {"id": 9364466, "case_name": "Rutledge v. Pharm. Care Mgmt. Ass'n", "date_filed": "2020-01-10", "court": "scotus", "citation_count": 3, "precedential_status": "Published"}, ... ], "per_page": 5, "next_url": "https://caserepository.com/api/v1/cases?after_date=2020-01-13&after_id=9231300&court=scotus&filed_after=2020-01-01&per_page=5" } Pagination: request the `next_url` from the response verbatim. It carries your filters and the cursor already set. When `next_url` is null you have all results. There are no page numbers and no total count. Do not build the cursor yourself. Its shape depends on the query: - No date filter: results are ordered by `id` ascending and the cursor is `after_id` alone. - With `filed_after` or `filed_before`: results are ordered by `date_filed` ascending, and the cursor is `after_date` *plus* `after_id`. Both are required. `date_filed` is not unique, so `after_id` breaks ties and stops rows filed on the same day from being skipped or repeated. Sending `after_date` without `after_id` is rejected with a 422 rather than silently restarting from the first page. The reverse, a date-filtered request carrying only `after_id`, is a cursor from before date ordering existed and keeps its old meaning: that walk continues in id order. Date-filtered queries are ordered by date because sorting them by `id` instead makes the database scan millions of rows per page. Following `next_url` keeps you on the fast path regardless. Citation filters are the slowest thing in the API, because the citation graph has 77 million edges. Typical queries land in 30-330ms; the most heavily cited cases in the corpus, like Strickland v. Washington with 123,000 citations, take about 1.3 seconds on the first page. Combining with `court` or a date range makes them faster, not slower, by narrowing the set first. **Results come back in `id` order, so one page is an arbitrary slice, not a top-N.** This endpoint has no ranking at all. `id` order is close to insertion order and means nothing about importance, so the most-cited case on page one is *not* the most-cited case matching your filters: it is the most-cited of however many rows you happened to fetch. This is the single most expensive mistake made against this API. A real example: `?text=qualified immunity&min_citations=500&per_page=10` matches **1,284** cases. The first ten, sorted by citation count client-side, put *Citizens United v. FEC* on top. The genuine most-cited in that selection is *Ashcroft v. Iqbal* at 149,772, and the leading qualified-immunity case is *Harlow v. Fitzgerald* at 22,846. Neither is in the first ten, and nothing in the response says so. So, to get the most-cited cases for a query, pick one: - **GET /search?q=...&sort=citation_count**; the server does it, bounded and exact. This is almost always what you want. - **Collect the whole matching set and sort it yourself**: page through `next_url` for small sets, or run an export for large ones. Check the size first with GET /api/v1/stats, which takes the same filters. `sort=` is rejected here with a 422 rather than silently ignored, because sorting a single page is a wrong answer that looks like a right one. ## GET /api/v1/cases/:id: case detail GET /api/v1/cases/1723 → { "id": 1723, "case_name": "Padilla v. Kentucky", "date_filed": "2010-03-31", "citation_count": 5017, "precedential_status": "Published", "case_name_full": "Padilla v. Kentucky", "judges": "Stevens, Kennedy, Ginsburg, ...", "docket_id": 127914, "court": "scotus", "opinions": [ {"id": 1723, "type": "010combined", "author": "", "page_count": 40}, {"id": 9413157, "type": "020lead", "author": "Stevens", "page_count": null}, {"id": 9413158, "type": "030concurrence", "author": "Alito", "page_count": null}, {"id": 9413159, "type": "040dissent", "author": "Scalia", "page_count": null} ], "citations": ["176 L. Ed. 2d 284", "130 S. Ct. 1473", "559 U.S. 356", "2010 U.S. LEXIS 2928"] } The `citations` array holds this case's reporter citations (how lawyers cite it), not the cases it cites. It is ordered by citation type (the reporters a lawyer would cite come first, then specialty reporters, then LEXIS, Westlaw and neutral cites) and by reporter name within a type. The order is stable across requests. **`citations[0]` is not "the" citation.** The order keeps LEXIS and WL out of the front, but it does not rank U.S. Reports above S. Ct. or L. Ed. 2d: the corpus does not record which parallel cite is the official one, and this API will not guess. For Supreme Court cases the sequence is `L. Ed. 2d`, `S. Ct.`, `U.S.`, so the U.S. Reports cite is third: Padilla above is exactly that shape. To get a specific reporter, match on it rather than taking a position. Anchor the pattern on a trailing page number, or `1984 U.S. LEXIS 118` will match a search for a `U.S.` cite: import re US = re.compile(r"^\d+\s+U\.\s?S\.\s+\d+$") official = next((c for c in case["citations"] if US.match(c)), None) ## GET /api/v1/cases/:id/text: full opinion text Returns the complete text of every opinion in the case. There is no page size here and no cap (a case with several long opinions returns all of them in one response) so size it before you fetch in bulk. Measured across the corpus, a default response runs about 6KB at the median, 31KB at the 90th percentile, 83KB at the 99th and 176KB at the 99.9th; the largest found so far is 1.5MB, for a single opinion. `include_markup=true` adds the structured source described below, which is 52-58% of the payload wherever it exists: it roughly doubles every one of those figures. It is off by default for that reason. Ask for it when you are going to render the opinion; leave it off when you are analyzing the text, which is what `text` is for. GET /api/v1/cases/1723/text → { "case_id": 1723, "case_name": "Padilla v. Kentucky", "opinions": [ {"id": 1723, "type": "010combined", "author": "", "text_source": "plain_text", "text_length": 84441, "text": "(Slip Opinion) OCTOBER TERM, 2009 ..."}, ... ] } - `text_source` tells you which underlying column supplied the text (in practice plain_text or html_with_citations; HTML sources are stripped to plain text for you). The OCR'd Harvard reporter scans, which are most of the corpus, reach you as `html_with_citations`, so that source may contain minor OCR artifacts. - `markup` carries the best structured source raw, with `markup_source` naming its column. Present only with `include_markup=true`, and then null when the opinion has no structured source (plain text only). The two are different answers and are spelled differently: without the parameter the keys are absent altogether, so a null always means "this opinion has none", never "you didn't ask". `include_markup` echoes back at the top of the response. Takes true/false/1/0 and rejects anything else. **`markup` is not one format.** It arrives in three dialects, and the response does not tell you which one you got. Measured on a random sample of the corpus: - Harvard XML, about 64% of cases. Real structure: ``, ``, and a `

` per paragraph. This is what you get wherever the case came from the Harvard reporter scans. - A single `

` typescript, about 21% of cases. An OCR'd page poured
    into one block, with NO paragraph structure at all. Code that assumes
    `

` will find none here. - Legacy reporter HTML, about 13% of cases. Has `

`, but those tags are sometimes typographic LINES rather than paragraphs. Two more things to know before writing a parser: - **Section tags do not reliably mean what they say.** `` sometimes wraps an entire opinion, and `class="parties"` sometimes holds the court's non-precedential notice rather than party names. Dropping such tags by name deletes real opinion text in roughly 2% of documents, silently. - **A `

` typescript is split around every citation.** One sentence
    arrives as `
`,
    so those blocks are fragments of running text, not units of anything.

  If you render markup, sanitize it first. For text analysis `text` is
  the field you want. For the citation graph, use `cites=`/`cited_by=`
  rather than parsing these spans: the resolved edges are already there
  and are more complete than what the markup exposes.
- `text` can be null for rare cases with no digitized text.
- Text may contain page markers like `*359`. These are U.S. Reports
  page numbers, useful for pinpoint citations.
- Remember the combined-vs-individual overlap rule from Core Concepts.

## GET /api/v1/cases/:id/parentheticals: what a case holds

One-line statements of what a case stands for, written by later courts that
cited it, scored by how good a summary CourtListener judged each to be. Best
first.

    GET /api/v1/cases/1723/parentheticals?per_page=3
    → {
        "case_id": 1723, "case_name": "Padilla v. Kentucky", "per_page": 3,
        "total": 579,
        "parentheticals": [
          {"text": "holding that counsel has a duty under the Sixth Amendment to inform a noncitizen defendant that his plea would make him eligible for deportation",
           "score": 0.9557,
           "describing_case": {"id": 626207, "case_name": "Vartelas v. Holder",
                               "date_filed": "2012-03-28"}}
        ]
      }

This is the most compact answer available to "what did this case decide", and
unlike the opinion text it comes from courts applying the case rather than the
court that wrote it.

Notes:

- Most cases have none. About 1.2 million opinions are described, out of ten
  million, concentrated in cases that get cited. An empty `parentheticals`
  array is the normal answer, not an error.
- `score` runs 0 to 1 and reflects how well the phrase works as a standalone
  summary. Above ~0.9 is reliably quotable.
- Near-duplicate entries are common: several courts often paraphrase the same
  holding, and the same opinion can appear twice with different punctuation.
  De-duplicate on text if you need distinct statements.
- `total` is how many exist, which is not how many you got. `per_page`
  works as it does on /cases and stops at 100, and there is no pagination
  cursor, so **100 is a hard ceiling on what this endpoint will ever
  return for one case.** Compare `total` against the array's length to
  know whether you are holding a complete set:

      Padilla v. Kentucky   total    579   reachable 100
      Roe v. Wade           total    482   reachable 100
      Ashcroft v. Iqbal     total 15,358   reachable 100

  Without that comparison a full page is indistinguishable from a
  complete one. Do not rank, count or characterise a case's holdings from
  a truncated set: for the heavily cited cases, which are the ones worth
  asking about, you are seeing well under 1% of what exists.

  The scores are what makes the ceiling tolerable: results come back best
  first, so the 100 you get are the 100 CourtListener rated highest as
  standalone summaries. That is a good sample for reading and a bad one
  for counting.

- The whole 6.4M-parenthetical set is reachable only one case at a time,
  one request per case, capped at 100 each. There is no way to filter
  /cases or /exports for "has parentheticals", and `include_parentheticals`
  does not exist on exports. `min_citations=` is a workable proxy for
  finding cases that have them, since parentheticals concentrate in cited
  cases, but it is a proxy and not a guarantee.

## GET /api/v1/cases/:id/similar: cases that read alike

Cases whose text is nearest this one in meaning, rather than in words.
Backed by embeddings of the opinion text, so it finds a case about the
same idea even when it shares no vocabulary with yours.

    GET /api/v1/cases/111170/similar?per_page=5

    {
      "id": 111170,
      "case_name": "Strickland v. Washington",
      "results": [
        {"id": 112665, "case_name": "...", "date_filed": "1986-01-14",
         "court": "scotus", "citation_count": 4021,
         "precedential_status": "Published", "similarity": 0.8231}
      ],
      "per_page": 5,
      "method": "..."
    }

`similarity` is cosine similarity in [0,1]; 1.0 would be an identical
vector. In practice a strong neighbour scores around 0.75-0.85, and the
number is only meaningful for ranking within one response. Do not compare
scores across two different cases' results.

Parameters: `per_page` (default 10, max 200). Deeper than the other
endpoints on purpose: similarity decays slowly, so the 200th neighbour
is often still on topic. No cursor: this is a neighbourhood, not a
list to page through.

Three things worth knowing before you build on it:

- **This is slower than every other endpoint**, on purpose. The index does
  not fit in memory, so a case nobody has asked for recently takes on the
  order of a second, and a repeat of the same case is fast. Cache the
  answer, and do not put it in a loop over many cases.
- **Similar is not the same as relevant.** Two cases can read alike and
  have nothing to do with each other precedentially. Nothing here walks
  the citation graph. For cases that cite or are cited by this one, use
  `cites=` and `cited_by=` on /cases, which are real edges.
- **Coverage is about 83% of opinions.** A case whose opinions have no
  embeddings returns an empty `results` array rather than an error, the
  same way `text=` silently excludes cases with no digitized text.

If the embedding store is unavailable the endpoint answers 503
`similar_unavailable` and every other endpoint is unaffected.

## GET /api/v1/search: ranked search with snippets

The preview form of full-text search: the top matches for a query, best
first, each with a snippet showing why it matched. Use it to see what a
query finds and to refine it cheaply: then collect the full result set
with `text=` on /cases or /exports.

    GET /api/v1/search?q="qualified immunity" -prison&court=ca8
    → {
        "query": "\"qualified immunity\" -prison",
        "sort": "relevance",
        "sorts": "sort=relevance (the default) ranks by how well the text
                  matches; sort=citation_count ranks by how often later
                  cases cite each result, ...",
        "results": [
          {"id": 2680601, "case_name": "Ron Nord v. Walsh County",
           "date_filed": "2014-06-26", "court": "ca8", "citation_count": 75,
           "precedential_status": "Published",
           "rank": 4.9,
           "snippet": "claims. Wild moved for summary judgment based upon
                       **qualified** **immunity** as to Nord's First
                       Amendment claim, which the district … Wild earned"},
          ...
        ],
        "per_page": 20,
        "match_estimate": {"count": 1546, "exact": true},
        "bulk": "Search previews the top matches only (at most 100, no
                 pagination). To download every matching case, POST
                 /api/v1/exports with text= plus the same filters."
      }

- `q=` is the query, same websearch syntax and same 256-character limit
  as `text=`: quoted phrases, OR, -exclusions, stemming.
- Every /cases filter combines with it: court, dates, citations, status,
  even the citation graph. `q="excessive force"&cites=112257` ranks the
  cases discussing excessive force that cite Graham v. Connor.
- **`sort=` picks the ordering, and the default is not importance.**

      sort=relevance       (default) best TEXT match, by ts_rank_cd
      sort=citation_count  most-cited first

  Default ranking is `ts_rank_cd` (cover density: rewards matched words
  appearing close together, often). `rank` is comparable within one
  response only. It measures how well the text matches, **not how
  important the case is**, and the two are very different:

      GET /search?q=qualified immunity
        1. Maestas v. State of Colorado            178 citations

      GET /search?q=qualified immunity&sort=citation_count
        1. Ashcroft v. Iqbal                   149,772 citations
        2. Monell v. New York City Dept.        41,449
        3. McDonnell Douglas Corp. v. Green     38,518
        4. Farmer v. Brennan                    28,443
        5. Harlow v. Fitzgerald                 22,846

  *Harlow v. Fitzgerald*, which established qualified immunity, is
  nowhere near the relevance-ranked first page, but it is fifth here,
  and Pearson v. Callahan (15th), Anderson v. Creighton (18th) and
  Mitchell v. Forsyth (25th) are all on this one page too. If you are
  looking for the leading cases on a doctrine rather than the best
  phrasing matches, use `sort=citation_count`. If you are refining a
  query to see what it catches, leave the default.

  Read the page, not the first row. The top three are a pleading
  standard, municipal liability and Title VII burden-shifting: none of
  them a qualified-immunity case. Matching is lexical, so
  `sort=citation_count` ranks the doctrine's canon and the giants that
  merely brush past it on one list. No parameter separates those two; a
  human reading twenty rows does it easily.

  **And the query above is unquoted, which matters more than it looks.**
  Bare `q=qualified immunity` requires both words somewhere in the case,
  not next to each other. McDonnell Douglas ranks third on it while
  containing the phrase zero times: it has "qualified mechanics" and
  "employer immunity", pages apart. Quote it and the result set changes:

      q=qualified immunity      → Iqbal, Monell, McDonnell Douglas, Farmer, Harlow
      q="qualified immunity"    → Iqbal, Monell, Farmer, Harlow, Illinois v. Gates

  Neither is wrong; they are different questions. Quote the phrase when
  you mean the phrase. A CLI that passes a user's string through
  untouched will pick between these on whether that person happened to
  type quotes, with a plausible answer either way and no error.

  `sort=citation_count` stays exact past the 10,000 cap: candidates are
  taken as the most-cited matches rather than in id order, so nothing
  outside the pool can outrank what is in it. Only `match_estimate.count`
  becomes a floor. An unrecognized value is rejected as `invalid_sort`.

  `sort=` works **only here**. On /cases, /stats and /exports it is a 422,
  because those return rows in id order and sorting one page of them is a
  wrong answer that looks like a right one.
- Snippets bold matches with `**word**` and join fragments with `…`.
- `per_page` up to 100, default 20. There is NO cursor and no deeper
  page: search depth is bounded by design, because collecting more than
  the top matches is what exports are for. The same query as `text=` on
  POST /exports downloads every match.
- `match_estimate` says how many cases match. `"exact": true` means the
  count is the whole truth and every match was ranked. `"exact": false`
  appears past 10,000 matches: the count is a floor, and a query that
  common usually wants narrowing or a bulk export anyway.

  When `exact` is false the response also carries **`note`, a top-level
  string** beside `match_estimate`, saying what the cap did to this
  particular answer: the two orderings are affected differently, so read
  it rather than assuming. The example above is scoped by `court=` and
  matches 1,546, so it does not have one; drop the `court=` and it does:

      "match_estimate": {"count": 10000, "exact": false},
      "note": "More than 10000 cases match, so match_estimate.count is a
               floor rather than a total. The ORDER is unaffected:
               candidates were taken as the 10000 most-cited matches …"
- Errors are the `text=` vocabulary plus two of its own: `missing_query`
  (422, no q=) and `unsupported_parameter` (422, text= on /search, put
  the query in q=). `search_timeout` (503) means the query hit the
  15-second backstop. **Add `court=` and retry.** Do not retry the same
  query unchanged: it hit a hard limit and will hit it again.

  `court=` is the filter measured to turn this timeout into an answer
  most often, because it bounds the work by the size of a single court
  rather than by how much of the corpus the query has to walk. It is a
  bound, not a speed-up: it can still take seconds.

  `search_busy` (503) is the other 503 here and means the opposite.
  Only so many heavy reads run at once (/search shares the pool with
  /stats, /cases carrying text= or a citation filter, /exports/estimate
  and /similar); past that the server refuses immediately rather than
  making you wait behind them, so that the rest of the API keeps
  answering. Your query is fine. Wait `retry_after_seconds` and send it
  again unchanged. The way to avoid it is to stop issuing these in
  parallel: the rate limits below cap how often you may ask, not how
  many you may ask at the same moment, and running them one after
  another is both faster overall and what the limits assume you are
  doing.

      q="excessive force"                    503 after 15.4s
      q="excessive force"&court=ca8          200 in 7.3s, 1,005 matches
      q="clear and present danger"           503 after 15.3s
      q="clear and present danger"&court=ca8 200 in 6.8s, 46 matches

  **A bound is only as tight as the court.** The biggest courts are big,
  and scoping to one does not always rescue a common phrase; pick the
  obvious court for a doctrine and you may pick a large one:

      q="clear and present danger"&court=scotus  503 after 15.3s
      q="clear and present danger"&court=ca8     200 in 5.7s

  That is not backwards. `scotus` holds 589,688 dockets to `ca8`'s
  103,612, so bounding by it barely bounds anything; court size, not the
  court's prominence in a doctrine, is what decides. When `court=` is
  already set and the query still times out, adding a required word is
  what is left, and it is decisive: the same scotus query answered in
  **0.66s** with one more word (`q="clear and present danger" espionage`).
  The 503 body says this too, and says it differently depending on
  whether you already sent a `court=`.

  Quoted phrases are what usually times out here, and they are where
  `court=` helps most. The same two words, quoted and unquoted:

      q="qualified immunity"                 200 in 9.6s
      q="qualified immunity"&court=ca8       200 in 1.4s   (6.6x faster)
      q=qualified immunity                   200 in 2.9s
      q=qualified immunity&court=ca8         200 in 5.9s   (2x slower)

  So on an unquoted query that already answers quickly, `court=` is a
  small tax and is not worth adding for speed. On a query that timed
  out, add it. A date range does **not** rescue a timed-out search: it
  narrows the answer without bounding the work.

  A more selective `q=` (a longer phrase, or another required word)
  also works, because the cost is driven by how many cases match the
  text rather than by how many come back.
- Throttled separately: 60 searches per 5 minutes per key, tighter than
  the general limit. The X-RateLimit-* headers always show whichever
  limit you are closest to.

## POST /api/v1/exports: bulk export (async job)

Creates a background job that writes matching cases to a JSONL file
(one JSON object per line). Same filters as /cases, plus:
- `include_text=true`: include full opinion text per case.
  Text exports are limited to 10,000 cases per job; metadata-only
  exports to 1,000,000. Either way you get `export_too_large` before
  the job is created, so narrowing costs you nothing. Max 3
  queued/running jobs at once, max 10 export jobs per hour.

  Size and time, measured on federal appellate cases:

      metadata only     ~150 bytes/case   ~6,000 cases/second
      include_text=true ~14 KB/case         ~150 cases/second

  So a 1,000,000-case metadata export is roughly 150 MB and finishes in
  about 3 minutes; a 10,000-case text export is roughly 140 MB and
  finishes in a minute or two. Both are far inside the job timeout:
  size is what to watch, not time. Those are uncompressed figures; the
  file you download is gzipped, so expect roughly a third of that over
  the wire for text, and about an eighth for metadata.

  Opinion length varies enormously. Supreme Court opinions run 50–200 KB
  each, while many circuit dispositions are a page. A text export of
  10,000 SCOTUS cases is many times larger than 10,000 circuit cases, so
  treat 14 KB as a working average, not a guarantee. Prefer metadata-only
  unless you actually need the text.

- `format=csv`: metadata-only exports can be written as CSV instead of
  JSONL, for the tools that read tables rather than JSON: Excel, R,
  Stata. The columns are the JSONL keys in their order (id, case_name,
  date_filed, court, citation_count, precedential_status, plus
  citation_depth when exactly one of cites=/cited_by= is set). UTF-8
  with a byte order mark so Excel reads party names correctly; RFC 4180
  quoting; CRLF row endings. One honest loss: CSV cannot distinguish a
  null from an empty string, both arrive as an empty cell. JSONL keeps
  the difference and stays the default. With include_text=true this is
  rejected (`invalid_format`): opinions are a nested array and do not
  fit rows. The file downloads under the same compression rules as
  JSONL, named .csv or .csv.gz by the same Accept-Encoding logic.

Workflow (three steps, implement all three):

    1. POST /api/v1/exports?court=scotus&filed_after=2020-01-01
       → 202 {"id": 2, "status": "queued", "download_url": null,
              "poll": "Poll GET /api/v1/exports/2 every few seconds
                       until status is 'completed', ...", ...}

    2. Poll until complete (every 2–5 seconds):
       GET /api/v1/exports/2
       → {"id": 2, "status": "completed", "record_count": 3994,
          "file_size_bytes": 64701, "corpus_snapshot": "2026-06-30",
          "download_url": "https://caserepository.com/api/v1/exports/2/download", ...}
       Statuses: queued → running → completed (or failed, with an
       "error" field explaining why).

    3. GET the download_url → the JSONL file.

`corpus_snapshot` is the snapshot the file was built from, the same
value /meta calls snapshot_date. Record it beside the job's
query_params and a result is re-derivable: the params alone are not
enough, because re-running them against a later snapshot selects
different rows. It is null until the job starts running, and on jobs
run before the field existed.

Export files are stored and sent gzipped, roughly 3-8x smaller.

The bytes are gzipped either way. What changes is **how they are
labeled**, and there are two labelings, not one:

- Send `Accept-Encoding: gzip` and you get `Content-Encoding: gzip` on a
  file named `.jsonl`. `requests`, `httpx`, browsers and
  `curl --compressed` do this by default and inflate it for you, so you
  read plain JSONL and there is nothing to change.
- Send anything else, or no `Accept-Encoding` at all, and you get the
  same gzip bytes with **no `Content-Encoding`**, named `.jsonl.gz` and
  typed `application/gzip`. Nothing inflates them for you; gunzip it.
  That is the origin's behavior; through https://caserepository.com
  Cloudflare asks the origin for gzip on your behalf and inflates it, so
  a client that sent no `Accept-Encoding` receives plain bytes named
  `.jsonl` or `.csv` with no `Content-Encoding`. Either way the bytes
  match the name you were given; trust the filename, not this paragraph.

So do not test `Content-Encoding` alone and conclude the body is plain:
on that second path there is no such header and the payload is still
gzip. **Either send `Accept-Encoding: gzip` and let your client inflate,
or treat the download as gzip and inflate it yourself.** What is never
done is handing gzip bytes over labeled `.jsonl` with no
`Content-Encoding`, because that is the one combination that corrupts
silently; the filename and content type always say what you have.

(Exports written before compression was added are stored as plain
`.jsonl` and are still served that way until they expire.)

Python's `urllib` is the case worth being careful with: it sends no
`Accept-Encoding` and never decompresses, so left alone it takes the
second path above and writes gzip bytes into whatever file you opened. Inflate it as you stream, which also
keeps a multi-GB export off the heap. Because it tests the header rather
than assuming, this is the shape to copy in any client:

    import urllib.request, zlib

    req = urllib.request.Request(download_url)
    req.add_header("Authorization", f"Bearer {KEY}")
    req.add_header("Accept-Encoding", "gzip")

    with urllib.request.urlopen(req) as r, open("export.jsonl", "wb") as f:
        gz = r.headers.get("Content-Encoding", "").lower() == "gzip"
        dec = zlib.decompressobj(16 + zlib.MAX_WBITS) if gz else None
        while chunk := r.read(1 << 20):
            f.write(dec.decompress(chunk) if dec else chunk)
        if dec:
            f.write(dec.flush())

With `requests` there is nothing to do; `r.content` is already plain JSONL.

JSONL parsing: read line by line; each line is one case object. With
include_text=true each case object also has an "opinions" array with
full text (same shape as /cases/:id/text, without `text_length`).

Python example:

    import requests, time, json
    H = {"Authorization": "Bearer ck_yourkey"}
    BASE = "https://caserepository.com/api/v1"

    r = requests.post(f"{BASE}/exports",
                      params={"court": "scotus", "filed_after": "2020-01-01"},
                      headers=H).json()
    while r["status"] not in ("completed", "failed"):
        time.sleep(3)
        r = requests.get(f"{BASE}/exports/{r['id']}", headers=H).json()
    if r["status"] == "failed":
        raise RuntimeError(r["error"])
    data = requests.get(r["download_url"], headers=H)
    cases = [json.loads(line) for line in data.text.splitlines()]

Large text collections (>10,000 cases): split into multiple export jobs
by date range and run them sequentially (max 3 concurrent). Pick the
slice boundaries from GET /exports/estimate and GET /api/v1/stats (both
below) instead of guessing: stats shows the per-year counts for your
exact selection, so you can cut it into jobs that each fit. Each job's
file contains complete, untruncated text.

GET /api/v1/exports lists your recent export jobs. Export files expire
**one day** after the job finishes, so download as soon as it completes:
the three-step loop above already does. Expiring costs you nothing but
time: the job record keeps its `query_params`, and the 410 names them, so
POSTing the same filters rebuilds the same file. Most exports regenerate
in seconds.

## GET /api/v1/exports/estimate: size an export before creating it

The same count the server runs before accepting an export, available on
its own. Costs no quota: no job is created, no export quota is spent, and
it does not count toward the 10-per-hour creation limit. Same filters as
POST /exports, including include_text (which decides the applicable cap).

Free is not the same as instant. This runs a real count against the
corpus and carries the same 15-second backstop as everything else, so a
`text=` selection broad enough to be worth checking is also broad enough
to time out here: `text="excessive force"` answers `query_timeout`, not
a number. Scope it the same way you would scope /stats:

    GET /exports/estimate?text="excessive force"             503 after 15.3s
    GET /exports/estimate?text="excessive force"&court=ca8    200 in 0.6s, 1,005

    GET /api/v1/exports/estimate?court=kan&text=royalty&include_text=true
    → {
        "estimate": {"count": 665, "exact": true},
        "include_text": true,
        "limit": 10000,
        "fits": true,
        "query_params": {"court": "kan", "text": "royalty", "include_text": true}
      }

`fits: true` means the POST with these exact parameters will be accepted.
When the selection is over the cap, `estimate.count` is a floor
(`"exact": false`; counting past the cap is never done), `fits` is
false, and `message` explains the options: split by date range (see
/stats, next), narrow the filters, or drop include_text for the higher
metadata cap.

## GET /api/v1/stats: cases per year, for any selection

Per-year counts over the same filters as everything else. This is the
export-splitting tool: when an export is over its cap, /stats shows where
the cases actually are, so date-range slices can be chosen once instead
of discovered by trial and error. It is also the fastest way to chart a
docket's shape over time.

    GET /api/v1/stats?court=kan&text=royalty
    → {
        "total": 665,
        "by_year": [{"year": 1875, "count": 1}, ...,
                    {"year": 2019, "count": 6}, ...],
        "undated": 0,
        "query_params": {"court": "kan", "text": "royalty"}
      }

- `by_year` is ascending and only contains years with matches.
- `undated` counts matching cases with no filing date; `total` is
  `by_year` plus `undated`, always.
- A handful of corpus records carry impossible dates (year 19, or the
  future); they appear under their recorded years. See /meta.
- A very broad selection can take a few seconds. The backstop is the
  same 15-second timeout /search carries (`stats_timeout` 503). Narrow
  it and retry with a **different** query: repeating the same one hits
  the same limit.

  `court=` is the most reliable narrowing here too, for the same reason
  as on /search: it bounds the work by the size of one court. Measured,
  with `court=` run first on a cold index and the bare form run twice
  afterwards on a warm one, so the cache cannot flatter the result:

      text="abuse of discretion"&court=ca8            200 in 4.8s
      text="abuse of discretion"                      503, then 503
      text="preponderance of the evidence"&court=ca8  200 in 4.7s
      text="preponderance of the evidence"            503, then 503

  A date range also cuts the work sharply (`filed_after=`/`filed_before=`
  turns the scan into an index range) but for the most common phrases it
  is not enough on its own.

- Common phrases are the queries that fail here. `text="reasonable
  person"`, `"substantial evidence"`, `"rational basis"` and the like
  match so much of the corpus that no unfiltered count of them will
  complete. Scope them with `court=` and they answer in seconds. This is
  a real limit of the API, not a transient one: there is no unfiltered
  count of the most common phrases in American law.

## Caching

Corpus-derived responses carry `Cache-Control: max-age=3600, private` and
an `ETag`. That is /cases and everything under it, /courts, /meta,
/search, /stats, and /exports/estimate: every answer that depends only
on what is in the corpus, not on who is asking. The
corpus changes only at a quarterly refresh, so cached copies stay good
for a long time: reuse a response for up to an hour without re-asking,
and after that revalidate with `If-None-Match`: a `304 Not Modified`
costs the server almost nothing and does not re-read the corpus.
Most HTTP libraries with a caching layer handle all of this for you.

Revalidations still require your API key and still count toward rate
limits. Per-key endpoints (/whoami, and the export job endpoints (POST
/exports, GET /exports, GET /exports/:id and the download)) are never
cached. /exports/estimate is the exception that proves the rule: it lives
under /exports but its answer is a count of the corpus, the same for
everyone, so it caches like the rest. Per-key answers are never cached: their
answers are yours alone and change as you act.

## Rate limits

Per API key: 300 requests per 5 minutes (general), 60 ranked searches
per 5 minutes on /search, 10 export creations per hour, 3 concurrent
export jobs. There is also a floor of 300 requests per 5 minutes per IP
address, which only matters if you drive several keys from one machine.
Exceeding a limit returns 429 with a `Retry-After` header
and a JSON body stating how long to wait. The X-RateLimit-* headers
always describe the limit you are closest to exhausting. If you hit the
general limit while collecting data, you should be using an export
instead.

The export-creation limit counts POSTs, not jobs: a POST /api/v1/exports
that is rejected with a 422 (bad format=, filters that are too large)
still spends one of the ten. Check GET /api/v1/exports/estimate first,
which is free, rather than trying filters against the create endpoint.

To see what you have already spent rather than what is left, GET
/api/v1/whoami returns `requests_count` for your key: the number of
requests it has made before this one. It is exact, and it is the cheapest
way to audit a development session that felt busier than it should have.

## Errors

All errors are JSON with this shape:

    {"error": "machine_readable_code",
     "message": "Human/LLM-readable explanation with the fix",
     "docs": "https://caserepository.com/doc.txt#cases",
     "section": "## GET /api/v1/cases: list and filter cases"}

This file is served as plain text, so the `#cases` on the end of `docs` does
not scroll your browser anywhere, it loads the whole guide. That is what
`section` is for: it is the section's heading copied exactly, so you can
search this file for it. Search for the `section` string, not the anchor.

One exception: the catch-all 404 for a path that is not an endpoint at all
carries `error`, `message` and `docs` but no `section`, because no section
of this guide describes a URL that does not exist. Every error from a real
endpoint has all four. Treat `section` as present-or-absent rather than
guaranteed.

The 429 from a rate limit carries one more key, `retry_after_seconds`,
alongside the `Retry-After` header.

Codes you may see: `unauthorized` (401, bad/missing key),
`key_expired` (401, a valid key past its expiry; GET /whoami reports
`expires_at` while a key still works),
`key_revoked` (401, a valid key that has been deactivated),
`unknown_court` (422, invalid court slug on /cases, /search, /stats or
/exports. GET /courts for valid IDs),
`court_not_found` (404, invalid court slug on GET /courts/:id),
`invalid_date` (422, a date parameter is not ISO 8601, use 2020-01-31),
`invalid_cite` (422, a cite= that is not volume-reporter-page, or whose
reporter nothing recognizes),
`invalid_integer` (422, min_citations/max_citations/per_page/after_id is
not a non-negative whole number; a non-numeric value is never silently
coerced, so a typo is reported rather than ignored. An out-of-RANGE
per_page is different: it is clamped to the maximum, not rejected, and
the response echoes the `per_page` actually used, `per_page=500` returns
100 and says `"per_page": 100`. Read the echo rather than assuming you
got what you asked for),
`invalid_name` (422, a name= shorter than 3 characters),
`invalid_status` (422, unrecognized status, the message lists the valid
values),
`invalid_cursor` (422, after_date sent without after_id; follow next_url
verbatim rather than building the cursor yourself),
`invalid_query` (422, a text= or q= query whose every word is a stopword
parses to nothing),
`query_too_long` (422, text= or q= over 256 characters),
`invalid_parameter` (422, a parameter carries a null byte, which nothing
here accepts; the message names it),
`search_unavailable` (503, this corpus snapshot was built without the
search tables; every other filter still works),
`missing_query` (422, /search without q=),
`invalid_sort` (422, an unrecognized sort= on /search; valid values are
relevance and citation_count),
`unsupported_parameter` (422, a parameter the endpoint cannot honor:
text= on /search (put the query in q=; or sort= on /cases, /stats or
/exports) it works on /search),
`search_timeout` / `stats_timeout` (503, the query exceeded its
15-second backstop. Add `court=` and retry; do NOT retry the query
unchanged, it will hit the same limit),
`search_busy` (503, the opposite advice: the server is already running
its cap of heavy corpus reads and refuses to queue more so that cheap
requests keep answering. It can come from /search, /stats, /cases with
text= or a citation filter, /exports/estimate and /cases/:id/similar,
which share one pool. Nothing is wrong with your query. Wait
`retry_after_seconds` and retry it UNCHANGED; narrowing it does not
help. If you are firing many of these at once, run them one at a time,
or use POST /api/v1/exports, which is not subject to this cap),
`similar_unavailable` (503, /cases/:id/similar only: the embedding store
is absent from this deployment or could not be reached. Every other
endpoint is unaffected; if the message says it could not be reached,
retry shortly),
`query_timeout` (503, the same thing on any other endpoint; usually
cites=/cited_by= on a heavily cited case; narrow it, or run the same
selection as an export, which has no request timeout),
`invalid_jurisdiction` / `invalid_in_use` (422, on GET /courts, only
when the value is not a plain string, e.g. `jurisdiction[]=F`; an
unrecognized code like `jurisdiction=ZZ` is an empty 200, not an error),
`invalid_include_text` / `invalid_include_markup` (422, must be true,
false, 1 or 0; never silently coerced),
`invalid_format` (422, format= on POST /exports takes jsonl or csv, and
csv only without include_text: opinions nest and rows do not),
`case_not_found` / `export_not_found` (404),
`endpoint_not_found` (404, no endpoint at that path; the message lists
every valid one; note the plurals, /cases and /exports),
`export_too_large` (422, narrow your filters or drop include_text),
`export_not_ready` (409, the export is still queued or running, keep
polling GET /exports/:id until status is "completed"),
`export_expired` (410, file past its one-day expiry, create a new export
with the same filters; the message gives them back to you),
`too_many_active_exports` (429, wait for jobs to finish),
`rate_limited` (429, wait Retry-After seconds).

Error messages are written to be self-explanatory. Read the `message`
field and follow its instructions.

Warnings are separate from errors. A 200 whose request carried a
parameter the endpoint does not recognize gains a top-level `warnings`
array of plain sentences, one per ignored parameter. The key is absent
when there was nothing to warn about, so its presence is itself the
signal: a warned 200 answered a different question than the one you
meant to ask.

**The 15-second backstop bounds query execution, not request latency.**
It is a database statement timeout, and it starts when the query starts,
not when your request arrives. A request can queue in front of it, so the
wall-clock time before a `503` comes back has been observed at well over
a minute. Do not set your HTTP client timeout to 15 seconds: you will
sever connections the server is still working on. 120 seconds is a safer
ceiling for /search, /stats and /exports/estimate.

/search is the one endpoint that now refuses rather than queues: past its
concurrency cap you get `search_busy` in milliseconds instead of a long
wait, so its wall clock sits much closer to the backstop than the minute
above. /stats and /exports/estimate still queue, and the 120-second
advice is written for them.

Timeouts are also not fully deterministic near the limit: a query that
costs close to 15 seconds may succeed once and fail the next time with
nothing changed. Well past the limit it fails every time. The error does
not distinguish the two, so treat a repeated identical failure as
"narrow it", not "try again".

## Recipes

**"Best cases about a doctrine":**
GET /search?q="qualified immunity" -prison, read the snippets, refine
the query, add filters (court=, filed_after=) until the top results are
what you mean. Then take ids directly, or feed the final query to an
export for the full set.

**"Most important cases on X court since year Y":**
GET /cases?court=Y&filed_after=YYYY-01-01&min_citations=500, paginate via
next_url until it is null, THEN sort by citation_count client-side. Sort
the complete set, never a single page: /cases returns rows in id order.
If you only want the leading cases and have a query for them, GET
/search?q=...&sort=citation_count does it server-side in one request.

**"Full text of one case I know by name or citation":**
GET /cases?cite=384+U.S.+436 if you have the citation, exact, one
result, and the reason to prefer it.

With only a name, expect several matches and look at them.
`name=brown+v.+board+of+education` returns **64**: `name=` is a literal
substring, and the corpus holds every order, rehearing and unrelated
party carrying those words. The rows come back in id order, so **the
first one is not the important one**: here the lowest id is a 1952
procedural order with 12 citations, while the 1954 landmark
(347 U.S. 483, 3,819 citations) is further down. GET /stats?name=... gives
the match count for free before you page.

**If your program has to pick one automatically, pick the most-cited
match, not the first.** Measured over ten well-known case names,
most-cited resolved to the decision a person meant 10 times out of 10 and
lowest-id 8, and the two it misses are the shape to expect, because ids
run roughly in filing order. For a landmark the lowest id tends to be a
procedural precursor rather than the decision: `katz+v.+united+states`
leads with a 1967 cert entry carrying 1 citation, ahead of 389 U.S. 347
and its 13,304. Brown behaves the same way. The first row is not randomly
wrong on the cases people look up; it is wrong in a direction.

Duplicate records do not undermine that rule. Some decisions are in the
corpus twice, from different sources, with the citation count split:
`name=gideon+v.+wainwright` returns three rows, two of them the same
decision, both 1963-03-18 and both 372 U.S. 335, at 7,325 and 8,419.
That splits a *count*; it does not change which decision you land on, and
most-cited resolves Gideon correctly either way. Duplicates matter when
you are counting, not when you are identifying.

Better than any automatic rule: show the candidates and let a person
choose. Best: use `cite=` when you have a citation. Then GET
/cases/:id/text.

**"Dataset of all cases from court X, 2015–2025, with text":**
GET /exports/estimate with the same params first, it costs nothing and
says whether the job fits. Then
POST /exports?court=X&filed_after=2015-01-01&filed_before=2025-12-31&include_text=true
(if it doesn't fit, GET /stats with the same selection and split the
date range where the cases are), poll, download, parse JSONL.

**"Every case discussing a doctrine, with full text":**
POST /exports?text="qualified immunity"&include_text=true
(the search is the selection; split by date range if over the 10,000-case
text limit, same as any big text export).

**"Chart cases per year for a court":**
GET /stats?court=X. One request; `by_year` is the chart. Add text= or
any other filter to chart a doctrine instead of a docket.

**"How a precedent's influence changed over time":**
GET /stats?cites=111221. `/stats` takes the citation-graph filters like
any other, so this is a per-year histogram of how many cases cited that
precedent, in one request. Cost tracks how heavily cited the case is:
Padilla (4,760 citing cases) answers in about 1.3s, Chevron (18,640) in
about 8s, and the most-cited cases in the corpus can exceed the backstop
, add `court=` or a date range for those.

`cited_by=` (what a case relied on) is the cheap direction, because
those sets are small: 111 cases for Heller, returned in about 0.2s. Use
it for the free exact count before paging through the listing.

**"A phrase count that keeps timing out":**
Add `court=`. GET /stats?text="reasonable person" will not complete; GET
/stats?text="reasonable person"&court=ca8 answers in under a second. The
most common phrases in American law have no unfiltered count: scope
them by court (or by date) and sum the parts if you need a total.

## Field reference

GET /cases list items, and every line of a metadata export:

    id                   integer   stable case identifier, use for /cases/:id
    case_name            string    short caption, e.g. "Padilla v. Kentucky"
    date_filed           string    ISO date "2010-03-31", may be null
    court                string    court slug, e.g. "scotus". The deciding
                                   court, on every row -- no per-case request
    citation_count       integer   times cited by other opinions, 0 if never.
                                   Opinion-level, so it runs above the number
                                   of cases cites= returns; see /cases
    precedential_status  string    "Published", "Unpublished", others rare
    citation_depth       integer   ONLY with cites= or cited_by=: how many
                                   times the edge to that case is drawn.
                                   Absent otherwise, and absent when both
                                   filters are set; see the citation graph

GET /cases/:id adds:

    case_name_full       string    long caption, often identical to case_name
    judges               string    comma-separated names, often "". Do not
                                   rely on it being populated
    docket_id            integer   internal docket identifier
    court                string    court slug, e.g. "scotus"
    opinions             array     {id, type, author, page_count}
                                   author is "" when unattributed
                                   page_count is usually null
    citations            array     this case's own reporter citations as
                                   strings, ordered by citation type so
                                   LEXIS/WL come last; [] if uncited.
                                   citations[0] is NOT the official cite
; match the reporter you want

GET /cases/:id/text opinions:

    id                   integer
    type                 string    "010combined", "020lead", ...
    author               string    may be ""
    text_source          string    which column supplied the text, or null
    text_length          integer   characters, or null
    text                 string    plain text, or null when none is digitized
    markup               string    the structured source raw (sanitize before
                                   rendering), or null when only plain text.
                                   THREE dialects, one of them structureless;
                                   see the notes above before parsing it.
                                   Absent entirely without include_markup=true
    markup_source        string    which column supplied markup, or null.
                                   Absent entirely without include_markup=true

Anything can be null or empty except `id`. This is a century-spanning
corpus assembled from many sources; write code that tolerates missing
fields rather than assuming every case has judges, citations, or text.

## Common mistakes

1. **Inventing a search parameter.** See the section above. Unrecognized
   parameters are ignored, so you get wrong answers rather than errors;
   the response's `warnings` array names what was dropped, and ignoring
   THAT is how a wrong answer gets published.
2. **Building the pagination cursor by hand.** Use `next_url` verbatim.
   Its shape changes depending on whether you filtered by date.
3. **Looping next_url to collect bulk data.** Use POST /exports. More
   than a few pages means you are using the wrong endpoint.
4. **Concatenating all opinions.** `010combined` usually contains the
   others; adding them together double-counts the text.
5. **Assuming fields are populated.** `judges` is empty for roughly half
   of cases, `page_count` is usually null, `text` can be null.
6. **Expecting a total count on a listing.** /cases has none, by design;
   pagination ends when `next_url` is null. But do not conclude that no
   count is available: `GET /api/v1/stats` takes the same filters and
   returns `total` for free, including for `cites=`/`cited_by=`, and
   /exports/estimate returns one too. Get the count from there before you
   rank or summarize a page, especially on the citation graph, where
   results come back in id order and one page of a larger set is an
   arbitrary slice.
7. **Sorting one page of /cases client-side and calling it a top-N.**
   /cases returns rows in id order, so page one is an arbitrary slice.
   Use GET /search?sort=citation_count, or collect the entire matching set
   before sorting. Sending `sort=` to /cases is a 422, not a silent
   wrong answer.
8. **Sending `Accept-Encoding: gzip` without inflating the response.**
   This is not only an export problem; ordinary JSON responses are
   compressed too, so a hand-rolled `urllib` client that sets the header
   as a courtesy fails on *every* call with `UnicodeDecodeError`, which
   mentions neither gzip nor this API. On downloads the same mistake
   writes gzip bytes into a file named `.jsonl`. Either use a client that
   handles it (`requests`, `httpx`, `curl --compressed`), or test
   `Content-Encoding` and inflate. See Authentication for the code.
9. **Not reading the `message` field on an error.** Every error names the
   fix in plain language. A 422 tells you which parameter was wrong and
   what a valid value looks like.

## Notes and limitations

- Data snapshot: quarterly from CourtListener bulk data (currently
  2026-06-30). Very recent decisions may be absent.
- The worked examples throughout this guide were run against that
  snapshot, and their counts are exact as of it. Totals move at each
  refresh; anything that depends on ranking or on one thin slice of
  coverage can move further. GET /api/v1/meta always reports the live
  snapshot.
- Coverage is published opinions; docket-only federal litigation
  (PACER records without opinions) is not exposed through this API.
- Full-text search reads a per-case document capped at 500k characters,
  and phrase matching degrades past roughly the 16,000th word of a
  document (positions stop being recorded). Both only matter in the
  extreme tail of very long opinions.
- Reporter abbreviation normalization for cite= uses Free Law Project's
  reporters-db (BSD-2-Clause).
- All data is public domain. No attribution required, but crediting
  the Free Law Project / CourtListener as the underlying source is
  good practice.