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. Breaking changes are announced, never silent.

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.

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."}'
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();

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.

Translate

POST /v1/translate metered

Body: { "text": string }. The response lists every known term in order of appearance. start/end are character offsets into exactly the text you sent, so you can highlight in place. Terms can nest: burden of proof and burden may both appear; render the longest non-overlapping spans.

response
{
  "phase": 2,
  "tokenCount": 24,
  "matchCount": 15,
  "substitutionCount": 15,
  "elapsedMs": 2.5,
  "matches": [
    {
      "termId": 3815,
      "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"
    },
    // …14 more matches, in order of appearance
  ]
}
  • plainLanguage is the curated rendering; null means the term is known but has no paraphrase yet (rare).
  • kinds says how the term matched: headword, subtype, synonym, word_sub
  • A malformed request costs no quota. We authenticate, then validate, then meter.

Usage

GET /v1/usage unmetered

Your plan, this period's consumption, and when the period resets. It's free to call, so it can drive dashboards and quota meters without spending a metered call.

response
{
  "tier": "core",
  "cap": 5000,
  "used": 1287,
  "remaining": 3713,
  "periodStart": "2026-07-01T14:52:11.000Z",
  "periodEnd": "2026-08-01T14:52:11.000Z"
}

Term detail

GET /v1/term/:id unmetered

The full curated card for one term (use the termId from a 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).

response (abridged)
{
  "termId": 3815,
  "term": "plaintiff",
  "slug": "plaintiff",
  "sections": [
    {
      "id": "plain",
      "label": "Plain language",
      "defaultOpen": true,
      "type": "prose+chips",
      "text": "The plaintiff is the person or group who starts a lawsuit…",
      "chips": ["person who sues", "…"]
    },
    {
      "id": "watch",
      "label": "What to watch for",
      "defaultOpen": true,
      "type": "bullets+prose",
      "bullets": ["In criminal cases the side bringing the case is usually the \"prosecution\"…"]
    },
    // …definition, byContext, subtypes, examples, contexts, related, word
  ]
}

Section ids, in order: plain, watch, definition,byContext, subtypes, examples,contexts, related, word. Matchable terms carry 7–9 of them in practice.

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 document 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…",
  "grounding": "standard"
}
response (abridged)
{
  "phase": 3,
  "refined": false,
  "tokenCount": 24,
  "matchCount": 15,
  "substitutionCount": 15,
  "elapsedMs": 2.5,
  "matches": [ /* same shape as /v1/translate */ ],
  "promptExport": {
    "grounding": "standard",
    "glossary": [
      { "term": "plaintiff", "plainLanguage": "…", "pitfalls": ["…"] }
    ],
    "prompt": "You are a plain-language editor for United States legal text.…",
    "messages": [
      { "role": "system", "content": "…" },
      { "role": "user", "content": "GLOSSARY…\n\nTEXT TO REWRITE:\n…" }
    ]
  }
}
  • grounding (optional): minimal = term + meaning, standard (default) = adds watch-out warnings, rich = adds definitions and alternate senses.
  • Metered exactly like a translate call.

Quota headers

Every metered response reports where you stand:

response headers
X-Quota-Limit:     5000
X-Quota-Used:      1288
X-Quota-Remaining: 3712

When the quota is spent, requests answer 429 until the period resets. Requests pause; nothing is billed for overage. Unmetered endpoints (/v1/usage, /v1/term/:id, key management) keep working.

Errors

Every error is JSON: { "error": string, … }. Ordering guarantee: a malformed request never costs quota (authenticate → validate → meter).

StatusBodyWhen
400{"error": "missing \"text\" (string) in body"}Validation: missing/empty text, or invalid JSON.
401{"error": "missing API key (Authorization: Bearer <key>)"}No key, or a key we don't recognize.
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 or unknown term id.
405{"error": "method not allowed"}Wrong HTTP method for the path.
409{"error": "…", "code": "trial_already_used"}Trial checkout only: one trial per email.
429{"error": "monthly API-call quota exceeded", "tier": "core", "cap": 5000, "used": 5001}Over quota. Calls resume next period. We never bill overage.
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.

  • POST /billing/checkout with { "tier": "trial" | "core" | "professional" }{ "url" } (Stripe Checkout). Also accepts GET ?tier=…, answering a 303 redirect, so a plain link works. Trial checkouts answer 409 if the email already used one.
  • 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, plan changes, and cancellation.

Browser use (CORS)

The API allows browser calls from plainlanguage.us (and localhost for development), 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.