Developer documentation

LawDiver 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, free during rollout, and a usage ledger for every call.

https://lawdiver.com/api/v1Get a free API key

Overview

The LawDiver API is a versioned REST surface at https://lawdiver.com/api/v1. It exposes case search, cite checking (a single citation, a batch, or an entire uploaded document), citation resolution, case metadata and retrieval (including PDFs), good-law and citing-case lookups, and a jurisdiction reference — over the same verified caselaw corpus that powers this site.

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 the current pricing policy
curl https://lawdiver.com/api/v1

Authentication

Send your API key on every request (except the unauthenticated discovery and jurisdiction routes). 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 calls against your account. Call the API from your own backend and proxy the results to your front end.

Getting a key

Create and revoke keys at /account/api-keys after signing up and verifying your email. 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 (units recorded for this call) and requestId.

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

usage.breakdown keys depend on the operation — for example { cases } on search and retrieval, { citations, errors } on cite check, and { citations, pages, errors } on document cite check.

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.
gone410The path has been retired. The body may include a replacement hint.
unsupported_media_type415Document upload was not a PDF or Word file. May include received and fileName.
payload_too_large413Uploaded document exceeds the size limit.
service_unavailable503A dependency was briefly unavailable. 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.

Retired endpoints

POST /api/v1/requests and GET /api/v1/requests/:id return gone (410). Case processing is no longer queued through the public API — use retrieval and cite-check for published data, or on-demand processing on the case display.

Pricing

The API is free during the current rollout. Usage is still recorded on every call so you can see volume in GET /api/v1/usage and so a future price change never rewrites history. Pricing may change later; when it does, the discovery document (GET /api/v1) and this page will state the new policy. Until then, do not plan integrations around per-action charges.

Usage rows still appear
Calls are recorded with a usage block even while the product is free. A quantity: 0 row means the work ran and deliberately recorded no units.

Idempotency

Send an Idempotency-Key header on the three endpoints that honour it: POST /api/v1/search, POST /api/v1/citecheck/cite, and POST /api/v1/cases/retrieve. If the same key is replayed for the same account, the stored response is returned with replayed: true and the work is not run again. Use it whenever a network timeout leaves you unsure whether a call landed.

Other endpoints — including GET /api/v1/cases/:id/pdf, case metadata, batch lookup, and document upload — do not read the header. A retried PDF download re-renders; cache the bytes on your side if you need a safe retry.

curl
curl -X POST https://lawdiver.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. The original call must have sent the key — a first call without one cannot be replayed later.

A replayed search returns results, total, replayed, usage, and requestId — not totalAvailable, searchInfo, or suggestion. Cite check replays the stored results; retrieve replays the stored response whole.

Rate limits

Authenticated requests are limited per minute per account. Guarded responses carry your budget headers; a 429 tells you exactly how long to wait. Unauthenticated routes (GET /api/v1 and GET /api/v1/jurisdictions) do not send X-RateLimit-*.

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

Your limit is reported in GET /api/v1/usage. The limiter is a per-replica in-memory window, so treat the published number as a floor and back off on Retry-After rather than computing against an exact global budget. 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://lawdiver.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);

Jurisdictions

GET/api/v1/jurisdictionsNo API key required

Authoritative list of jurisdiction types, circuit ids, USPS state codes, and example payloads for search. Call this instead of hard-coding state codes.

curl
curl https://lawdiver.com/api/v1/jurisdictions

Cite check — a citation

