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.
# No key required — lists endpoints and the current pricing policy
curl https://lawdiver.com/api/v1Authentication
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.
Authorization: Bearer lt_live_xxxxxxxxxxxxxxxxxxxx
# or
X-API-Key: lt_live_xxxxxxxxxxxxxxxxxxxxGetting 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.
{
"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.
{
"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"
}| Field | Type | Description |
|---|---|---|
missing_api_key | 401 | No key was presented. |
invalid_api_key | 401 | The key is unknown, revoked, or expired. These are not distinguished on purpose. |
account_suspended | 403 | The account exists but is not permitted to make calls. |
rate_limited | 429 | Too many requests. Honour Retry-After. |
invalid_request | 400 | Validation failed. details names the offending fields. |
not_found | 404 | No such case, job, or report. |
gone | 410 | The path has been retired. The body may include a replacement hint. |
unsupported_media_type | 415 | Document upload was not a PDF or Word file. May include received and fileName. |
payload_too_large | 413 | Uploaded document exceeds the size limit. |
service_unavailable | 503 | A dependency was briefly unavailable. Retry. |
internal_error | 500 | Our fault. Quote the requestId. |
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 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 -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 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-*.
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
Retry-After: 12 # only on 429Your 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.
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);Case search
/api/v1/searchSearches the caselaw corpus and returns cases with the passage that made each one relevant. Jurisdiction is required — an unscoped national search is the slowest query available and is almost never what was intended.
Request body
| Field | Type | Description |
|---|---|---|
queryrequired | string | What to search for: a citation, a case name, or a description of the issue. |
jurisdictionrequired | object | Which courts to search. See the table below. |
searchType | enum | auto (default), citation, case_name, keyword, semantic, or hybrid. Leave it out unless you know what you have. |
limit | integer | Cases to return, 1–200, default 10. Capped by your account's maxCasesPerSearch (platform default 50 — asking for more than your cap silently returns at most the cap). |
filters.dateFrom | string | YYYY-MM-DD. Only cases filed on or after this date. |
filters.dateTo | string | YYYY-MM-DD. Only cases filed on or before this date. |
filters.includeUnpublished | boolean | Unpublished dispositions are excluded by default. Set true to include them (some jurisdictions allow limited citation of unpublished opinions). |
filters.publishedOnly | boolean | When true, further restrict to opinions marked published on is_published. Independent of the default unpublished exclusion above. |
filters.goodLawOnly | boolean | Exclude cases with negative treatment. Off by default: bad law is flagged, not hidden, because you usually need to know it exists. |
searchType
Omitting searchType routes the query automatically and is the recommended default. Pinning an engine is worth it when you already know what the string is — it removes both the latency and the false positives of engines that had nothing to contribute.
| Field | Type | Description |
|---|---|---|
auto | default | The router inspects the query and picks. Use this unless you have a reason not to. |
citation | pinned | The string is a reporter citation. |
case_name | pinned | The string is a party name. |
keyword | pinned | Literal term matching, including boolean operators. See search connectors for the operator vocabulary. |
semantic | pinned | Meaning-based retrieval; the query is a description of an issue. |
hybrid | pinned | Keyword and semantic together, fused. |
Jurisdiction
Eight choices. Some require a companion field, and the validation error will name it if you omit it. For the authoritative list of state codes and examples, call GET /api/v1/jurisdictions (no key required).
| Field | Type | Description |
|---|---|---|
all_states | — | Every state court, no federal courts. |
all_states_and_federal | — | Everything. The broadest and slowest scope. |
all_federal | — | Every federal court, including the Supreme Court. |
one_state | + state | That state's own courts only. Requires state, a USPS code such as "FL". |
one_state_plus_federal | + state | The state's courts, its regional circuit, and the U.S. Supreme Court — what a practitioner in that state actually cites. Requires state. |
federal_circuit | + circuit | One circuit plus the Supreme Court. Requires circuit: "1"–"11", "dc", or "federal". |
federal_district | + districtState | The federal district courts sitting in a state. Requires districtState, e.g. "TX" for the districts of Texas. |
us_supreme_court | — | The U.S. Supreme Court alone. |
Example
curl -X POST https://lawdiver.com/api/v1/search \
-H "Authorization: Bearer $LAWTOOLS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "qualified immunity excessive force",
"jurisdiction": { "type": "federal_circuit", "circuit": "11" },
"limit": 5,
"filters": { "dateFrom": "2015-01-01" }
}'Response
{
"results": [
{
"caseId": "4218873",
"caseName": "Smith v. City of Miami",
"citation": "812 F.3d 1234",
"court": "United States Court of Appeals for the Eleventh Circuit",
"courtAbbreviation": "11th Cir.",
"jurisdiction": "Eleventh Circuit",
"dateFiled": "2016-02-11",
"year": 2016,
"published": true,
"citedByCount": 74,
"snippet": "...the officers' use of force was not objectively reasonable...",
"snippetSource": "citing_opinion",
"goodLaw": {
"status": "good_law",
"negative": false,
"unknown": false,
"negativeTreatmentCount": 0,
"basis": "No negative treatment among 74 citing opinions."
},
"matchExplanation": "Matched on keyword and semantic recall.",
"retrievalUrl": "https://lawdiver.com/api/v1/cases/4218873/pdf"
}
],
"total": 1,
"totalAvailable": 46,
"searchInfo": {
"enginesUsed": ["keyword", "semantic"],
"jurisdictionLabel": "the 11th Circuit and the U.S. Supreme Court",
"citationsDetected": [],
"liftedPhrases": [],
"bodyTextSearchUnavailable": false,
"degraded": false,
"engineErrors": [],
"latencyMs": 412
},
"suggestion": null,
"usage": { "operation": "case_search", "quantity": 1, "breakdown": { "cases": 1 } },
"requestId": "req_Ab3xK9pQ"
}| Field | Type | Description |
|---|---|---|
total vs totalAvailable | integer | total is what you were sent; totalAvailable is how many matched overall. Raise limit to see more. |
goodLaw.unknown | boolean | True means treatment has not been determined — not that the case is good. Never present it to a user as a clean bill of health. |
snippet | string | The passage that made the case relevant, often how a later court described it rather than the opinion’s own words. |
retrievalUrl | string | A ready-made link to the case PDF. |
searchInfo.bodyTextSearchUnavailable | boolean | True when keyword recall covered case names, syllabi, and holdings only because a full-text index was unavailable. Explains an unexpectedly thin result set. |
searchInfo.degraded | boolean | True when at least one search engine timed out or failed. An empty result set with degraded true is an outage, not proof that no matching case exists. |
searchInfo.engineErrors | array | Engines that failed, each with engine and error. Empty when the search completed cleanly. |
suggestion | string | null | Present only on an empty result set — a broader query worth trying instead. |
Jurisdictions
/api/v1/jurisdictionsNo API key requiredAuthoritative list of jurisdiction types, circuit ids, USPS state codes, and example payloads for search. Call this instead of hard-coding state codes.
curl https://lawdiver.com/api/v1/jurisdictionsCite check — a citation
/api/v1/citecheck/citeChecks 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
| Field | Type | Description |
|---|---|---|
citation | string | One citation, as written. A case name works too. |
citations | string[] | 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
| Field | Type | Description |
|---|---|---|
valid | verdict | The 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_mismatch | verdict | The 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_valid | verdict | Up 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_found | verdict | Nothing matched. This is not proof the citation is fabricated; see corpusCaveat. |
error | verdict | This 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. |
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.corpusCaveat is set and says so. Do not label a citation fake on this basis alone.Example
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"]}'{
"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"
}| Field | Type | Description |
|---|---|---|
matchedBy | enum | How the candidate was found: reporter_key, case_name, volume_page_near, or docket_number. |
confidence | number | 0–1. Exactly 1 only for a reporter-key match. |
reporterKeys | string[] | The reporter citations parsed out of your input. Empty means the string was not a citation at all, which is itself useful feedback. |
explanation | string | Plain language, safe to show a user verbatim. |
Cite check — a document
/api/v1/citecheck/documentAsync — returns a job idUpload 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 -X POST https://lawdiver.com/api/v1/citecheck/document \
-H "Authorization: Bearer $LAWTOOLS_API_KEY" \
-F "[email protected]"{
"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"
}200, not 202: the state lives in the status field so one code path handles queued, processing, and completed.2. Poll
/api/v1/citecheck/jobs/:idPoll 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).
{
"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"
}| Field | Type | Description |
|---|---|---|
status | enum | queued, processing, completed, or failed. |
otherAuthoritiesFound | integer | Statutes, rules, and regulations detected but not verified — this product checks cases. Reported so their absence from the report is explained rather than mysterious. |
error | string | null | Why the job failed, when status is failed. |
3. Download the report
/api/v1/citecheck/jobs/:id/reportReturns 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.
not_found, because a cite-checked brief is privileged work product.Citation resolve
/api/v1/citations/resolveMaps 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 -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"}'| Field | Type | Description |
|---|---|---|
queryrequired | string | A citation or case name, 2–500 characters. |
Case retrieval
/api/v1/cases/retrieveResolves 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
| Field | Type | Description |
|---|---|---|
queryrequired | string | A citation (410 U.S. 113) or a case name (Roe v. Wade). Both go to the same resolver. |
caseId | string | Your 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
| Field | Type | Description |
|---|---|---|
ok | status | Exactly one case matched unambiguously. case holds it, case.pdfUrl links to the PDF. |
did_you_mean | status | Up 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_found | status | Nothing matched. corpusCaveat may explain that the case could exist but not yet be loaded. |
The did-you-mean round trip
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"}'{
"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"
}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
/api/v1/cases/:idReturns 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 https://lawdiver.com/api/v1/cases/2812209 \
-H "Authorization: Bearer $LAWTOOLS_API_KEY"Case batch
/api/v1/cases/batchLookup 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 -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
/api/v1/cases/:id/good-lawGood-law status for a case plus negativeCitations (negative treatment among citing opinions).
curl https://lawdiver.com/api/v1/cases/2812209/good-law \
-H "Authorization: Bearer $LAWTOOLS_API_KEY"Cited by
/api/v1/cases/:id/cited-byCases that cite this one, paginated. Query params: limit (1–100, default 25) and offset (default 0).
curl "https://lawdiver.com/api/v1/cases/2812209/cited-by?limit=25&offset=0" \
-H "Authorization: Bearer $LAWTOOLS_API_KEY"Case PDF
/api/v1/cases/:id/pdfReturns 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 -L https://lawdiver.com/api/v1/cases/2812209/pdf \
-H "Authorization: Bearer $LAWTOOLS_API_KEY" \
-o windsor.pdfResponse 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).
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: 0Idempotency-Key — store the bytes on your side when you need a safe retry, and re-fetch when currency matters.Usage
/api/v1/usageYour 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 "https://lawdiver.com/api/v1/usage?days=30" \
-H "Authorization: Bearer $LAWTOOLS_API_KEY"{
"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"
}| Field | Type | Description |
|---|---|---|
days | query | Look-back window, 1–365, default 30. |
calls vs units | integer | Calls is requests made; units is work counted on the ledger (for example cases returned). They differ whenever a call returns several cases or none. |
yourPricing | object | Account 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.
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.
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.
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');
}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.
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 devRequests 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.
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.