Developer documentation

CaseDiver API v1

Search caselaw, check citations one at a time or across an entire brief, and retrieve opinions as PDFs — over the same verified corpus that powers this site. Plain REST, API key authentication, and per-result pricing.

https://casediver.com/api/v1Pricing and plans

Overview

The CaseDiver API is a versioned REST surface at https://casediver.com/api/v1. It exposes three capabilities over a verified caselaw corpus: case search, cite checking (a single citation, a batch, or an entire uploaded document), and case retrieval (the opinion PDF with its processing material appended).

Every request and response is JSON, except where a PDF is the product. Every response — success or failure — carries a requestId. Quote it in support requests; it keys the exact ledger row and log line for your call.

discovery
# No key required — lists endpoints and current prices
curl https://casediver.com/api/v1

Authentication

Send your API key on every request. Two header forms are accepted; the bearer form is preferred because it is what HTTP clients and API gateways already understand.

headers
Authorization: Bearer lt_live_xxxxxxxxxxxxxxxxxxxx
# or
X-API-Key: lt_live_xxxxxxxxxxxxxxxxxxxx
Keys are server-side credentials
Never put an API key in browser JavaScript, a mobile app bundle, or a public repository. A key authorizes billable calls against your account. Call the API from your own backend and proxy the results to your front end.
Build-phase master key
While the API is in development the key Ludwig1011 is accepted on every endpoint. It resolves to a shared synthetic account and its usage is metered like anyone else's. It exists so integrations can be written before key issuance is self-service, and it will be removed before public launch. Do not build against it.

Getting a key

Keys are issued by CaseDiver. A key is shown exactly once, at creation — only a SHA-256 hash is stored, so it cannot be recovered or re-displayed. If a key is lost, revoke it and issue a new one. Revocation takes effect on the next request.

Responses and errors

Successful responses carry the endpoint's payload plus two envelope fields: usage (what this call cost) and requestId.

200 OK
{
  "results": [ ... ],
  "usage": {
    "operation": "case_search",
    "quantity": 7,
    "costMillicents": 210,
    "costCents": 21,
    "breakdown": { "cases": 7 }
  },
  "requestId": "req_Ab3xK9pQ"
}

Errors use the same envelope with an error object. Branch on error.code, not on the message — codes are stable, messages may be reworded.

400 Bad Request
{
  "error": {
    "code": "invalid_request",
    "message": "Invalid search request.",
    "details": [
      { "field": "jurisdiction.state", "message": "jurisdiction.state is required when type is 'one_state'" }
    ]
  },
  "usage": null,
  "requestId": "req_Ab3xK9pQ"
}
FieldTypeDescription
missing_api_key401No key was presented.
invalid_api_key401The key is unknown, revoked, or expired. These are not distinguished on purpose.
account_suspended403The account exists but is not permitted to make calls.
rate_limited429Too many requests. Honour Retry-After.
invalid_request400Validation failed. details names the offending fields.
not_found404No such case, job, or report.
unsupported_media_type415Document upload was not a PDF or Word file.
payload_too_large413Uploaded document exceeds the size limit.
service_unavailable503A dependency was briefly unavailable. Nothing was charged; retry.
internal_error500Our fault. Quote the requestId.
A did-you-mean is a 200, not a 404
When a retrieval query is ambiguous the API answers 200 with status: "did_you_mean" and up to three candidates. The request was understood and usefully answered, so treating it as an error class would make your error handler swallow the suggestions.

Billing model

You are charged for what you receive, never for what you attempted. That single rule explains every price below.

FieldTypeDescription
Case searchper caseCharged per case actually returned. A search that matches nothing is free.
Cite check (cite)per citationOne unit per citation checked, including citations that do not resolve — "this is not in the corpus" is the answer you paid for.
Cite check (document)per citation + per pageCitations found plus pages processed, and only once a report PDF exists. A failed job is free.
Case retrievalper caseCharged per case delivered. A miss is free, and so is a did-you-mean list — only your choice of a candidate is billed.

Current prices are in the discovery document (GET /api/v1) and, for your own account including any negotiated rate, in GET /api/v1/usage. Each call's usage block reports the exact charge applied, and the price in force at call time is recorded on the ledger row, so a later price change never rewrites a past charge.

Zero-cost calls still appear in your usage history
A free call is recorded with quantity: 0. That row is the evidence the work was done and deliberately not billed, which is what an invoice question needs.

Idempotency

