API surfaces: public /api/v1 and the admin API
The server exposes two surfaces on one host:
| Surface | Prefixes | Auth | Purpose |
|---|---|---|---|
| Public | /api/v1/* | none | Read-only, versioned contract for consuming applications |
| Admin | /api/en/*, /api/settings, /api/auth | admin JWT (cookie / Bearer token) | Everything the admin UI does: editing, import / export, statistics, login |
Nothing under /api/v1 mutates data or requires a login; nothing outside it is part of the
public contract. The public contract is also served as an OpenAPI document;
Swagger UI, the website's reference and the other ways to read the API are compared in
api-tools.md.
The public contract
-
Versioned prefix. Response shapes under
/api/v1change only with a new prefix (/api/v2). The types consumers rely on live inapps/server/types/public/v1/. -
X-API-Version: 1on every response of the prefix, errors included. -
Envelope: every successful answer is
{ "data": ..., "meta": { ... } }— the payload underdata(a list or one object), paging and counts undermeta, never mixed into the items. -
Errors reuse the
ErrorResTshape everywhere under the prefix, whatever raised them (validation, an unknown route, the rate limit):{ "statusCode": 429, "message": "too_many_requests", "error": true } -
Languages. The headwords are English and the prefix carries no language segment: a second source language is out of scope, so
/api/v1/words/runwill not become/api/v1/en/words/run. The translation language is a filter on the answer and travels as the multi-valued query parameterlanguage(/api/v1/words/run/translations?language=ru); the detailed search'stranslation_languages(a repeated query key) does the same. Without the parameter every language is returned.GET /api/v1/metalists the languages an instance serves underavailable_languages; consumers should read it instead of assumingru. -
Rate limit. One budget per client IP for the whole prefix,
PUBLIC_API_RATE_LIMIT(<requests>/<seconds>, default100/60); every request costs one unit, the batch lookup included. Exceeding it answers429with the error above. There are no API keys yet; put the instance behind a reverse proxy if you need per-client quotas. -
Cacheable. Every successful
GETcarriesETag,Last-ModifiedandCache-Control: public, max-age=<PUBLIC_API_CACHE_MAX_AGE>; conditional requests answer304— see Caching. The search has aGETform for that reason.
Endpoints
Every successful answer is an envelope: the payload under data, paging and counts under
meta. The response types are in apps/server/types/public/v1/index.ts.
| Method | Path | Query / body | Response |
|---|---|---|---|
GET | /api/v1/meta | — | { data: { api_version, app_version, dataset_version, license, notice, counts, available_languages } } |
GET | /api/v1/openapi.json | — | the OpenAPI 3 document of this contract (no envelope; see OpenAPI document) |
GET | /api/v1/search | search, type?, limit? | { data: PublicSearchWordV1T[], meta: { count, fuzzy, short_term } } |
GET | /api/v1/search/detailed | search, type?, limit?, page?, with_meanings?, with_translations?, translation_languages? | { data: PublicWordV1T[], meta: { page, limit, has_more, fuzzy, short_term } } |
GET | /api/v1/words/{word} | — | { data: PublicWordV1T[], meta: { word, count } } |
GET | /api/v1/words/{word}/meanings | — | { data: PublicMeaningV1T[], meta: { word, count } } |
GET | /api/v1/words/{word}/translations | language? | { data: { short_translations, meaning_translations }, meta } |
GET | /api/v1/words/{word}/forms | — | { data: PublicWordFormV1T[], meta: { word, count } } |
GET | /api/v1/words/{word}/synonyms | — | { data: PublicWordLinkV1T[], meta: { word, count } } |
GET | /api/v1/words/{word}/antonyms | — | { data: PublicWordLinkV1T[], meta: { word, count } } |
GET | /api/v1/words/id/{id} | — | { data: PublicWordV1T } |
GET | /api/v1/words | filters, cursor?, limit?, with_meanings?, with_translations? | { data: PublicWordV1T[], meta: { limit, has_more, next_cursor } } |
GET | /api/v1/random | filters | { data: PublicWordV1T } |
POST | /api/v1/words/batch | { words: string[] } (1–50) | { data: { word, count, entries: PublicWordV1T[] }[], meta: { count, not_found } } |
POST | /api/v1/suggestions | { headword, word_id?, message?, kind?, edits? } | 201 { data: { id, status } } |
The same endpoints can be tried on the website's playground and the admin's Documentation pages (api-tools.md); the machine-readable contract is the OpenAPI document.
POST /api/v1/suggestions is the one write of the public surface: a reader of the
website's word pages files feedback into the instance's moderation queue (the admin
Suggestions page). Two kinds share the endpoint:
kind: "report"(the default) — a free-textmessageabout a headword;word_idoptionally names the entry from a word answer.kind: "edit"— the reader edits the word form as a whole; the proposal the admin can apply in one click travels asedits, a list of{ target_type, target_id, changes }items covering every touched piece:target_typeisword|meaning|meaning_translation|short_translation,target_idcomes from the word answers,changesholds the proposed field values (description/transcriptionfor a word,title/definitionfor a meaning or its translation,descriptionfor a short translation). The server snapshots the current values into before/after diffs at file time; unknown fields, empty values, targets of another headword and a proposal that changes nothing are rejected. Applying walks every item through the same edit services the admin UI uses, so the changes are audited and flag the entryuser_modified.
The headword must exist in the dictionary. The endpoint has a rate limit of its own —
SUGGESTIONS_RATE_LIMIT, default 5/3600 (five reports per hour per client), separate from
the shared /api/v1 budget — and answers 503 suggestion_queue_full once 500 reports are
waiting for the admin. See data.md. A suggestion the admin
applies becomes part of the dictionary data and travels with it under the data license, CC BY
4.0 (DATA_LICENSE.md); the word pages say so next to the form.
The examples use localhost:3010, the port of a start without Docker; a docker compose
installation publishes the API on localhost:3240 by default (SERVER_PORT).
curl 'http://localhost:3010/api/v1/search?search=run&limit=5'
curl 'http://localhost:3010/api/v1/search/detailed?search=run&with_meanings=true'
curl 'http://localhost:3010/api/v1/words/run'
curl 'http://localhost:3010/api/v1/words?part_of_speech=noun&word_level=B1&word_level=B2&limit=50'
curl 'http://localhost:3010/api/v1/random?part_of_speech=verb&word_level=A2'
Search tiers and typo tolerance
Both searches are GET reads: the fields travel in the query string
(translation_languages as a repeated key, booleans as true / false), the answer carries
the caching headers of the prefix, and a search can be pasted into a browser or shared as a
link. limit is 1–100 (default 10) for the search and 1–20 for the detailed one, whose page
starts at 1. translation_languages is either omitted (every language) or a non-empty list —
an empty list answers 400. The POST forms of the alpha are gone
(Removed aliases).
Both search endpoints rank their answer by tiers: exact headword, phrasal variants, starts
with the term, phrases containing it as a word, ends with it, contains it anywhere. Every tier
is served by an index on Postgres (a byte-order btree for the prefixes, a trigram GIN for the
rest). Within a tier the starts-with entries come in byte order (an autocomplete
reads them as typed) and the others shortest headword first — language before
body language for guag. An inflected form resolves to its base entry in every tier, so a
headword appears once however many of its forms match, each tier fills the part of limit
left to it with distinct headwords, and type binds every tier, the exact one included.
When no tier matches at all, a fuzzy tier answers instead: headwords whose trigrams are
similar enough to the term (pg_trgm, similarity ≥ 0.3), best match first. Such an answer
carries meta.fuzzy: true and a similarity (0–1) on every item — the "did you mean"
signal for a UI or an SDK:
{ "data": [{ "word": "relieve", "similarity": 0.45, "…": "…" }], "meta": { "count": 8, "fuzzy": true } }
Note
fuzzy is false whenever the exact tiers found something, and also when nothing at all is
similar (empty data). The fuzzy tier exists on Postgres instances only (pg_trgm); a
SQLite instance answers an empty list for a typo, with fuzzy: false.
A term of one or two characters (after trimming) searches the exact and prefix tiers
only — the headword itself, its phrasal variants, an inflected form's base entry and
headwords starting with the term — and answers with meta.short_term: true. The suffix,
substring, phrase and fuzzy tiers are skipped: half the dictionary contains any given letter,
so those tiers would return an arbitrary slice rather than a match, and a trigram index has
nothing to look up in fewer than three characters. a, I, ok, TV still answer, as
exact headwords; a blank term answers an empty list.
Headword reads
GET /api/v1/words/{word} answers every entry of a headword — one item per part of
speech, each with its forms, meanings (definitions, examples, translations, synonyms,
antonyms) and short translations. The spelling is matched case-insensitively; URL-encode
spaces for phrases (/api/v1/words/put%20up%20with). An inflected form resolves to its base
entry: /api/v1/words/ran answers the verb run (with ran among its forms). An unknown
spelling answers 404 with word_doesnt_found.
The partial reads (/meanings, /translations, /forms) flatten the same entries into one
list; every item carries word_id and part_of_speech so it can be tied back to its entry.
/translations splits into short_translations (per entry) and meaning_translations (per
meaning, with meaning_id); ?language=ru keeps one language only. /synonyms and
/antonyms list the linked headwords of every meaning — { word, meaning_id, word_id, part_of_speech }, each word readable through /words/{word} — so a thesaurus
does not need the full entry; a headword without links answers an empty list.
GET /api/v1/words/id/{id} is the same entry by its numeric id (the id of any item above).
POST /api/v1/words/batch with { "words": ["run", "ran", "put up with"] } looks
up to 50 spellings in one request — for a consumer enriching a word list, which would
otherwise spend its whole rate-limit budget on per-word GETs. Each spelling is matched like
the single read; the answer keeps the request order with one item per spelling — word (the
normalized spelling), count and entries, exactly what GET /api/v1/words/{word} answers
under data — collapsing duplicates and case, and lists the spellings without an entry under
meta.not_found instead of failing. A batch counts as one request against the rate limit
whatever its size; instances exposed to the open internet size PUBLIC_API_RATE_LIMIT with
that in mind. Being a POST, it carries no cache validators; a consumer that re-reads the same
words benefits from the cached single reads instead.
Word items are an explicit projection of the dictionary rows: the fields of
PublicWordV1T and its parts in apps/server/types/public/v1/index.ts are the whole promise,
each assigned by name from the row (src/modules/PublicApiModule/utils/projection.ts), so a
column added to the database does not become public by accident. The instance's editorial
state — generated, generated_by_model, version, user_modified — is not part of v1; it
stays on the admin API (GET /api/en/{id}), where the admin UI reads it.
Filtered list and cursor pagination
GET /api/v1/words lists entries ordered by (word, id) — the headword by its bytes,
case-folded (LOWER(word) COLLATE "C" on Postgres, LOWER(word) on SQLite: a bag of wind
before aaron burr, whatever the database locale, and a grammar pattern that keeps its
sentence capital — It’s the first time … — sorts among the is, not before a), then the
id. Filters: part_of_speech,
word_level, language_register, category, area_variant, form_of_word, plus search
and is_obsolete. Every enum filter accepts one value or a repeated key; values
of one filter are OR-ed, different filters are AND-ed
(?word_level=B1&word_level=B2&part_of_speech=noun — B1 or B2 nouns). search keeps the
headwords starting with the prefix, case-insensitively (?search=ru — run, rung, runner, …;
a phrase prefix keeps its spaces); ordered as the list is and cacheable like every GET, it
is what an autocomplete or an A–Z browser should page through instead of the search.
is_obsolete=true / false keeps obsolete or current entries only. Without
form_of_word only base forms are listed; inflected forms are reachable through their base
entry's forms or explicitly (?form_of_word=past_simple). Items carry no meanings or
short translations unless with_meanings=true / with_translations=true is passed.
Pages are read with a cursor: take meta.next_cursor of a page and pass it back as
?cursor= (with the same filters) to get the next one; next_cursor is null on the last
page and has_more says whether there is one. Unlike page numbers, a cursor never repeats or
skips an item while the dictionary is being edited. limit is 1–100, default 20.
Important
The cursor is opaque — do not build it by hand; an unrecognised value answers 400 with
invalid_cursor, and so does a cursor taken from a page read with other filters (it carries
a fingerprint of them); ?cursor= with nothing after it is the first page.
Random entry
GET /api/v1/random answers one random entry matching the same filters as the list (base
forms unless form_of_word is given); 404 when nothing matches. The draw is an index
lookup, not ORDER BY random() — cheap on a 300k-row dictionary; entries right after a gap
in the ids come up slightly more often, which does not matter for a "word of the day".
Meta
GET /api/v1/meta describes what the instance serves: api_version ("1"), app_version
(the server's package.json), dataset_version (the version of the dataset the dictionary
was last imported from, null for data authored in place or imported without a manifest),
the terms of the data — license (the SPDX identifier, "CC-BY-4.0"), license_url and
attribution (the line a consumer has to show, see DATA_LICENSE.md),
notice (the provenance line to pass on to readers: the data is generated by language models
and not human-verified, data.md) — and counts (entries, words, phrases, grammar patterns, word forms, meanings, meaning and short
translations; the counts are refreshed at most once a minute), and available_languages: source, the language of the headwords (["en"]), and translations, the
languages a translation may carry on this build (["ru", "es", "fr", "de", "pt", "zh", "ar"]) — the values
?language= accepts. They describe the schema, not the data: a language is listed whether or not a
translation in it has been imported yet.
OpenAPI document
The contract above is also an OpenAPI 3 document, built from the controllers, DTOs and
response types of the running code — nothing is hand-written, so it cannot drift.
GET /api/v1/openapi.json serves it from any instance, with the caching headers of the prefix;
apps/server/openapi/public-v1.json is the committed copy the SDKs and the website are
generated from. Where each is used and the regeneration chain after a change:
api-tools.md. The
generator bootstraps the application without listening, on an in-memory SQLite database, so it
needs no .env and its output depends on the source code only; admin.json — the whole API
including the admin surface — is written next to it and ignored by git.
Response schemas. The controllers type their answers with the TypeScript
contract in apps/server/types/public/v1, which Swagger cannot see, so the generator reads
those types with ts-json-schema-generator, converts the result to OpenAPI 3.0 component
schemas (nullable for | null, enums, generics inlined; src/openapi/json-schema-to-openapi.ts)
and commits them to openapi/public-v1.schemas.json. src/openapi/public-responses.ts maps
every public operation to its response type and error statuses; the document build fails for a
route missing there, so an endpoint cannot ship untyped. The running server serves the committed
schemas — no TypeScript at runtime. test/public-schemas.e2e-spec.ts calls every operation on a
seeded dictionary and validates the real bodies against the served schemas (strictly, unknown
fields fail), which is what makes the schemas trustworthy for SDK generators.
SDKs
- Node.js / TypeScript —
@vocab-bloom-hub/client: one method per endpoint (the batch and thesaurus reads included), types generated fromopenapi/public-v1.json, typed errors, cursor and page iteration, optional ETag cache, opt-in retry on429/5xxhonouringRetry-After, a versionedUser-Agent. ESM and CommonJS with a declaration file each. On npm since the first alpha. - Python —
vocab-bloom-hub: sync and async clients on httpx, pydantic models generated from the same spec, typed exceptions, per-request options, opt-in retry, cursor and page iteration, ETag cache,words_dataframe()for notebooks. On PyPI since the first alpha.
Caching
Dictionary data changes rarely, so the public GET reads are built to be cached by browsers,
CDNs and reverse proxies. Every successful GET answer carries:
| Header | Value |
|---|---|
ETag | weak, a hash of the JSON body: W/"…". Changes exactly when the answer changes |
Last-Modified | the newest change anywhere in the dictionary (entries, words, meanings, translations), refreshed once a minute |
Cache-Control | public, max-age=<PUBLIC_API_CACHE_MAX_AGE> (default 3600); public, no-cache when the variable is 0 |
HEAD answers with the same three headers as the GET of that URL, so a cache that checks
freshness with HEAD sees the validators it stored. A client that sends the tag back
revalidates in one bodiless round trip:
curl -i 'http://localhost:3010/api/v1/words/run' # 200, ETag: W/"…"
curl -i -H 'If-None-Match: W/"…"' 'http://localhost:3010/api/v1/words/run' # 304, no body
curl -i -H 'If-Modified-Since: <Last-Modified>' 'http://localhost:3010/api/v1/words/run'
Invalidation is implicit: the ETag is a content hash, so the first answer after an edit or
an import carries a new tag and a 304 is never served for changed data. Last-Modified is
informational (a minute behind at most) — when both validators are sent the ETag decides. A
CDN or proxy in front keeps an answer for max-age and then revalidates; lower
PUBLIC_API_CACHE_MAX_AGE (or set it to 0) on an instance whose dictionary is edited live.
Note
Not cached: the two POST requests, the batch lookup and a suggestion (HTTP caches do not
store POST), every error under the prefix (Cache-Control: no-store, so a miss or a 429
is never served from a cache), and everything under the admin prefixes (no-store on every
answer, including 401s and the 404s of a disabled surface).
Removed aliases
POST /api/en/search and POST /api/en/search/detailed — the pre-public-API search routes
that answered with the bare bodies and a Deprecation: true header through the alpha — are
gone since v0.2.0-beta.1, and so are the POST /api/v1/search and
POST /api/v1/search/detailed forms that bridged the alpha (the same fields in a JSON body).
All four answer 404 like any unknown route; the successors are GET /api/v1/search and
GET /api/v1/search/detailed with the { data, meta } envelope.
Running a public-only or admin-only instance
Two switches decide which surfaces an instance serves; both default to on:
| Variable | Effect when false |
|---|---|
PUBLIC_API_ENABLED | /api/v1/* answers 404 as if the routes did not exist |
ADMIN_API_ENABLED | /api/en/*, /api/settings, /api/auth answer 404; the admin UI cannot sign in |
Warning
Disabling both is a configuration error and the server refuses to start.
A demo or embedded instance runs with ADMIN_API_ENABLED=false (edit the data elsewhere and
move it over with the dataset export / import, see offline-import.md);
an internal editing instance that must not be readable from outside runs with
PUBLIC_API_ENABLED=false.
Probes: /api/health and /api/ready
Two routes live under /api but belong to neither surface: the liveness probe
GET /api/health (200 { status: 'ok', version }) and the readiness probe GET /api/ready
(200 { status: 'ok' }, or 503 { status: 'error', reason } while the database does not answer
(database_unreachable), a shutdown is draining requests (shutting_down), the automatic
dictionary load of the first start is still running (importing, with percent and stage)
or that load failed (import_failed, with error)). They need no login, are not rate-limited, ignore both
switches above and are sent with Cache-Control: no-store. They are not part of the /api/v1
contract — the public OpenAPI document does not list them and the SDKs do not wrap them; they
are for process managers, orchestrators and the reverse proxy
(deployment/README.md).
Keeping the admin API private behind a reverse proxy
When one instance serves both surfaces and only the dictionary should be reachable from the
internet, expose /api/v1 and fence the admin prefixes (/api/en, /api/settings,
/api/auth) at the proxy — by address list or basic auth — or serve them only from a private
network. Tested Caddy and nginx configs for that, TLS, the TRUST_PROXY setting the rate limits
need behind a proxy, and the other exposure profiles are in
deployment/reverse-proxy.md.
Set CORS_ORIGINS to the origins that may call the API from a browser; curl-style clients
are not affected by CORS.