POST/api/v1/citecheck/cite

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 in one call.

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 resolves cleanly. correctedCitation carries proper Bluebook form. Usually one candidate is returned, but parallel reporters that both resolve can yield more than one — always inspect candidates rather than assuming candidates[0] is the only match. Use POST /api/v1/cases/retrieve when you need an explicit pick.
name_mismatchverdictThe reporter citation resolves to a real case, but the party names, year, or court as written do not match that case. The resolved candidate is still returned — this is the shape of a hallucinated citation (a real cite glued to the wrong caption).
likely_validverdictUp to three candidates, and deliberately no pick. Two common causes: (1) candidates found by name or partial signal while the citation as written keys to none of them (transposed volume, wrong page, misremembered reporter); (2) an exact reporter-key hit whose Bluebook form diverges sharply from the as-written locator (for example a WL cite that resolves to a U.S. Reports parallel) — a Possible Match where the citation is right but you must verify. correctedCitation is null in both cases.
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". Error rows are excluded from usage.quantity.
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://lawdiver.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://lawdiver.com/api/v1/cases/2812209/pdf"
        }
      ]
    }
  ],
  "usage": { "operation": "citecheck_cite", "quantity": 2, "breakdown": { "citations": 2, "errors": 0 } },
  "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/documentAsync — returns a job id

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. If object storage or the job queue is down, the upload returns service_unavailable.

1. Upload

curl
curl -X POST https://lawdiver.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,
  "message": "Cite check queued.",
  "statusUrl": "https://lawdiver.com/api/v1/citecheck/jobs/9f2c1a44-.../",
  "reportUrl": "https://lawdiver.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.

2. Poll

GET/api/v1/citecheck/jobs/:id

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. Until the job finishes, counts, citations, otherAuthoritiesFound, and reportUrl are null (not omitted).

200 OK
{
  "jobId": "9f2c1a44-8e7b-4d2a-9c15-6b0e3f7a1d88",
  "status": "completed",
  "fileName": "brief.pdf",
  "pageCount": 34,
  "citationCount": 41,
  "counts": { "valid": 36, "likelyValid": 3, "notFound": 2, "errors": 0 },
  "otherAuthoritiesFound": 7,
  "citations": [ /* one entry per citation, same shape as the cite endpoint */ ],
  "reportUrl": "https://lawdiver.com/api/v1/citecheck/jobs/9f2c1a44-.../report",
  "error": null,
  "createdAt": "2026-08-13T14:02:11.000Z",
  "startedAt": "2026-08-13T14:02:12.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, when status is failed.

3. Download the report

GET/api/v1/citecheck/jobs/:id/report

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 once the job has completed.

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.

Citation resolve

POST/api/v1/citations/resolve

Maps a cite or case name to up to five candidate cases without delivering a PDF. Use this when you need candidates only; use POST /api/v1/cases/retrieve when you want the did-you-mean / deliver flow in one product surface.

curl
curl -X POST https://lawdiver.com/api/v1/citations/resolve \
  -H "Authorization: Bearer $LAWTOOLS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query":"410 U.S. 113"}'
FieldTypeDescription
queryrequiredstringA citation or case name, 2–500 characters.

Case retrieval

POST/api/v1/cases/retrieve

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

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. Must be an opinion id (not a cluster id).

Outcomes

FieldTypeDescription
okstatusExactly one case matched unambiguously. case holds it, case.pdfUrl links to the PDF.
did_you_meanstatusUp 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_foundstatusNothing 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://lawdiver.com/api/v1/cases/retrieve \
  -H "Authorization: Bearer $LAWTOOLS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query":"Smith v. Jones"}'
200 OK
{
  "status": "did_you_mean",
  "message": "More than one case could match... Choose one and call this endpoint again with its `caseId`.",
  "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://lawdiver.com/api/v1/cases/1183422/pdf"
    }
  ],
  "usage": { "operation": "case_retrieval", "quantity": 0 },
  "requestId": "req_Ab3xK9pQ"
}
2. answer it — same endpoint
curl -X POST https://lawdiver.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". Answering stays on the same endpoint so the two-step conversation does not force you to switch routes mid-flow.

Case metadata

GET/api/v1/cases/:id

Returns case metadata (no PDF body). :id may be an opinion id or a cluster id — unlike retrieve and PDF, which take the opinion id only.

curl
curl https://lawdiver.com/api/v1/cases/2812209 \
  -H "Authorization: Bearer $LAWTOOLS_API_KEY"

Case batch

POST/api/v1/cases/batch