Send an Idempotency-Key header on any billable call. If the same key is replayed for the same account, the stored response is returned with replayed: true and no second charge. Use it whenever a network timeout leaves you unsure whether a call landed — retrying with the same key is always safe.

curl
curl -X POST https://casediver.com/api/v1/search \
  -H "Authorization: Bearer $LAWTOOLS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: my-request-2026-08-13-001" \
  -d '{"query":"adverse possession","jurisdiction":{"type":"one_state","state":"FL"},"limit":5}'
A key is bound to the response it first produced, not to the request body. Reusing a key with a different query returns the original answer rather than running the new search. Generate a fresh key per distinct request.

Rate limits

Requests are limited per minute per account. Every response carries your budget, and a 429 tells you exactly how long to wait.

headers
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
Retry-After: 12          # only on 429

Your limit is reported in GET /api/v1/usage. If you need a higher ceiling, ask — it is a per-account setting, not a plan tier.

Quickstart

A minimal TypeScript client. It centralizes the key, surfaces the requestId on failure, and adds nothing else — the API is plain HTTP and does not need an SDK.

typescript
const BASE = 'https://casediver.com/api/v1';
const KEY = process.env.LAWTOOLS_API_KEY!; // server-side only

async function call<T>(path: string, body?: unknown, idempotencyKey?: string): Promise<T> {
  const res = await fetch(BASE + path, {
    method: body ? 'POST' : 'GET',
    headers: {
      Authorization: `Bearer ${KEY}`,
      ...(body ? { 'Content-Type': 'application/json' } : {}),
      ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });

  const json = await res.json();
  if (!res.ok) {
    // Quote requestId in support tickets; it keys our log line and ledger row.
    throw new Error(`${json.error?.code}: ${json.error?.message} (${json.requestId})`);
  }
  return json as T;
}

const found = await call<{ results: Array<{ caseName: string; citation: string }> }>('/search', {
  query: 'promissory estoppel reliance damages',
  jurisdiction: { type: 'one_state_plus_federal', state: 'NY' },
  limit: 5,
});

for (const r of found.results) console.log(r.caseName, '—', r.citation);

Cite check — a citation

POST/api/v1/citecheck/citeCharged per citation checked

Checks whether a citation resolves to a real case, returns the correct Bluebook form, and reports good-law status. Send citation for one, or citations for up to 50 in a single call.

Request body

FieldTypeDescription
citationstringOne citation, as written. A case name works too.
citationsstring[]Up to 50 citations. Billed as one ledger row with quantity = the number checked, so your invoice matches your call log.

Supply exactly one of the two. Beyond 50 citations, use the document endpoint — it is asynchronous and paces itself rather than holding a connection open.

Verdicts

FieldTypeDescription
validverdictThe citation keys to exactly one case. correctedCitation carries proper Bluebook form; one candidate is returned.
likely_validverdictCandidates were found by name or partial signal, but the citation as written keys to none of them — a transposed volume, wrong page, or misremembered reporter. Up to three candidates, and deliberately no pick.
not_foundverdictNothing matched. This is not proof the citation is fabricated; see corpusCaveat.
errorverdictThis one citation could not be checked. Reported explicitly rather than omitted, because an omitted row reads as "checked and fine".
We never silently correct a citation
On likely_valid no candidate is chosen for you. Quietly rewriting a citation to a case the author never read is the worst thing a cite checker can do, so the choice stays with you. Present the candidates; let a human pick.
not_found is a corpus statement, not a fabrication verdict
The corpus is still loading. When a confident negative is not supportable, corpusCaveat is set and says so. Do not label a citation fake on this basis alone.

Example

curl
curl -X POST https://casediver.com/api/v1/citecheck/cite \
  -H "Authorization: Bearer $LAWTOOLS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"citations":["570 U.S. 744","999 F.3d 1"]}'
200 OK
{
  "results": [
    {
      "citationAsWritten": "570 U.S. 744",
      "verdict": "valid",
      "correctedCitation": "United States v. Windsor, 570 U.S. 744 (2013)",
      "explanation": "This citation resolves to a case in the corpus.",
      "corpusCaveat": null,
      "reporterKeys": ["us/570/744"],
      "candidates": [
        {
          "caseId": "2812209",
          "caseName": "United States v. Windsor",
          "bluebookCitation": "United States v. Windsor, 570 U.S. 744 (2013)",
          "citation": "570 U.S. 744",
          "parallelCitations": ["133 S. Ct. 2675", "186 L. Ed. 2d 808"],
          "court": "Supreme Court of the United States",
          "year": 2013,
          "published": true,
          "citedByCount": 1204,
          "goodLaw": { "status": "good_law", "negative": false, "unknown": false },
          "matchedBy": "reporter_key",
          "confidence": 1,
          "retrievalUrl": "https://casediver.com/api/v1/cases/2812209/pdf"
        }
      ]
    }
  ],
  "usage": { "operation": "citecheck_cite", "quantity": 2, "costCents": 6 },
  "requestId": "req_Ab3xK9pQ"
}
FieldTypeDescription
matchedByenumHow the candidate was found: reporter_key, case_name, volume_page_near, or docket_number.
confidencenumber0–1. Exactly 1 only for a reporter-key match.
reporterKeysstring[]The reporter citations parsed out of your input. Empty means the string was not a citation at all, which is itself useful feedback.
explanationstringPlain language, safe to show a user verbatim.

Cite check — a document

POST/api/v1/citecheck/documentPer citation found + per page, only if a report is produced

Upload a brief or memo as multipart/form-data with a file field. Every case citation in it is extracted and checked, and the result is a report PDF listing each citation with its verdict — with the first page of each case found appended as an exhibit.

PDF, .docx, and .doc are accepted, up to 40 MB. The call returns a job id immediately; a 60-page brief takes 30–120 seconds, which is longer than any load balancer will hold a connection.

1. Upload

curl
curl -X POST https://casediver.com/api/v1/citecheck/document \
  -H "Authorization: Bearer $LAWTOOLS_API_KEY" \
  -F "[email protected]"
200 OK
{
  "jobId": "9f2c1a44-8e7b-4d2a-9c15-6b0e3f7a1d88",
  "status": "queued",
  "fileName": "brief.pdf",
  "fileSize": 1841204,
  "statusUrl": "https://casediver.com/api/v1/citecheck/jobs/9f2c1a44-.../",
  "reportUrl": "https://casediver.com/api/v1/citecheck/jobs/9f2c1a44-.../report",
  "pollAfterSeconds": 5,
  "requestId": "req_Ab3xK9pQ"
}
This is 200, not 202: the state lives in the status field so one code path handles queued, processing, and completed. Nothing is charged at upload.

2. Poll

GET/api/v1/citecheck/jobs/:idFree

Poll every 2–5 seconds until status is completed or failed. The status response also carries the full per-citation findings, so you can render your own UI instead of the PDF if you prefer.

200 OK
{
  "jobId": "9f2c1a44-8e7b-4d2a-9c15-6b0e3f7a1d88",
  "status": "completed",
  "fileName": "brief.pdf",
  "pageCount": 34,
  "citationCount": 41,
  "counts": { "valid": 36, "likelyValid": 3, "notFound": 2 },
  "otherAuthoritiesFound": 7,
  "citations": [ /* one entry per citation, same shape as the cite endpoint */ ],
  "reportUrl": "https://casediver.com/api/v1/citecheck/jobs/9f2c1a44-.../report",
  "error": null,
  "createdAt": "2026-08-13T14:02:11.000Z",
  "completedAt": "2026-08-13T14:03:04.000Z"
}
FieldTypeDescription
statusenumqueued, processing, completed, or failed.
otherAuthoritiesFoundintegerStatutes, rules, and regulations detected but not verified — this product checks cases. Reported so their absence from the report is explained rather than mysterious.
errorstring | nullWhy the job failed. A failed job is never charged.

3. Download the report

GET/api/v1/citecheck/jobs/:id/reportFree — the charge was applied when the report was produced

Returns application/pdf: a cover page with the tally, one entry per citation with its verdict and correct Bluebook form, and the first page of each case found appended behind it. Fetch it as many times as you like — you are billed for producing the report, not for downloading it.

Jobs are scoped to your account
A job id is a UUID, but unguessable is not the same as authorized. Another account's job id returns not_found, because a cite-checked brief is privileged work product.

Case retrieval

POST/api/v1/cases/retrieveCharged only when a case is delivered

Resolves a citation or a case name to one case. Three outcomes, all of them 200: an unambiguous hit (charged), a did-you-mean list (free), or nothing found (free).

Request body

FieldTypeDescription
queryrequiredstringA citation (410 U.S. 113) or a case name (Roe v. Wade). Both go to the same resolver.
caseIdstringYour answer to a previous did-you-mean. Supplying it skips resolution and delivers that case.

Outcomes

FieldTypeDescription
okchargedExactly one case matched unambiguously. case holds it, case.pdfUrl links to the PDF.
did_you_meanfreeUp to three candidates. Also returned when a citation is valid but two parallel reporters both resolved — delivering the wrong one of two cases is worse than asking.
not_foundfreeNothing matched. corpusCaveat may explain that the case could exist but not yet be loaded.

The did-you-mean round trip

1. ambiguous query
curl -X POST https://casediver.com/api/v1/cases/retrieve \
  -H "Authorization: Bearer $LAWTOOLS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query":"Smith v. Jones"}'
