ProofKit

Docs

API & receipt spec

One endpoint, one JSON document back. The response is the signed receipt.

POST /api/v1/check

Body: {"text": "…"} — UTF-8, 5 to 20,000 characters. No key needed on the free tier.

curl -X POST https://proof.sapir861.com/api/v1/check \
  -H "content-type: application/json" \
  -d '{"text":"See Roe v. Wade, 410 U.S. 113 (1973)."}'
StatusMeaning
200Receipt: {body, sig, digest, log}
400Text missing, too short or over 20,000 characters
429rate_limited (burst) or monthly_limit — free tier is 100 citations per month per network

Response header x-proofkit-remaining shows the free citations left this month. Pro and Team plans get an API key with higher limits.

Receipt fields

FieldDescription
body.specsapir-receipt/1
body.kindcitation-check
body.tsISO-8601 UTC time of the check
body.input_sha256SHA-256 of the exact input text (prove which text was checked without storing it)
body.checkerMethod and source used
body.results[]cite, name, verdict, note, source, opinion, matched_case
verdictverified · mismatch (real cite, different case) · not_found · ambiguous · exists_name_unchecked · unverifiable · unchecked
body.summarytotal, verified, flagged
body.keySigning public key (Ed25519, JWK x)
sigEd25519 signature (base64url) over the canonical JSON of body
digestSHA-256 hex of the canonical JSON of body
logPosition n and chain hash in the public transparency log

Canonical JSON: object keys sorted, no whitespace, standard JSON string escaping, undefined fields dropped.

Verification

  1. Serialize body canonically.
  2. SHA-256 must equal digest.
  3. Ed25519-verify sig over the same bytes with the key from /.well-known/proofkit-pubkey; it must equal body.key.x.
import { readFileSync } from "node:fs";   // node verify.mjs receipt.json  (Node 20+)
const r = JSON.parse(readFileSync(process.argv[2] || "receipt.json", "utf8"));
const canon = o => o === null || typeof o !== "object" ? JSON.stringify(o) : Array.isArray(o) ? "[" + o.map(canon).join(",") + "]"
  : "{" + Object.keys(o).sort().filter(k => o[k] !== undefined).map(k => JSON.stringify(k) + ":" + canon(o[k])).join(",") + "}";
const msg = new TextEncoder().encode(canon(r.body));
const hex = Buffer.from(await crypto.subtle.digest("SHA-256", msg)).toString("hex");
const pub = (await (await fetch("https://proof.sapir861.com/.well-known/proofkit-pubkey")).json()).jwk;
const key = await crypto.subtle.importKey("jwk", pub, { name: "Ed25519" }, false, ["verify"]);
const ok = await crypto.subtle.verify({ name: "Ed25519" }, key, Buffer.from(r.sig, "base64url"), msg);
console.log(ok && hex === r.digest && pub.x === r.body.key.x ? "VALID" : "INVALID");

Limits — read before relying on it

  • Coverage: US case law in CourtListener. Some state and older reporters are incomplete.
  • Westlaw (WL), Lexis and public-domain citations cannot be checked against an open database — marked unverifiable.
  • Name match is token-based (≥ 0.5). Very short or unusual case names can produce mismatch for a real case — always open the source link.
  • We verify existence and identity of the case, not whether it supports the argument.