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.

https://api.plainlanguage.us

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
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."}'
JavaScript
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.

Data-path diagramYour application sends text to the PlainLanguage API and receives matches and glossary entries back. The API stores nothing and runs no model. Separately, your application may send the prompt export to your own model. No line connects PlainLanguage to any model.trust boundaryYour applicationyour code, your keyPlainLanguage APIdeterministic glossary matchnothing stored, no model runsyour textmatches and glossaryprompt export, sent by youYour modelany vendor, your account
No line runs from us to a model: the only model in the picture is yours, and you hold the only connection to 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, the sections filter, and both /v1/usage shapes.
  • 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 matchCount counts against it; term detail cards and usage checks are free. Past the allowance, requests answer 429 until 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:

RequestDeveloper cost (operations)Workspace cost (term matches)
POST /v1/translate1 per term match (= matchCount)matchCount
GET /v1/term/:id1 per section actually returned (full card: typically 7 to 9)free
POST /phase3matchCount + (distinct embedded terms × 0/1/3 at minimal/standard/rich)matchCount
GET /v1/usage, key managementfreefree

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

POST /v1/translate metered

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.

response (abridged: 4 of the 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 carries opsBilled (= 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.
  • plainLanguage is the curated rendering; null means the term is known but has no paraphrase yet (rare). A null match still counts toward your limit like any other: cost is matchCount, suggestion or not.
  • substitutionCount is how many of the matches carry a plainLanguage suggestion. The difference from matchCount is the null ones.
  • kinds says 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.
  • tokenCount is how many word tokens the matcher saw in your text (letters-only tokenization). phase is an internal pipeline marker (2 = the deterministic pipeline).
  • elapsedMs is 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 different Content-Type doesn'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 proof and burden may 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 plainLanguage strings riding in attributes.
JavaScript
// 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 = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#x27;" };
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));
}
Python
# 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

GET /v1/usage unmetered

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:

response (workspace key)
{
  "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.

response (developer key)
{
  "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

GET /v1/term/:id free on Workspace · per section on Developer

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.

response (abridged: the first 2 of this term's 8 sections)
{
  "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 answers 400 naming 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 to plain,watch costs at most 2. Requesting only sections the term lacks returns an empty list and costs 0.

Prompt export (bring your own AI)

POST /phase3 metered

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.

request
{
  "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"
}
response (abridged: 1 of the 15 matches and glossary entries)
{
  "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 same matchCount, whatever the grounding.
  • Developer keys: matchCount operations, plus per distinct term embedded in the glossary: 0 extra at minimal, 1 at standard, 3 at rich (the extra card sections the prompt carries). So minimal costs exactly what /v1/translate costs; a term matched five times bills five matches but one embed. opsBilled reports 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.

the messages array, POSTed to a chat API (exact shape varies by vendor)
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 system entry's content there, 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:

headers (workspace responses)
X-Quota-Limit:     7500
X-Quota-Used:      1284
X-Quota-Remaining: 6216

The 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).

StatusBodyWhen
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

POST /v1/key/roll unmetered

Replace your key. The new key is revealed once in the response; the old key stops working immediately.

curl
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…" }
POST /v1/key/revoke unmetered

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
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/checkout with { "tier": "workspace" | "developer" }{ "url" } (Stripe Checkout). Also accepts GET ?tier=…, answering a 303 redirect: 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.

  1. 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)
  2. Send Content-Type: application/json. The parser is lenient today; don't build on that. (translate)
  3. Treat offsets as UTF-16 code units. Convert before slicing in code-point languages. (rendering)
  4. Settle nesting: longest span wins. Terms can contain other terms; render the longest non-overlapping spans. (rendering)
  5. Handle plainLanguage: null. A known term can lack a suggestion; it still counts like any other match. (translate)
  6. 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)
  7. 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)
  8. Expect 413 and 429; both are free. Split the text on 413. On 429 (workspace keys only), pause until the period resets; there is no overage billing. (errors, quota)
  9. Read cost from the response. matchCount is what a request spends on a workspace key. opsBilled is what it cost on a developer key. /v1/usage is free and reports the period either way. (usage)
  10. Build on the stable surface only. That is /v1/* plus /phase3: not /billing/*, not elapsedMs. (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 /.