200 OK — free
{
  "status": "did_you_mean",
  "message": "More than one case could match... Choose one and call this endpoint again with its `caseId` (no charge for this response).",
  "candidates": [
    {
      "caseId": "1183422",
      "caseName": "Smith v. Jones",
      "bluebookCitation": "Smith v. Jones, 421 So. 2d 1024 (Fla. 1982)",
      "year": 1982,
      "confidence": 0.82,
      "matchedBy": "case_name",
      "pdfUrl": "https://casediver.com/api/v1/cases/1183422/pdf"
    }
  ],
  "charged": false,
  "usage": { "operation": "case_retrieval", "quantity": 0, "costCents": 0 },
  "requestId": "req_Ab3xK9pQ"
}
2. answer it — same endpoint
curl -X POST https://casediver.com/api/v1/cases/retrieve \
  -H "Authorization: Bearer $LAWTOOLS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query":"Smith v. Jones","caseId":"1183422"}'

The second call returns status: "ok" and is charged. Answering stays on the same endpoint so the two-step conversation does not force you to switch routes mid-flow.

Ambiguity is free by design
If a did-you-mean were billed, a caller with a slightly wrong cite would pay for being told we were unsure. Only your explicit choice is billable.

Case PDF

GET/api/v1/cases/:id/pdfCharged per case delivered; a 404 is free

