Vocab Bloom Hub
Esta página solo está disponible en inglés.

API surfaces: public /api/v1 and the admin API

The server exposes two surfaces on one host:

SurfacePrefixesAuthPurpose
Public/api/v1/*, /api/compat/dictionaryapi/*, /api/compat/freedictionaryapi/*noneRead-only, versioned contract for consuming applications
Admin/api/en/*, /api/settings, /api/authadmin JWT (cookie / Bearer token)Everything the admin UI does: editing, import / export, statistics, login

The native public contract lives under /api/v1; the separate dictionaryapi.dev adapter and freedictionaryapi.com adapter use upstream response shapes. Start with the dictionaryapi.dev migration guide or the freedictionaryapi.com migration guide for URL changes and reading every open dataset in an adapter's response format. Compatibility reads require no login. 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/v1 change only with a new prefix (/api/v2). The types consumers rely on live in apps/server/types/public/v1/.

  • X-API-Version: 1 on every response of the prefix, errors included.

  • Envelope: every successful answer is { "data": ..., "meta": { ... } } — the payload under data (a list or one object), paging and counts under meta, never mixed into the items.

  • Errors reuse the ErrorResT shape 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/run will not become /api/v1/en/words/run. The translation language is a filter on the answer and travels as the multi-valued query parameter language (/api/v1/words/run/translations?language=ru); the detailed search's translation_languages (a repeated query key) does the same. Without the parameter every language is returned. GET /api/v1/meta lists the languages an instance serves under available_languages; consumers should read it instead of assuming ru.

  • Rate limit. One budget per client IP for the whole prefix, PUBLIC_API_RATE_LIMIT (<requests>/<seconds>, default 100/60); every request costs one unit, the batch lookup included. Exceeding it answers 429 with the error above. There are no API keys yet; put the instance behind a reverse proxy if you need per-client quotas. The one exemption is the instance's own website: its server-side requests carry INTERNAL_API_TOKEN as X-Internal-Token and are not counted (environment.md).

  • Cacheable. Every successful GET carries ETag, Last-Modified and Cache-Control: public, max-age=<PUBLIC_API_CACHE_MAX_AGE>, stale-while-revalidate=<the same>; conditional requests answer 304 — see Caching. The search has a GET form 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.

MethodPathQuery / bodyResponse
GET/api/v1/meta—{ data: { api_version, app_version, dataset, source, dataset_version, license, …, counts, available_languages } }
GET/api/v1/openapi.json—the OpenAPI 3 document of this contract (no envelope; see OpenAPI document)
GET/api/v1/searchsearch, dataset?, type?, limit?{ data: PublicSearchWordV1T[], meta: { count, fuzzy, short_term } }
GET/api/v1/search/detailedsearch, dataset?, 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}/translationslanguage?{ 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/{word}/history—{ data: PublicChangeV1T[], meta: { word, count } }
GET/api/v1/words/{word}/datasets—{ data: PublicWordDatasetV1T[], meta: { word, datasets, found } }
GET/api/v1/words/{word}/datasets/{dataset}/history—{ data: PublicChangeV1T[], meta: { word, count } }
GET/api/v1/words/id/{id}—{ data: PublicWordV1T }
GET/api/v1/wordsfilters, cursor?, limit?, with_meanings?, with_translations?{ data: PublicWordV1T[], meta: { limit, has_more, next_cursor } }
GET/api/v1/randomfilters{ 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, dataset?, word_id?, message?, kind?, edits?, author_name?, author_consent? }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-text message about a headword; word_id optionally 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 as edits, a list of { target_type, target_id, changes } items covering every touched piece: target_type is word | meaning | meaning_translation | short_translation, target_id comes from the word answers, changes holds the proposed field values (description / transcription for a word, title / definition for a meaning or its translation, description for 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 recorded in the history of the dataset — as a correction of a reader — and flag the entry user_modified.

A sender who wants to be credited passes author_name (up to 80 characters) together with author_consent: true: the name is published with the correction — on the word page, in /words/{word}/history, in the exports of the data. A name without the consent answers 400; a consent without a name names nobody. The owner removes a name on request (data.md).

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 license of the dataset it corrects — CC BY 4.0 for the project's own (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'

Selected and grouped search (#652)

GET /api/v1/search and /search/detailed accept optional dataset=<name>. Omitting it retains the existing active-dataset envelope and behavior. An explicit selector searches that public dataset without activating it and adds meta.dataset. Unknown, closed and deleted targets all return 404 dataset_not_found, without fallback or private details. A temporarily unreadable selected dictionary returns 503 dataset_unavailable. Unselected search also sends X-Dataset, identifying the active dictionary without changing its JSON envelope. Use the result's exact spelling and dataset in a website link: /en/word/Amber?dataset=my_words; IDs are local to that dataset.

GET /api/v1/datasets lists publicly readable dataset terms in installation order and meta.max_search_datasets (currently 5). It opens no dictionary connections and includes no private datasets or word counts. Use it to populate selectors.

GET /api/v1/search/datasets?search=run&datasets=default&datasets=my_words is a separate, grouped detailed search. Specify 1–5 unique names as repeated keys; there is no implicit “all dictionaries” selection. The same type, meanings/translation flags and translation language filters apply to each group. Groups follow installation order, regardless of the order of the requested names. Each group carries the dataset's full terms (name, title, source, version, license and attribution). The same spelling in two dictionaries remains two results with separate local IDs, content and source terms.

  • status: "ok" has data and detailed-search meta (page, limit, has_more, fuzzy, short_term). Zero matches is an empty data array. Scores/ranking belong to the group; scores from different dictionaries are not compared.
  • status: "unavailable" has error: "dataset_unavailable", with no data or pagination. Reader failures return HTTP 200 with meta.partial: true, even if every selected reader failed. An inaccessible target rejects the whole selection with 404 instead.
  • limit is 1–20 per group (default 10), page is 1–20 (default 1); at most 100 entries are returned. To advance just one group, repeat the request with only its name and the next page. The website's group pagination does this independently.
  • One request consumes one unit of the shared public budget per selected dataset, including failed reads. All units are charged before searching; if the budget runs out, the request returns 429 without searching (units already charged are not refunded). Single-dataset searches cost one unit. Readers are used sequentially, at most one at a time per grouped request; the existing indexed search tiers are reused unchanged.
  • Selected searches and the selector inventory use Cache-Control: public, no-cache, must-revalidate and a body ETag. Only ETags validate these responses; Last-Modified is informational because visibility changes can occur within one HTTP-date second. Changing an inactive selected dictionary changes its results and ETag. Closing a target yields 404 even with a previously valid validator. Partial failures use no-store and never return 304. Unselected search retains the existing cache policy.
curl 'http://localhost:3010/api/v1/search?search=run&dataset=wiktionary'
curl 'http://localhost:3010/api/v1/search/datasets?search=run&datasets=default&datasets=wiktionary&limit=5&with_meanings=true'

On the website, open Dictionaries, select up to five, and submit the search. No selection means the active dictionary. The query and selection travel in q, dataset (one selection) or repeated datasets keys (several), including when switching interface language. Results link to their originating dictionary's word-page tab; unsuccessful groups are shown separately.

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 } }

Nota

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 (unless the dictionary holds both spellings); 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).

Spellings that differ by case

The project's own dataset writes its headwords in lower case, and a lookup does not depend on the case of the letters: Run, RUN and run answer the same entries with meta.word: "run". A dataset of a public source holds words that differ by nothing else — Polish, the language, and polish, to make shiny; Test, a match of cricket, and test. Those are words of their own:

RequestAnswermeta.wordmeta.variants
/words/polishthe entries spelled polishpolish["Polish"]
/words/Polishthe entries spelled PolishPolish["polish"]
/words/POLISHthe entries of both: the dictionary has no word spelled sopolish["Polish", "polish"]
/words/Run, /RUNthe entries of run: the dictionary holds one spellingrun[]

meta.variants lists the other spellings of the headword, each readable through /words/{word}, and every entry says its own in word. The partial reads, the history and the batch lookup match the same way. variants was added after 1.0 and is optional in the contract.

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.

Every word names its source: where the data of the entry comes from — vocab-bloom-hub for the project's own dataset, wiktionary, wordnet, princeton-wordnet, opengloss, or an own dataset's name. This identifies the serving dataset, not the complete ancestry of a word. An instance serves one dataset at a time (datasets.md), so every word of an answer has the same source; the terms that go with it are in GET /api/v1/meta. The one read that answers from several datasets, /words/{word}/datasets, groups the entries by dataset and states the terms of each group. Word-specific origins, contributions and licenses retain additional terms (below). The field was added after 1.0 and is optional in the contract: a server of 1.0 does not send it.

source is on every answer that carries an entry, a part of one or an edit of one: the headword, id, batch, list and random reads, both searches, the items of /meanings, /forms, /translations, /synonyms and /antonyms, the edits of /history, and the entries of every group of /words/{word}/datasets. An item taken out of its answer still says what it is attributed to.

Every word says whether it was modified: changed or added on the instance, so that it is not, or not only, what its source says. The licenses of the datasets ask that a reader is told; show it wherever you show the entry. The flag is per entry — the noun of a headword may be modified and its verb not — and goes back to false when an update of the dataset replaces the entry with the content of its source, or when the change is taken back. Optional in the contract like source.

The mark is on every answer that carries an entry or a part of one: the headword, id, batch, list and random reads, both searches, and the partial reads — an item of /meanings, /forms, /translations, /synonyms and /antonyms carries the modified of the entry it belongs to, next to word_id and part_of_speech. GET /api/v1/meta says it of the dataset as a whole: modified_entries.

The history of a headword

GET /api/v1/words/{word}/history lists what was changed: the edits of the entries of the headword that show in what is served, the latest first. The history of a dataset starts empty — the data is taken to be what its source published until an edit is recorded — so it answers an empty list for a headword served as its source has it, and 404 for an unknown spelling; an inflected form answers with the history of its base entry.

{
  "data": [
    {
      "created_at": "2026-09-27T10:00:00.000Z",
      "word": "lamp",
      "part_of_speech": "noun",
      "entity": "meaning",
      "action": "update",
      "record": { "title": "a light", "sort_order": 1 },
      "diff": { "definition": { "before": "A light.", "after": "A device that gives light." } },
      "origin": "suggestion",
      "author": "Ada Lovelace",
      "source": "wiktionary"
    }
  ],
  "meta": { "word": "lamp", "count": 1 }
}
FieldWhat it says
entitywhat was edited: word, word_form, meaning, meaning_translation, short_translation
actioncreate, update or delete
wordthe spelling of the entry the edit belongs to, as word of the entry says it: with part_of_speech it names the entry among the ones /words/{word} answers
part_of_speechthe entry of the headword the edit belongs to
recordwhat names the edited record inside its entry — the form, the title of the meaning, the translation; null for the entry itself. No ids
diff{ field: { before, after } }: the fields that changed; every field of a record that was added (before: null) or deleted (after: null)
originadmin — an edit of the owner; suggestion — a correction of a reader the owner applied
authorthe reader who sent the correction, when they asked to be named; null otherwise
sourcethe source of the dataset the edit was made in: what the changed entry is attributed to, next to the owner of the instance

The values are what the records say: related words by their spelling, no ids, no timestamps of the database — the same history reads the same on another instance. The editorial state of the instance (generated, generated_by_model) is left out. At most 200 edits are listed, the latest. How the history is kept and undone: datasets.md.

A headword in every dataset

An instance may hold several datasets and serves one of them. GET /api/v1/words/{word}/datasets reads the headword from every open dataset the instance holds, for a consumer that compares what the sources say — the entry of the project's dataset next to the one of Wiktionary or WordNet — without the owner switching anything. The answer is one group per dataset, in the order the datasets were installed, and the groups are never merged:

{
  "data": [
    {
      "dataset": "default",
      "active": false,
      "source": "vocab-bloom-hub",
      "dataset_version": "1.0.0",
      "license": "CC-BY-4.0",
      "license_url": "https://creativecommons.org/licenses/by/4.0/",
      "attribution": "…",
      "attribution_url": "…",
      "notice": "…",
      "license_text": "",
      "word": "polish",
      "variants": [],
      "count": 2,
      "entries": [{ "id": 17, "word": "polish", "source": "vocab-bloom-hub", "modified": false, "…": "…" }]
    },
    {
      "dataset": "wiktionary",
      "active": true,
      "source": "wiktionary",
      "dataset_version": null,
      "license": "CC-BY-SA-4.0",
      "license_url": "https://creativecommons.org/licenses/by-sa/4.0/",
      "attribution": "…",
      "attribution_url": "https://en.wiktionary.org",
      "notice": "",
      "license_text": "",
      "word": "polish",
      "variants": ["Polish"],
      "count": 3,
      "entries": [{ "id": 90412, "word": "polish", "source": "wiktionary", "modified": true, "…": "…" }]
    }
  ],
  "meta": { "word": "polish", "datasets": 2, "found": 2 }
}
FieldWhat it says
datasetthe name of the dataset on the instance: default, wiktionary, wordnet, wordnet_princeton, opengloss, or an own dataset/fork name
activewhether it is the dataset the other routes of the API serve
source, dataset_version, license, license_url, attribution, attribution_url, notice, license_textthe terms of that dataset, the ones /meta states for the served one
word, variantsthe headword the entries belong to in that dataset and its other spellings there (by the rule of a headword read); the asked spelling in lower case and [] for a dataset without the headword
count, entrieswhat GET /words/{word} would answer under data if that dataset were the served one; 0 and [] for a dataset without the headword
meta.wordthe spelling that was asked
meta.datasets, meta.foundhow many datasets the instance holds, and how many of them hold the headword
  • The spelling is matched inside each dataset. polish names one word in a dataset that writes its headwords in lower case and one of two in a dataset that holds Polish as well, so every group has a word and variants of its own.
  • An entry says what its own dataset says of it: source is the source of the group, and modified tells whether the entry was changed on the instance in that dataset. What was changed is read from GET /api/v1/words/{word}/datasets/{dataset}/history — the answer of the history of a headword, read from the dataset that is named; 404 dataset_not_found for a dataset the instance does not hold.
  • Ids are per dataset: the id of an entry means something in its group only. /words/id/{id} reads the served dataset.
  • The terms are per group. An entry is used under the terms of the group it was taken from, and a consumer that takes entries from several groups is bound by the terms of each: a share-alike license of one group does not reach the entries of another, and does not leave its own.
  • 404 word_doesnt_found when no dataset holds the headword. A dataset without it answers an empty group: that the source has no such word is information.
  • One request against the rate limit for this headword read, whatever the number of datasets. Grouped search has a separate weighted budget cost (see above).
  • Only publicly open datasets are read, whether active or not, with their edit history and credited authors. New installations and own datasets start closed; the administrator controls visibility.
  • A report names the viewed dataset. Send its group’s dataset with POST /api/v1/suggestions; all IDs are validated in that dataset and headword. Omitting dataset uses the active dataset. Closed, unknown or deleted targets answer 404 dataset_not_found without fallback. The client rate budget is shared across targets; each dataset has its own queue cap. Reports remain available for admin moderation after closing a dataset. See reader reports.

An instance with one dataset — every instance on SQLite — answers one group.

What a consumer does with it

The mark and the history exist because the licenses of the data ask that a change is indicated, and the obligation passes to whoever shows the data further:

You readYou do
attribution, license_url of /metashow the attribution line and link to the license wherever you show the data
modified: true on a word or a partsay next to the entry that it was changed: it is not, or not only, what its source published
/words/{word}/historyoptional: show or link what was changed, and credit the author of a correction when one is named
a group of /words/{word}/datasetsdo all of the above per group, with the terms the group carries: entries of two groups are shown under two attributions
license of /meta ending in -SA-4.0keep what you build on the data, changed entries included, under the same license
a non-empty license_text of /metakeep the text with every copy of the data: show it, or link to a page that does
modified_entries of /meta above 0know that the instance serves data that differs from its source
word origins, contributions, licensesretain each source's and contribution's attribution, notices and associated licenses; the dataset's primary license does not replace them

Read them from the API rather than hard-coding them: the owner may activate another dataset, edit an entry or take a change back at any time. The terms in full: DATA_LICENSE.md.

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.

Importante

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), the dataset — dataset (its name on the instance, default for the one it was born with), source (where its data comes from: vocab-bloom-hub, wiktionary, wordnet, princeton-wordnet, opengloss, or an own dataset name) and dataset_version (the imported version, or the version set by the owner for an own dataset or fork; null when unknown; a string to show, without a promised format: 1.0.0 for the project's dataset, the day of the extract for Wiktionary, 2026.09.25, the edition for a WordNet, 2025, or OpenGloss 2.4 — datasets.md) —, the terms of the data — license (the SPDX identifier, "CC-BY-4.0" for the project's dataset, "CC-BY-SA-4.0" for Wiktionary), license_url, attribution (the line a consumer has to show, see DATA_LICENSE.md) and attribution_url (where the line links to, null when the dataset names none), notice (the provenance line to pass on to readers — for the project's dataset: the data is generated by language models and not human-verified, data.md; an empty string when the dataset has none) — 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.

The terms are the ones of the active dataset and change when the owner activates another one (datasets.md): read them from meta rather than hard-coding the license of the project's dataset. license_text holds the notices the source of the dataset asks to be kept with its data, in full — the WordNet license, the one of the CMU Pronouncing Dictionary; an empty string for a dataset whose license is named by its link alone. Show it, or link to a page that does, wherever the license asks for its text on every copy. dataset, source, attribution_url and license_text were added after 1.0 and are optional in the contract.

modified_entries counts the headwords the instance serves with entries that were changed or added on it: 0 says that the data is what its source published, anything else that a consumer shows data that differs from the source and has to say so (The history of a headword). It is refreshed with the counts, at most once a minute. Optional in the contract.

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 from openapi/public-v1.json, typed errors, cursor and page iteration, optional ETag cache, opt-in retry on 429 / 5xx honouring Retry-After, a versioned User-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:

HeaderValue
ETagweak, a hash of the JSON body: W/"…". Changes exactly when the answer changes
Last-Modifiedthe newest change anywhere in the dictionary (entries, words, meanings, translations), refreshed once a minute; for the reads of every dataset, the newest change of any dataset the instance holds and of their set (one installed, activated or deleted)
Cache-Controlpublic, max-age=<PUBLIC_API_CACHE_MAX_AGE>, stale-while-revalidate=<the same> (default 3600): a shared cache keeps the answer for an hour and may serve it stale for another while it revalidates in the background; 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.

Nota

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:

VariableEffect 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

Advertencia

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.

Word origins and license associations

Word, search, form, and partial word responses can include optional origins[], contributions[] and licenses[]. Origins belong to a base headword and part of speech. Each origin carries a name, nullable version, optional url/record_url, attribution, mandatory notices, and its licenses (identifier where known, name, URL, optional full text). A flattened license includes origin_id, referring to an origin or contribution; preserve that association when displaying attribution.

contributions contains the distinct terms of content edits that still apply to the word. An unchanged fork/copy retains the original origins without acquiring the receiving dataset's license. An actual edit adds its captured contribution terms and license; reverting all such edits removes that contribution from the current response. Earlier snapshots keep their recorded names, versions and terms when dataset settings change.

scope: "dataset" means the source did not identify the affected words, not that each contributor was verified for each word. An origin's method is "manual" for user-declared attribution or "dataset" for captured dataset terms. recorded_at may be null when the acquisition date is unknown. Repeated copies/forks are recorded in acquisitions[], with a method, date and revision; via can name an intermediate dataset without adding its license to unchanged material. inherited protects captured source terms from ordinary editing. license_relation: "all" means cumulative terms; "any" means the source offers alternatives. Neither a list nor a custom license is a compatibility guarantee.

Dataset groups and /meta expose their upstream origins and description; their legacy primary license remains the dataset's own/default contribution license. source still identifies the dataset serving the response. History may include inherited_from, a metadata-correction reason, and a contribution snapshot. OpenGloss words demonstrate why these fields matter: all retain the OpenGloss source; those marked as derived from WordNet also retain its 3.0 source and license. A consumer cannot infer that distinction from source: "opengloss" or /meta.license alone. These fields are optional so clients continue to read older v1 servers. The generated TypeScript and Python SDK models include them. See datasets for editing and offline import for compatibility.

dictionaryapi.dev compatibility

Replace the base URL https://api.dictionaryapi.dev/api with https://<your-instance>/api/compat/dictionaryapi. Both lookup versions are supported:

GET /api/compat/dictionaryapi/v2/entries/en/hello
GET /api/compat/dictionaryapi/v1/entries/en/hello

The ordinary lookup routes are anonymous reads of the active dataset only, using the native headword reader; they never contact dictionaryapi.dev or combine dictionaries. Activate the desired dataset in the admin UI. vocabBloom.dataset identifies it in each result. Only English is supported: en, and the historical en_US / en_GB aliases (case-insensitive). The aliases do not filter pronunciation regions. A language is not stored on each word. Query parameters, including dataset and the historical include=example option, are unsupported and return 400.

Response mapping

Both versions return a JSON array, without the native { data, meta } envelope. One item represents one native entry (part of speech); entries with distinct provenance are not merged. A headword with several parts of speech can therefore have more array items than upstream. Version 2 has meanings: [{ partOfSpeech, definitions, synonyms, antonyms }]; version 1 has meaning: { "noun": [definitions], ... }, as in the upstream legacy contract. Other fields are the same. The adapter is listed in /api/v1/openapi.json and the website's API reference. Generated SDK types include it; the native SDK methods and native response envelopes are unchanged.

  • Definitions stay flat and in native meaning order. Both definition-level and meaning-level synonym/antonym arrays are sorted and deduplicated; the latter are derived from definitions.
  • example is the first nonempty stored example, omitted when none exists. Quotes are not silently substituted for examples. The native API retains all examples and quotes.
  • modal_verb maps to verb, numeral_fractional to numeral, grammar_pattern to phrase; all other supported part-of-speech names are unchanged. Arbitrary source tags are ignored.
  • origin joins nonempty etymology texts by local number, separated by blank lines. Missing etymology omits the field. The native API retains the meaning-to-etymology references.
  • phonetic is the preferred IPA (American, then British, then the first remaining IPA). phonetics follows stored pronunciation order. Each recording becomes its own item, so multiple recordings do not overwrite each other. IPA without audio has audio: ""; audio without IPA omits text. EnPR is not presented as IPA; its recordings can still be returned without text. sourceUrl and audio license are omitted when unknown. No region field exists upstream; regions affect IPA preference but are not invented in its response.
  • Empty arrays remain arrays; absent optional scalars are omitted, not null. Empty entries can have an empty definitions array; the adapter does not invent a definition.
  • Matching follows native rules: case-insensitive when unambiguous, exact capitalization when distinct spellings exist, all matching spellings when neither is exact. Inflections resolve to their base entries (including the base spelling and pronunciations in the answer). Encode words, including spaces and literal percent signs, as one URL path segment.

Sources and licenses

The adapter does not label our data as Wiktionary. Its additive vocabBloom object carries dataset, source, modified, and complete origins, contributions, and licenses snapshots, including attribution, notices, custom license texts and all/any license relations. Keep these terms when storing or redistributing the data. Audio items carry their own vocabBloom: { attribution, licenses }; text licenses never become recording licenses.

The upstream singular license: { name, url } is a summary. For one distinct license it uses that license's name and URL. For multiple licenses it says Multiple licenses — see vocabBloom terms and links to the native word/datasets response, instead of choosing one and losing obligations. A license without its own URL also links there; unknown licenses omit license. sourceUrls includes exact source/record URLs, the native word/datasets link and, for modified entries, the dataset-specific edit history. These native links are absolute URLs on the requested host. A reverse proxy must preserve the public Host and configure TRUST_PROXY correctly for HTTPS links. Follow the group named by vocabBloom.dataset in a datasets response. The inline snapshots remain the authoritative terms of the returned content even if the instance later changes its active dataset or terms.

HTTP behavior and compatibility limits

Successful reads use application/json, ETag, Last-Modified and the instance's PUBLIC_API_CACHE_MAX_AGE. Conditional GET/HEAD requests can return 304. The adapter shares PUBLIC_API_RATE_LIMIT with /api/v1, respects INTERNAL_API_TOKEN and is hidden by PUBLIC_API_ENABLED=false. It does not send the native X-API-Version: 1 header. Browser reads allow any origin (Access-Control-Allow-Origin: *, without credentials); admin and native CORS settings remain controlled by CORS_ORIGINS.

Missing words, unsupported languages and unknown routes/versions return 404 with the upstream three-string { title, message, resolution } error shape. Invalid input returns 400 and rate limits return 429 with the same shape and Retry-After; errors use Cache-Control: no-store. Error wording for 400/429/500 and cache/rate-limit durations are local policies, not promises to reproduce upstream infrastructure behavior.

The scope is the English v1 and v2 lookup contracts, with the documented additive provenance fields and mapping choices. Clients that reject unknown JSON properties must allow vocabBloom. This does not reproduce upstream word coverage, sense grouping, definitions, recordings, pronunciation-region selection, unsupported language databases, optional query extensions, or upstream media hosting. Converters can only expose data preserved in the installed dataset; see datasets and offline import.

Contract evidence checked on 2026-10-09: the upstream documentation, legacy v1 specification, and error/route implementation. Live hello requests confirmed both versions and the newer license/source URL fields. Live missing-word and invalid-language probes timed out; their documented error shape/status is based on the published implementation, not on a CDN timeout. Tests use original local fixtures and never require upstream availability.

freedictionaryapi.com compatibility

Replace https://freedictionaryapi.com/api/v1 with https://<your-instance>/api/compat/freedictionaryapi/v1:

GET /api/compat/freedictionaryapi/v1/entries/en/hello?translations=true
GET /api/compat/freedictionaryapi/v1/languages?pretty=true

These routes read the active dataset, without calling the upstream service. The entries response has word, entries and source; the languages response is an array of { code, name, words }. The native API and dictionaryapi.dev adapter keep their own contracts.

Lookup and query rules

  • en and all both read the English database. Other language codes, including EN and en_US, return 200 with empty entries. No language column is stored.
  • Spelling is case-sensitive, with no trimming or fuzzy matching. Missing words also return 200 with empty entries; the top-level word preserves the requested spelling. Encode the word as one URL segment; phrases and literal percent signs are decoded once.
  • A stored inflection resolves to its base entry, using the form's effective pronunciations. An alternative spelling with its own entries returns those entries. An alternative-only EnEntry follows its spelling links one hop to readable entries, including their base forms; no part-of-speech relation or transitive spelling chain is inferred. entries[].vocabBloom.word identifies the actual base spelling returned.
  • translations defaults to false: the sense field is omitted, not an empty array. With true, each sense has a translations array, possibly empty. pretty defaults to false; true uses two-space indentation without a trailing newline, for either route.
  • Both options accept only the lowercase strings true and false. Empty strings, 1, 0 and other values produce a 400 plain-text boolean parsing error. For repeated parameters, the first occurrence wins. /languages ignores translations, as upstream does. Other unrecognized parameters are ignored, except dataset, which returns 400 because these routes cannot select another dataset.
  • /languages returns only English, even when empty. words counts distinct readable spellings in the active database, including phrases, inflections and one-hop alternative-only spellings. Multiple parts of speech count once. Unlinked placeholder rows created for thesaurus links do not count. This is not a count of meanings or translation languages.

Mapping the stored data

Each base word/part of speech becomes one entry. IPA and EnPR records with text become pronunciations; regions map to broad British, American and Australian tags. Common pronunciations have no region tag. Audio-only records are omitted: this upstream contract has no audio or etymology fields. Use the native API for those fields.

Meanings stay flat, with subsenses: []. Definitions, all stored examples, sense synonyms and antonyms are preserved. Entry-level synonyms and antonyms are deduplicated unions of the senses. Quotes keep their text; reference combines a supplied reference and source URL, or is an empty string when neither is known. No bibliographic details are invented.

Known enums map to response strings, without adding tag columns: modal verbs become verb, fractional numerals numeral, grammar patterns phrase; other parts of speech keep their names. Sense tags include stored region, register, obsolete/uncountable/transitivity flags and categories (IT becomes computing). Word-level region and register supply defaults for meanings. Arbitrary source tags are not preserved.

forms maps the existing form enums: plural; singular/plural possessives; past; past/present participles; third-person singular present; comparative/superlative; object; possessive adjective/pronoun; reflexive; ordinal; multiplicative. A base spelling returned for a form or alias has canonical; spelling links have alternative, separate from inflections. The Wiktionary converter currently recognizes seven inflection patterns: plural, comparative, superlative, past, past participle, present participle and third-person singular present. Other native form enums can be supplied through native editing/import; the adapter does not recover forms or tags discarded during conversion.

Translations come from each meaning's lexical variants_of_words, with { language: { code, name }, word } per variant. Supported languages remain ru, es, fr, de, pt, zh, ar. Localized definition/title text and word-level short translations are not misrepresented as sense translation words. Converter limits (including supported languages, three examples and six translation words per imported sense/language) still apply; this adapter does not restore source data absent from an installed dataset.

Sources, licenses and HTTP behavior

source describes the returned data, never a hard-coded Wiktionary attribution. A unique source URL is retained; multiple sources use a link to the native word/datasets response. One license is summarized by name and URL; custom terms without a URL link to that native response. Multiple licenses use a summary pointing to the full terms, without choosing one license to cover the whole response. Unknown terms are identified as unavailable. Empty results use the active dataset's terms and the native metadata URL.

The additive vocabBloom object at the top contains the active dataset's terms. On every entry it contains the dataset, resolved spelling, source, modified state, exact origin and contribution snapshots, and their derived licenses. These retain attribution, notices, custom license text, versions and all/any license relations. Consumers must retain the applicable terms in these extensions; a single upstream-shaped source.license cannot express all obligations. Clients that reject unknown fields must allow vocabBloom.

Reads share the native and dictionaryapi.dev rate budget and PUBLIC_API_ENABLED switch. They allow anonymous wildcard CORS without credentials, support HEAD and conditional caching with ETag/Last-Modified, and do not carry the native X-API-Version header. Compact and pretty representations have different ETags. Errors are plain text with Cache-Control: no-store; rate limits return 429 with Retry-After. The 128-character word limit, rate/cache durations and non-boolean error wording are local policies. Upstream word coverage, nested sense grouping, arbitrary tags and unsupported language databases are not promised.

Evidence checked on 2026-10-09: upstream documentation and OpenAPI contract, plus live probes of successful/missing/case-sensitive lookups, languages, unsupported codes, repeated and invalid booleans, translations and pretty output. Automated tests use original fixtures and require no upstream availability. No database migration, import/export format change or RFC 2229 server is part of this adapter.

Dataset-group and dataset-history responses always use Cache-Control: public, no-cache, must-revalidate: clients must revalidate before reuse, even when PUBLIC_API_CACHE_MAX_AGE is nonzero. Closing or opening a dataset invalidates their validators. Responses are revalidated using ETag; date-only conditional requests receive a fresh response because HTTP dates have only second precision. Closed datasets are omitted from counts and groups, and their history returns 404, including requests with the website's internal token. See dataset visibility.

Compatibility responses from every dataset

Add /datasets to an adapter lookup to read every open dataset without switching the active one. These routes are Vocab Bloom Hub extensions for comparison and integration tools: the ordinary adapter routes retain their upstream-shaped responses and active-dataset behavior.

MethodPathQuery
GET/api/compat/dictionaryapi/v2/entries/{language}/{word}/datasetsnone
GET/api/compat/dictionaryapi/v1/entries/{language}/{word}/datasetsnone
GET/api/compat/freedictionaryapi/v1/entries/{language}/{word}/datasetstranslations, pretty

The outer JSON object is { data, meta }. Each item of data contains the dataset terms (PublicDatasetTermsV1T: dataset, title, active, source, dataset_version, attribution, notices, license text and provenance) and two additional fields:

Fielddictionaryapi.dev v1/v2freedictionaryapi.com v1
status200 when found, 404 when missing in that dataset200, including missing words
responseThe full v1/v2 array, or the usual { title, message, resolution } error on a missThe full { word, entries, source, vocabBloom } object; entries may be empty

meta has the requested word and language, datasets (number of returned groups), and found (groups with matching entries). The whole request returns HTTP 200 even when found is zero. Dataset groups are retained on a miss, so callers can display which sources have no result. A database failure fails the whole request; it is not reported as a missing word or silently omitted.

Each dataset uses the same reader and projection as its ordinary adapter. dictionaryapi.dev accepts en, en_US and en_GB case-insensitively; it resolves headwords and inflections by the native rules. An unsupported language gives an outer 404 and query parameters give 400. freedictionaryapi.com requires exact spelling, resolves forms and one-hop spelling aliases, and reads English for en and all; other languages produce empty groups. Its translations and pretty use the same boolean parsing rules as the ordinary route. pretty formats the whole wrapper. dataset is rejected by both adapters; these routes always read all installed datasets, including the active one and the owner's datasets/forks. SQLite returns default only.

The nested response preserves word sources, modification state and complete vocabBloom terms. Its active flag, when present, says which dataset is actually served; reading another dataset does not change it. In empty freedictionaryapi.com results, source/license fallback links point to this all-datasets route rather than /api/v1/meta, which describes only the active dataset. Keep the terms of every group whose data you use.

One request costs one unit of the shared public rate limit. PUBLIC_API_ENABLED, anonymous CORS and the respective adapter's outer error format apply. GET/HEAD support ETag and Last-Modified, based on all datasets, including edits to an inactive one, registry changes and deletion. Compact/pretty responses have different ETags. These routes do not call the upstream services or merge entries from different datasets. They are listed in OpenAPI, the site's API reference and Playground; both SDKs include generated types, without native wrapper methods for compatibility routes.

curl -fsS 'http://localhost:3240/api/compat/dictionaryapi/v2/entries/en/hello/datasets'
curl -fsS 'http://localhost:3240/api/compat/freedictionaryapi/v1/entries/en/hello/datasets?translations=true'

DICT over TCP

The optional RFC 2229 listener serves installed datasets through DICT commands. It has its own port and configuration and does not change these HTTP contracts.

Semantic search (#655)

GET /api/v1/search/semantic/status returns whether the independent SEMANTIC_SEARCH_ENABLED switch is on; it never invokes a model. GET /api/v1/search/semantic?query=<description>&dataset=<name>&limit=10 returns matching existing senses from one public dataset, each with its word, definition, cosine score, source and modification marker. The response metadata includes the dataset's terms. The description is 1–1,000 characters and the limit 1–20; dataset omission uses the active one. Both routes share the public rate budget and return Cache-Control: no-store.

This is a separate mode: spelling search is unchanged, and semantic failures never fall back to it. Disabled search returns 404; unavailable vectors/model/storage return 503. See semantic-search.md for configuration, errors, model compatibility and current exact-ranking/performance limits.