Lookup metadata for up to 50 case ids in one call. Response includes cases for those found and notFound for ids that did not resolve. Each id may be an opinion id or a cluster id, same as GET /api/v1/cases/:id.

curl
curl -X POST https://lawdiver.com/api/v1/cases/batch \
  -H "Authorization: Bearer $LAWTOOLS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"caseIds":["2812209","1183422"]}'

Good-law detail

GET/api/v1/cases/:id/good-law

Good-law status for a case plus negativeCitations (negative treatment among citing opinions).

curl
curl https://lawdiver.com/api/v1/cases/2812209/good-law \
  -H "Authorization: Bearer $LAWTOOLS_API_KEY"

Cited by

GET/api/v1/cases/:id/cited-by

Cases that cite this one, paginated. Query params: limit (1–100, default 25) and offset (default 0).

curl
curl "https://lawdiver.com/api/v1/cases/2812209/cited-by?limit=25&offset=0" \
  -H "Authorization: Bearer $LAWTOOLS_API_KEY"

Case PDF

GET/api/v1/cases/:id/pdf

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. :id must be an opinion id.

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

Response headers

A PDF body cannot carry the JSON usage envelope, so operation and unit counts travel in headers (named X-LawTools-* for historical reasons — that is what the server emits and what CORS exposes).

response headers
Content-Type: application/pdf
Content-Disposition: inline; filename="United States v. Windsor.pdf"
X-Request-Id: req_Ab3xK9pQ
X-LawTools-Operation: case_retrieval
X-LawTools-Charge-Units: 1
X-LawTools-Charge-Cents: 0
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. This endpoint does not honour Idempotency-Key — store the bytes on your side when you need a safe retry, and re-fetch when currency matters.

Usage

GET/api/v1/usage

Your call volume by operation and the limits in force for your key (rateLimitPerMinute, maxCasesPerSearch). While the API is free, cost fields still appear on the ledger as zeros or recorded millicents — see Pricing.

curl
curl "https://lawdiver.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": 0 },
    { "operation": "case_retrieval", "calls": 96, "units": 88, "costCents": 0 }
  ],
  "totalCostCents": 0,
  "yourPricing": {
    "caseSearchPerCase": "3",
    "citeCheckPerCitation": "2",
    "citeCheckPerPage": "1",
    "caseRetrievalPerCase": "5",
    "rateLimitPerMinute": 60,
    "maxCasesPerSearch": 50
  },
  "requestId": "req_Ab3xK9pQ"
}
FieldTypeDescription
daysqueryLook-back window, 1–365, default 30.
calls vs unitsintegerCalls is requests made; units is work counted on the ledger (for example cases returned). They differ whenever a call returns several cases or none.
yourPricingobjectAccount limits and a reserved price schedule. The four per-unit fields are strings (cents labels such as "3"); rateLimitPerMinute and maxCasesPerSearch are numbers. Not applied as charges while the API remains free.

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. Wait until status === 'completed' before reading job.counts — until then those fields are null.

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
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. When the first returns did_you_mean, present the candidates and call again with the chosen caseId.

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. On POST /search, POST /citecheck/cite, and POST /cases/retrieve, an idempotency key makes the retry return the original answer without re-running the work.

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 execution
    } 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. PDF download and most other routes do not honour the header.

Python

python
import os, requests

BASE = "https://lawdiver.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("units:", payload["usage"]["quantity"])

The local tester

A standalone app in tools/api-tester exercises the main routes (search, cite, document, retrieval, usage/discovery) with a form per route, shows latency and rate-limit headroom, and previews returned PDFs inline. It is the fastest way to see a response shape before writing code against it. It does not yet cover every Phase 9 case extension.

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

# Point it at a different server (defaults to http://localhost:3001)
$env:LAWTOOLS_API_URL = "https://lawdiver.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.

Uses your live key
The tester calls the API with your real key. While the product is free that is still real traffic against your account limits — see Pricing.

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 or an additional key, ask — those are per-account settings rather than plan tiers.