Returns the opinion as a PDF with its processing material appended: metadata, the citation with parallel cites, good-law status with the basis for it, and the extracted analysis. This is the URL handed out as retrievalUrl and pdfUrl elsewhere in the API, so those links work directly in a browser.

curl
curl -L https://casediver.com/api/v1/cases/2812209/pdf \
  -H "Authorization: Bearer $LAWTOOLS_API_KEY" \
  -o windsor.pdf

Billing headers

A PDF body cannot carry the JSON usage envelope, so the charge travels in headers. Read them if you reconcile spend per download.

response headers
Content-Type: application/pdf
Content-Disposition: inline; filename="United States v. Windsor.pdf"
X-Request-Id: req_Ab3xK9pQ
X-CaseDiver-Operation: case_retrieval
X-CaseDiver-Charge-Units: 1
X-CaseDiver-Charge-Cents: 5
Rendered fresh every time, so cache it yourself
Each request re-renders the PDF rather than serving a cached copy, because good-law status and analysis change as the corpus grows and a stale PDF claiming a since-overruled case is good law is a failure this product cannot have. If you need speed, store the bytes on your side — and re-fetch when currency matters.

Usage and pricing

GET/api/v1/usageFree

Your spend by operation, plus the prices and limits in force for your key — including any negotiated rate, which the public discovery document does not show.

curl
curl "https://casediver.com/api/v1/usage?days=30" \
  -H "Authorization: Bearer $LAWTOOLS_API_KEY"
200 OK
{
  "consumer": { "name": "Acme Legal", "email": "[email protected]", "status": "active" },
  "periodDays": 30,
  "since": "2026-07-14T00:00:00.000Z",
  "byOperation": [
    { "operation": "case_search", "calls": 412, "units": 3180, "costCents": 954 },
    { "operation": "case_retrieval", "calls": 96, "units": 88, "costCents": 440 }
  ],
  "totalCostCents": 1394,
  "yourPricing": {
    "caseSearchPerCase": 3,
    "citeCheckPerCitation": 2,
    "citeCheckPerPage": 1,
    "caseRetrievalPerCase": 5,
    "rateLimitPerMinute": 60,
    "maxCasesPerSearch": 100
  },
  "requestId": "req_Ab3xK9pQ"
}
FieldTypeDescription
daysqueryLook-back window, 1–365, default 30.
calls vs unitsintegerCalls is requests made; units is what you were billed for. They differ whenever a call returns several cases or none.
yourPricingobjectWhat your key is actually charged, which may be below the public rate. All values in cents.

