API reference
One base URL, JSON in and out, and errors that always tell you what went wrong. This page is the complete v1 surface, and it stays current: what it says is what the API does. Response examples are real captured output for the requests shown. Where a block is abridged, its label says so and … marks elided string content; every request and response block on this page parses as JSON.
Quickstart
Send legal text, get back every known legal term with its plain-language meaning and its exact character position in your text: structured JSON from a key-authed API, priced per operation, ready for your backend to build on.
curl https://api.plainlanguage.us/v1/translate \
-H "Authorization: Bearer pl_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "The plaintiff bears the burden of proof to establish a prima facie case of negligence before the defendant is required to rebut the claim."}'const res = await fetch("https://api.plainlanguage.us/v1/translate", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.PL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ text: legalText }),
});
if (!res.ok) throw new Error((await res.json()).error);
const data = await res.json();Where your text goes
One hop out, one hop back. Text you send is matched against the glossary inside the request and is gone when the response returns: nothing is stored, and no model runs on our side (the promises in full: trust). The dashed leg exists only if you use it. The prompt export comes back to you, and you decide which model sees it.
Evaluation kit
Evaluate the integration offline, before spending anything: no account, no signup. The kit is generated from captured API responses on every site build and validated against them, so it can't drift from what the API actually does.
- openapi.json: OpenAPI 3.1 spec of the product surface, real captured responses as examples. Feed it to a client generator and mock the whole integration.
- field-schema.json: JSON Schema for every response shape, including
opsBilled, the UTF-16 offset encoding,kinds, thesectionsfilter, and both/v1/usageshapes. - term-card-annotated.json: a real, complete term detail card (plaintiff) annotated with the billable units of the operation table.
Authentication
Every request carries your API key in the Authorization header: Bearer pl_live_… (test-mode deployments issue pl_test_… keys). You get the key exactly once, right after checkout. We store only a hash of it. Keep it on your server: treat it like a password.
- Lost the key? Roll it: the replacement is shown once, and the old key stops working immediately.
- One active key per subscription.
Billing by plan
Keys come in two classes, and the class decides how requests are billed. The endpoints, request shapes, and response shapes are identical either way.
- Workspace keys ($49/month) carry a monthly allowance of 7,500 term matches. Each metered response's
matchCountcounts against it; term detail cards and usage checks are free. Past the allowance, requests answer429until the period resets. Never overage. - Developer keys ($0.0025 per operation) have no allowance and no monthly fee: every operation is billed monthly in arrears. Each metered response reports what it just cost in
opsBilled.
The operation table, in full:
| Request | Developer cost (operations) | Workspace cost (term matches) |
|---|---|---|
POST /v1/translate | 1 per term match (= matchCount) | matchCount |
GET /v1/term/:id | 1 per section actually returned (full card: typically 7 to 9) | free |
POST /phase3 | matchCount + (distinct embedded terms × 0/1/3 at minimal/standard/rich) | matchCount |
GET /v1/usage, key management | free | free |
Universal rules, both classes: text where nothing is found costs nothing, and refused requests (400/413/429) are never charged. We authenticate, then validate, then compute, then meter.
Translate
Despite the name, this endpoint does not rewrite anything: it identifies and explains the legal terms in place. For a full rewrite, carry the prompt export to your own model.
Body: { "text": string }. The response lists every known term in order of appearance. start/end are UTF-16 code-unit offsets into exactly the text you sent (safe for JavaScript's slice; Python and other code-point languages count differently once text contains emoji or other astral characters). Terms can nest: burden of proof and burden may both appear; render the longest non-overlapping spans.
The response below is the real output for the Quickstart request above, abridged to 4 of its 15 matches.
{
"phase": 2,
"tokenCount": 24,
"matchCount": 15,
"substitutionCount": 15,
"elapsedMs": 2.5,
"matches": [
{
"termId": 5323,
"surface": "plaintiff",
"kinds": ["headword"],
"start": 4,
"end": 13,
"tokens": ["plaintiff"],
"plainLanguage": "the person who starts a lawsuit by claiming that someone else did something wrong and asking the court to fix it"
},
{
"termId": 1001,
"surface": "bear",
"kinds": ["headword"],
"start": 14,
"end": 19,
"tokens": ["bears"],
"plainLanguage": "to carry, pay, or be responsible for something, or to have something marked or written on it"
},
{
"termId": 3289,
"surface": "establish",
"kinds": ["headword", "word_sub"],
"start": 43,
"end": 52,
"tokens": ["establish"],
"plainLanguage": "to create, set up, or officially decide on something, such as a rule, a plan, a fact, or an organization"
},
{
"termId": 5589,
"surface": "prima facie case",
"kinds": ["headword"],
"start": 55,
"end": 71,
"tokens": ["prima", "facie", "case"],
"plainLanguage": "a set of facts or evidence that is strong enough to prove a claim unless the other side can provide evidence to disprove it"
}
]
}- Cost:
matchCount. Workspace keys spend it against the monthly allowance; developer keys are billed one operation per match, and the response carriesopsBilled(=matchCount) saying exactly what it cost. Text where we find nothing costs nothing. - Limits per request: text up to 250,000 characters, up to 10,000 matches. Bigger answers a quota-free
413; send smaller pieces. plainLanguageis the curated rendering;nullmeans the term is known but has no paraphrase yet (rare). A null match still counts toward your limit like any other: cost ismatchCount, suggestion or not.substitutionCountis how many of the matches carry aplainLanguagesuggestion. The difference frommatchCountis the null ones.kindssays how the term matched, one or more of:headword,subtype,word_sub,override.- "Legal term" here means an entry in our curated glossary. It is US-oriented and covers terms of art, multi-word legal phrases, and legal senses of everyday words.
tokenCountis how many word tokens the matcher saw in your text (letters-only tokenization).phaseis an internal pipeline marker (2 = the deterministic pipeline).elapsedMsis coarsened at the edge and often reads 0; don't build on it.- A malformed, oversized, or over-quota request costs no quota. We authenticate, then validate, then compute, then meter.
- Send
Content-Type: application/json(every example here does). For compatibility, a missing or differentContent-Typedoesn't change anything: the body is parsed as JSON regardless. Don't lean on that in new code.
Rendering matches
A renderer has exactly two jobs beyond the obvious: count offsets the way the API counts them, and settle overlaps. Everything else is string slicing.
- Offsets are UTF-16 code units. JavaScript counts strings the same way, so
slice()is already correct. Code-point languages (Python, Go, Rust) drift one position for every astral character (an emoji, some rare CJK) earlier in the text: slice through a UTF-16 view instead, as the Python example does. - Terms can nest.
burden of proofandburdenmay both be present. Keep the longest span at each position and drop the spans it covers. - Escape what you re-emit. Offsets point into the raw text you sent. Escape every piece on its way into HTML, including the
plainLanguagestrings riding in attributes.
// Render /v1/translate matches as <mark> highlights.
// start/end are UTF-16 code units: exactly how JavaScript strings count,
// so slice() needs no conversion.
const ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
const esc = (s) => s.replace(/[&<>"']/g, (c) => ESCAPES[c]);
// Terms can nest ("burden of proof" contains "burden"): keep the longest
// span at each position, drop the spans it covers.
function pickSpans(matches) {
const sorted = [...matches].sort((a, b) => a.start - b.start || b.end - a.end);
const kept = [];
let cursor = 0;
for (const m of sorted) {
if (m.start >= cursor) {
kept.push(m);
cursor = m.end;
}
}
return kept;
}
export function renderHtml(text, matches) {
let html = "";
let cursor = 0;
for (const m of pickSpans(matches)) {
html += esc(text.slice(cursor, m.start));
const tip = m.plainLanguage ? ` title="${esc(m.plainLanguage)}"` : "";
html += `<mark${tip}>${esc(text.slice(m.start, m.end))}</mark>`;
cursor = m.end;
}
return html + esc(text.slice(cursor));
}
# Render /v1/translate matches as <mark> highlights.
# start/end are UTF-16 code units. Python strings count code points, so
# slice through a UTF-16 view instead: 2 bytes per code unit, offsets
# doubled. The API never splits a surrogate pair, so decoding is safe.
import html
def pick_spans(matches):
# Terms can nest ("burden of proof" contains "burden"): keep the
# longest span at each position, drop the spans it covers.
kept, cursor = [], 0
for m in sorted(matches, key=lambda m: (m["start"], -m["end"])):
if m["start"] >= cursor:
kept.append(m)
cursor = m["end"]
return kept
def render_html(text, matches):
units = text.encode("utf-16-le")
def piece(start, end):
return units[2 * start : 2 * end].decode("utf-16-le")
out, cursor = [], 0
for m in pick_spans(matches):
out.append(html.escape(piece(cursor, m["start"])))
tip = ""
if m["plainLanguage"]:
tip = ' title="{}"'.format(html.escape(m["plainLanguage"]))
inner = html.escape(piece(m["start"], m["end"]))
out.append("<mark{}>{}</mark>".format(tip, inner))
cursor = m["end"]
out.append(html.escape(piece(cursor, len(units) // 2)))
return "".join(out)
Both examples produce byte-identical HTML and were validated against the evaluation kit's captured response before landing here. The same slicing works for any output format: swap the <mark> wrapper for whatever your renderer needs.
Usage
Your plan, this period's consumption, and when the period ends. It's free to call, so it can drive dashboards and meters without spending anything. Workspace keys count term matches against the cap:
{
"tier": "workspace",
"cap": 7500,
"used": 1284,
"remaining": 6216,
"periodStart": "2026-07-01T14:52:11.000Z",
"periodEnd": "2026-08-01T14:52:11.000Z"
}Developer keys report the period's billed operations. cap and remaining are null (there is no allowance to run down), and estimatedCost is used × $0.0025 in USD, an estimate: the monthly Stripe invoice is the authoritative amount.
{
"tier": "developer",
"cap": null,
"used": 16400,
"remaining": null,
"estimatedCost": 41,
"periodStart": "2026-07-01T14:52:11.000Z",
"periodEnd": "2026-08-01T14:52:11.000Z"
}Term detail
The full curated card for one term (use the termId from a /v1/translate response). The server decides which sections exist for each term and sends them in a fixed order. Render what you receive, and skip section types you don't know. Responses are privately cacheable for 24 hours (Cache-Control: private, max-age=86400); billing is per request served, so a browser-cache hit costs nothing.
{
"termId": 5323,
"term": "plaintiff",
"slug": "plaintiff",
"sections": [
{
"id": "plain",
"label": "Plain language",
"defaultOpen": true,
"type": "prose+chips",
"text": "the person who starts a lawsuit by claiming that someone else did something wrong and asking the court to fix it",
"chips": [
"person who files a lawsuit",
"person bringing the case",
"person making the complaint",
"the one suing"
]
},
{
"id": "watch",
"label": "What to watch for",
"defaultOpen": true,
"type": "bullets+prose",
"bullets": [
"Non-lawyers often confuse plaintiff with defendant. The plaintiff is the one who starts the case; the defendant is the one being sued. Always clarify who is doing what.",
"In criminal cases, the 'plaintiff' is technically the state or government, not a private person. This differs from civil cases where a private individual can be the plaintiff."
],
"notes": "### Civil vs. Criminal Context\nIn civil cases, the plaintiff is typically a private person or business seeking compensation for harm. In criminal cases, the plaintiff is the government (state, federal, or local) prosecuting someone for breaking the law. The plain-language translation should clarify which context applies."
}
]
}Section ids, in order: plain, watch, definition,byContext, subtypes, examples,contexts, related, word. Matchable terms carry 7–9 of them in practice.
?sections=filters the card to named panels, comma-separated from the ids above (for example?sections=plain,watch). An unknown id answers400naming the whole vocabulary; sections a term doesn't carry are simply absent. The order is always the server's, never the query's.- Workspace keys: free, filtered or not (opening cards is part of reading a result you already paid for).
- Developer keys: 1 operation per section actually returned, echoed in
opsBilled. A full card is typically 7 to 9 operations; filtering toplain,watchcosts at most 2. Requesting only sections the term lacks returns an empty list and costs 0.
Prompt export (bring your own AI)
Everything /v1/translate returns, plus promptExport: a ready-to-paste prompt (and a messages[] array for API use) that packages your text with its glossary so your model can rewrite it under our constraints. We never call a model. The refined: false field says exactly that.
{
"text": "The plaintiff bears the burden of proof to establish a prima facie case of negligence before the defendant is required to rebut the claim.",
"grounding": "standard"
}{
"phase": 3,
"refined": false,
"tokenCount": 24,
"matchCount": 15,
"substitutionCount": 15,
"elapsedMs": 2.5,
"matches": [
{
"termId": 5323,
"surface": "plaintiff",
"kinds": ["headword"],
"start": 4,
"end": 13,
"tokens": ["plaintiff"],
"plainLanguage": "the person who starts a lawsuit by claiming that someone else did something wrong and asking the court to fix it"
}
],
"promptExport": {
"grounding": "standard",
"glossary": [
{
"term": "plaintiff",
"plainLanguage": "the person who starts a lawsuit by claiming that someone else did something wrong and asking the court to fix it",
"pitfalls": [
"Non-lawyers often confuse plaintiff with defendant. The plaintiff is the one who starts the case; the defendant is the one being sued. Always clarify who is doing what."
]
}
],
"prompt": "You are a plain-language editor for United States legal text. The user\nmessage contains a <glossary> section…",
"messages": [
{ "role": "system", "content": "…" },
{ "role": "user", "content": "<glossary>…</glossary>\n\n<legal_text>…</legal_text>\n\nTask: rewrite the text inside <legal_text>…" }
]
}
}- Abridged above: the full response carries all 15 matches (same shape as
/v1/translate), a glossary entry per distinct term, each pitfall, and the complete prompt and messages. grounding(optional):minimal= term + meaning,standard(default) = adds watch-out warnings,rich= adds definitions and alternate senses.- Workspace keys: metered exactly like
/v1/translate: the same text costs the samematchCount, whatever the grounding. - Developer keys:
matchCountoperations, plus per distinct term embedded in the glossary: 0 extra atminimal, 1 atstandard, 3 atrich(the extra card sections the prompt carries). So minimal costs exactly what/v1/translatecosts; a term matched five times bills five matches but one embed.opsBilledreports the total. - The assembled prompt is capped at 500,000 characters (bigger is unusable by most models). Past it you get a quota-free
413: pick a lighter grounding or send smaller pieces.
Choosing a model
The prompt asks a lot of a model: follow eight rules at once, keep the glossary's meaning, preserve legal effect, and hold a 6th-grade reading level. Small models tend to drop rules.
- Tested with Claude Haiku 4.5 (July 2026), which follows the prompt well. At the time of testing it is available free through DuckDuckGo's Duck.ai. More capable models should do at least as well.
- Quick check on any model: if the rewrite comes back in long sentences, or ignores the glossary wording, the model is too small. Try a stronger one.
- For smaller models, use minimal grounding (the picker in the workspace, or
grounding: "minimal"in the API): a shorter prompt is easier to follow.
Handing off to your model
The export comes in two forms for the two ways models are used. prompt is one string: paste it into any chat interface as-is. messages is the same content split for API calls: a system entry (the editor instructions) and a user entry (glossary, your text, and the task), in the role/content shape chat APIs share.
POST https://api.model-vendor.example/v1/chat
{
"model": "your-chosen-model",
"messages": [
{ "role": "system", "content": "You are a plain-language editor for United States legal text. …" },
{ "role": "user", "content": "<glossary>…</glossary>\n\n<legal_text>…</legal_text>\n\nTask: rewrite the text inside <legal_text>…" }
]
}- Send both entries unchanged. If your provider takes the system prompt as a top-level parameter instead of a message, pass the
systementry'scontentthere, verbatim. - Don't edit inside the
<glossary>or<legal_text>tags. The instructions tell the model to treat tag contents as text to rewrite, never as instructions to follow, and edits weaken that. - The rewrite comes back from your model to you. We never see that conversation: the handoff crosses the trust boundary on your side of it.
Quota headers
Successful responses on a workspace key report where the monthly allowance stands:
X-Quota-Limit: 7500
X-Quota-Used: 1284
X-Quota-Remaining: 6216The numbers count term matches. A request whose matches would pass the limit answers 429 (unserved, uncharged) until the period resets; nothing is billed for overage. Unmetered endpoints (/v1/usage, /v1/term/:id, key management) keep working. There is no per-second rate limit on the API: the monthly quota and the per-request size limits are the only meters. Errors and refusals (including 429) carry no X-Quota-* headers; when you need the numbers without spending anything, ask /v1/usage.
Developer keys have no quota, so their responses carry no X-Quota-* headers: per-request cost rides in opsBilled inside the body, and the running total in /v1/usage.
Errors
Every error is JSON: { "error": string, … }. Ordering guarantee: a malformed request never costs quota (authenticate → validate → meter).error is always present; the extra fields shown below are stable for their case (status on 403, code on 409, and tier/cap/used/requested on 429).
| Status | Body | When |
|---|---|---|
400 | {"error": "missing \"text\" (string) in body"} | Validation: invalid JSON, missing or empty "text", an unknown "grounding", or an unknown "sections" id. |
401 | {"error": "missing API key (Authorization: Bearer <key>)"} | No Authorization header (or not in Bearer form). |
401 | {"error": "invalid API key"} | The key isn't one we recognize: mistyped, rolled away, or revoked. |
402 | {"error": "checkout not completed"} | Billing success fetched before payment finished. |
403 | {"error": "subscription is not active", "status": "canceled"} | Key is real, but the subscription lapsed. |
404 | {"error": "not found"} | Unknown path (on an authenticated POST) or unknown term id. |
405 | {"error": "method not allowed"} | Wrong HTTP method for the path. An unknown non-POST path answers 405 too, not 404. |
409 | {"error": "this subscription is no longer active", "status": "canceled"} | Billing success for a subscription that has since ended. |
413 | {"error": "\"text\" exceeds the 250,000-character limit; send smaller pieces"} | Too big for one request: body over 2 MB, text over 250,000 characters, more than 10,000 matches, or a prompt export over 500,000 characters. Costs nothing; split the text (or pick a lighter grounding). |
429 | {"error": "monthly term quota exceeded", "tier": "workspace", "cap": 7500, "used": 7460, "requested": 53} | Workspace keys: the request's matches would pass your monthly limit, so it is refused unserved and uncharged. Requests resume next period. We never bill overage. Developer keys have no quota and never see this. |
501 | {"error": "/all not yet implemented"} | Reserved future endpoint. |
Key management
Replace your key. The new key is revealed once in the response; the old key stops working immediately.
curl -X POST https://api.plainlanguage.us/v1/key/roll \
-H "Authorization: Bearer pl_live_YOUR_KEY"
# → { "apiKey": "pl_live_NEW_KEY…", "note": "Store this key now…" }End API access now. Revoking doesn't cancel billing (do that in the billing portal), and a fresh key requires a new checkout. Use roll if you want to keep going.
curl -X POST https://api.plainlanguage.us/v1/key/revoke \
-H "Authorization: Bearer pl_live_YOUR_KEY"
# → { "ok": true, "note": "Key revoked; API access ends now.…" }Billing endpoints
These power the website's pricing and account pages; you rarely call them directly. They are the site's integration surface, not part of the stable product API (see Versioning and stability).
POST /billing/checkoutwith{ "tier": "workspace" | "developer" }→{ "url" }(Stripe Checkout). Also acceptsGET ?tier=…, answering a303redirect: deliberate, so a plain link can start checkout where a form can't. Nothing changes until the checkout completes at Stripe (an abandoned session just expires), and this endpoint is rate-limited at the edge.GET /billing/success?session_id=…→ the one-time API-key reveal after checkout.POST /billing/portal(key-authed) → your Stripe customer-portal URL for invoices, card updates, and cancellation.
Browser use (CORS)
The API allows browser calls from plainlanguage.us (and, for development, localhost or 127.0.0.1 on any port), with the X-Quota-* headers exposed. If you embed calls in your own website, route them through your server instead. A key shipped to browsers is a key you've published.
Integration checklist
The rules above, condensed. Each links to the section that expands it; an integration that holds all ten needs nothing else from this page.
- Keep the key server-side. A key in a browser or an app bundle is a key you've published. It is shown once at checkout and never again: store it like a password, replace it with roll. (CORS, keys)
- Send
Content-Type: application/json. The parser is lenient today; don't build on that. (translate) - Treat offsets as UTF-16 code units. Convert before slicing in code-point languages. (rendering)
- Settle nesting: longest span wins. Terms can contain other terms; render the longest non-overlapping spans. (rendering)
- Handle
plainLanguage: null. A known term can lack a suggestion; it still counts like any other match. (translate) - Skip section types you don't recognize. The server decides what a term's card carries; render the ids you know and ignore the rest. (term detail)
- Let term cards cache. Responses are privately cacheable for 24 hours, and a browser-cache hit costs nothing. On developer keys, filter with
?sections=to pay for only the panels you show. (term detail) - Expect
413and429; both are free. Split the text on413. On429(workspace keys only), pause until the period resets; there is no overage billing. (errors, quota) - Read cost from the response.
matchCountis what a request spends on a workspace key.opsBilledis what it cost on a developer key./v1/usageis free and reports the period either way. (usage) - Build on the stable surface only. That is
/v1/*plus/phase3: not/billing/*, notelapsedMs. (stability)
Versioning and stability
The stable product surface is the /v1/* endpoints plus POST /phase3. The /phase3 path predates the /v1 prefix and keeps its name for compatibility; it carries the same stability promise as everything under /v1. We treat this surface as frozen: no breaking changes to paths, fields, statuses, or semantics.
The /billing/* endpoints and the homepage demo's POST /demo/translate are the website's integration surface, not part of the product API: build integrations on the product surface only, and expect these to evolve with the site.
GET / is the service health check, answering { "ok": true, "service": "pl-api-v3", "contract": "v1" } with no authentication. There is no /health path (an unknown GET path answers 405); point uptime monitors at GET /.