This is also the fastest way to tell a connectivity problem from a credentials problem: if GET /api/v1 answers but GET /api/v1/usage returns invalid_api_key, the network is fine and the key is not.

Recipes

Cite check a document, end to end

Upload, poll, download. Note the polling ceiling: a job that never finishes must fail your code rather than loop forever.

typescript
import { readFile } from 'node:fs/promises';

// 1. Upload
const form = new FormData();
form.append('file', new Blob([await readFile('brief.pdf')], { type: 'application/pdf' }), 'brief.pdf');

const started = await fetch(BASE + '/citecheck/document', {
  method: 'POST',
  headers: { Authorization: `Bearer ${KEY}` }, // no Content-Type: fetch sets the boundary
  body: form,
}).then((r) => r.json());

// 2. Poll. 5s matches pollAfterSeconds; the cap turns a stuck job into an error.
let job = started;
for (let i = 0; i < 120 && (job.status === 'queued' || job.status === 'processing'); i++) {
  await new Promise((r) => setTimeout(r, 5000));
  job = await call(`/citecheck/jobs/${started.jobId}`);
}
if (job.status !== 'completed') throw new Error(`Cite check ${job.status}: ${job.error ?? 'timed out'}`);

console.log(`${job.citationCount} citations over ${job.pageCount} pages`, job.counts);

// 3. Download the report (free — the charge landed when it was produced)
const pdf = await fetch(`${BASE}/citecheck/jobs/${started.jobId}/report`, {
  headers: { Authorization: `Bearer ${KEY}` },
}).then((r) => r.arrayBuffer());

Retrieve a case, handling did-you-mean

Resolution can need two calls. The first is free when it is ambiguous, so the branch below costs nothing until a case is actually chosen.

typescript
let hit = await call<any>('/cases/retrieve', { query: 'Smith v. Jones' });

if (hit.status === 'did_you_mean') {
  // Present hit.candidates to a human. Auto-picking the top candidate is a
  // product decision — it can silently return a case nobody asked for.
  const chosen = hit.candidates[0];
  hit = await call<any>('/cases/retrieve', { query: 'Smith v. Jones', caseId: chosen.caseId });
}

if (hit.status === 'not_found') {
  console.log('No match.', hit.corpusCaveat ?? '');
} else {
  const pdf = await fetch(hit.case.pdfUrl, {
    headers: { Authorization: `Bearer ${KEY}` },
  }).then((r) => r.arrayBuffer());
}

Retry safely after a timeout

When a call times out you cannot tell whether it landed. An idempotency key makes the retry free and returns the original answer.

typescript
const key = `search-${crypto.randomUUID()}`; // one key per distinct request

async function withRetry<T>(path: string, body: unknown): Promise<T> {
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      return await call<T>(path, body, key); // same key: at most one charge
    } catch (err) {
      if (attempt === 2) throw err;
      await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
    }
  }
  throw new Error('unreachable');
}
Do not reuse a key across different queries — it is bound to the first response it produced, so the second query would return the first query's results.

Python

python
import os, requests

BASE = "https://casediver.com/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['LAWTOOLS_API_KEY']}"

res = session.post(f"{BASE}/citecheck/cite", json={"citations": ["570 U.S. 744", "999 F.3d 1"]})
res.raise_for_status()
payload = res.json()

for item in payload["results"]:
    print(item["citationAsWritten"], "->", item["verdict"])
    if item["verdict"] == "likely_valid":
        for cand in item["candidates"]:
            print("   did you mean:", cand["bluebookCitation"])

print("charged (cents):", payload["usage"]["costCents"])

The local tester

A standalone app in tools/api-tester exercises every endpoint with a form per route, shows latency, rate-limit headroom, and the exact charge for each call, and previews returned PDFs inline. It is the fastest way to see a response shape before writing code against it.

powershell
cd tools/api-tester
npm install
npm run dev            # http://localhost:5199

# Point it at a different server (defaults to http://localhost:4000)
$env:LAWTOOLS_API_URL = "https://casediver.com"; npm run dev

Requests are proxied through the dev server, so the browser treats them as same-origin and PDFs render inline without CORS configuration. Your key is kept in localStorage and sent only to the proxied API.

The tester spends real money
It calls the live API with your live key. Every search and retrieval is billed to your account exactly as it would be from your own code.

Support

Include the requestId from the failing response in any support request. It resolves to the exact log line and ledger row, which turns most questions into a one-look answer. For a higher rate limit, an additional key, or a volume rate, ask — all three are per-account settings rather than plan tiers.