# BeMyWords REST API — the one description of the /api surface. # # Rendered at /docs/api (MarketingController#api_reference) and served raw at # /docs/api/openapi.yml for Postman, agents and generators. A request spec # (spec/requests/marketing_api_reference_spec.rb) holds this file and # config/routes.rb to the same set of method + path pairs, so an endpoint # cannot ship undocumented and a documented endpoint cannot disappear. # # Prose here is what the page shows. Keep it true of the code: every rate # limit is config/initializers/rack_attack.rb, every status code is the # controller's. Prices never appear here (C4). openapi: 3.1.0 info: title: BeMyWords API version: "1" summary: Fetch, push and keep translations current — from CI, a runtime, a browser or an AI assistant. description: | Every project has a UUID and its own API keys. Build-time integrations fetch a namespace per language with one GET; runtimes report missing keys as they meet them; CI declares the full key set with one PUT. The Embed script uses the unauthenticated endpoints from the visitor's browser, and an AI assistant reaches the same project over MCP. servers: - url: https://app.bemywords.org tags: - name: Translations description: >- Project-scoped reads and writes. Authenticate with a project API key (`Authorization: Token token=…`) or a personal access token (`Authorization: Bearer pat_…`). A read-only key may only reach the two GETs; every write answers 403 `read_only_key` to it. Reads are not rate-limited; writes are capped at 300 a minute per project. - name: Account description: Who am I — for personal access tokens. - name: MCP description: >- The Model Context Protocol endpoints — JSON-RPC 2.0 over HTTP, stateless, one POST per call. The full tool list is generated further down this page. - name: Embed description: >- The unauthenticated endpoints the Embed script calls from a visitor's browser. CORS-open, throttled per IP, guarded by the project's allowed origins where a project is named. components: securitySchemes: projectKey: type: http scheme: token description: >- A project API key from the project's Connect page, sent as `Authorization: Token token=YOUR_API_KEY`. Read-only or read/write. pat: type: http scheme: bearer description: >- A personal access token (`pat_…` for one project, `pat_ws_…` for the whole workspace) from your avatar → Personal access tokens. Writes are attributed to you. parameters: project_id: name: project_id in: path required: true description: The project UUID, shown on the project's page and in its URL. schema: { type: string, format: uuid } language: name: language in: path required: true description: A language code the project has on — `en`, `nb`, `sv`, `pt-BR`. schema: { type: string, example: nb } namespace_key: name: namespace_key in: path required: true description: The namespace's short key — `common`, `marketing`, `app`. schema: { type: string, example: common } schemas: FlatTranslations: type: object description: Flat `key → value` map. Keys are dot-separated. additionalProperties: { type: string } example: { "welcome.title": "Welcome to BeMyWords", "btn.save": "Save" } Error: type: object properties: error: { type: string } message: { type: string } paths: /api/{project_id}/latest/{language}/{namespace_key}: get: tags: [Translations] operationId: getTranslations summary: Read a namespace in one language (the CI fetch) description: | The flat `{ key: value }` map for one namespace and language, served from a cache that writes refresh. Responses carry an `ETag`; send `If-None-Match` on repeat fetches and an unchanged body answers `304 Not Modified` with no content. Cache the file in your build — a build only sees what existed when it ran. security: [{ projectKey: [] }, { pat: [] }] x-read-only-ok: true parameters: - $ref: "#/components/parameters/project_id" - $ref: "#/components/parameters/language" - $ref: "#/components/parameters/namespace_key" responses: "200": description: The namespace. content: application/json: schema: { $ref: "#/components/schemas/FlatTranslations" } example: { "greeting": "Hei", "btn.save": "Lagre" } "304": { description: Your cached copy is still current. } "401": { description: Missing or wrong token, or a token for another project. } "404": { description: Unknown namespace, or a language the project does not have on. } /api/missing/{project_id}/latest/{language}/{namespace_key}: post: tags: [Translations] operationId: reportMissing summary: Report missing keys description: | Adds keys the namespace does not have yet and leaves every existing translation untouched. The runtime safety net: when your i18n library meets a key it cannot find, post it with its source value and it appears for translation without waiting for the next build. Non-destructive, so safe to call from anywhere with a read/write key you control. Throttled with the other writes at 300 a minute per project. security: [{ projectKey: [] }, { pat: [] }] parameters: - $ref: "#/components/parameters/project_id" - $ref: "#/components/parameters/language" - $ref: "#/components/parameters/namespace_key" requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/FlatTranslations" } example: { "welcome.title": "Welcome to BeMyWords", "welcome.cta": "Get started" } responses: "201": { description: Stored. Empty body. } "403": { description: The key is read-only. } "413": { description: Body over 5 MB — chunk it. } "422": description: A value was refused — it starts with a script URL or carries an inline event handler. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { error: invalid_translation, message: "Translation value starts with a script URL" } /api/sync/{project_id}/latest/{language}/{namespace_key}: put: tags: [Translations] operationId: syncKeys summary: Declare the full key set (CI) x-example-language: en description: | Declares every key that exists in your source for this namespace. Keys in the payload are created or unhidden; keys the server has that you did not send are marked hidden so translators stop seeing them — only when `language` is the project's source language, and nothing is ever deleted. A first push to an unknown namespace creates it. `preserve_source_values` (default `true`) decides who owns an edit to an existing source value: with the default, edits made in BeMyWords survive your syncs and only new keys take their value from the payload. Send `false` when the code is canonical — changed values are overwritten and listed in `source_value_changed`, and per-language locks on those keys clear so translators re-review. `dynamic_key_prefixes` protects keys generated at runtime (error codes, for instance) from being hidden. `partial: true` marks one chunk of a larger file — nothing is hidden — for namespaces of about a thousand new keys and more, since a request has a 25-second budget; finish with one full sync. A flat `{ key: value }` body is also accepted. security: [{ projectKey: [] }, { pat: [] }] parameters: - $ref: "#/components/parameters/project_id" - $ref: "#/components/parameters/language" - $ref: "#/components/parameters/namespace_key" requestBody: required: true content: application/json: schema: type: object properties: translations: { $ref: "#/components/schemas/FlatTranslations" } dynamic_key_prefixes: { type: array, items: { type: string } } preserve_source_values: { type: boolean, default: true } partial: { type: boolean, default: false } example: translations: { "welcome.title": "Welcome", "welcome.cta": "Get started", "btn.save": "Save" } dynamic_key_prefixes: ["errors.server."] preserve_source_values: true partial: false responses: "200": description: What changed. content: application/json: example: summary: { total_incoming: 3, created: 2, hidden: 1, unhidden: 0, source_value_changed: 0, unchanged: 1 } created: ["welcome.title", "btn.save"] unhidden: [] hidden: ["legacy.unused_key"] source_value_changed: [] "403": { description: The key is read-only. } "413": { description: Body over 5 MB — send it as partial chunks. } "422": { description: A row failed to save. } /api/overwrite/{project_id}/latest/{language}/{namespace_key}: put: tags: [Translations] operationId: overwriteValues summary: Overwrite values in one language description: | Force-sets the value of keys that already exist in this language. Keys it does not know come back in `not_found` and nothing is created — use missing or sync for that. `refreshed` lists keys whose value you sent unchanged while the source had moved: the row is stamped current so `/api/stale` stops flagging it, which is how you say "still correct for the new source" without rewriting it. security: [{ projectKey: [] }, { pat: [] }] parameters: - $ref: "#/components/parameters/project_id" - $ref: "#/components/parameters/language" - $ref: "#/components/parameters/namespace_key" requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/FlatTranslations" } example: { "welcome.title": "Welcome back", "btn.save": "Save changes" } responses: "200": description: What changed. content: application/json: example: summary: { total_incoming: 3, updated: 1, refreshed: 1, unchanged: 0, not_found: 1 } updated: ["welcome.title"] refreshed: ["btn.save"] unchanged: [] not_found: ["missing.key"] "403": { description: The key is read-only. } "422": { description: A value was refused. } /api/stale/{project_id}/latest/{namespace_key}: get: tags: [Translations] operationId: listStale summary: List translations the source has outrun description: | Keys whose translation in `lang` was made against an older source value than the current one. Rows that predate source versioning are included unless you pass `include_null=false`. Close the loop with overwrite (a human value) or retranslate (AI). security: [{ projectKey: [] }, { pat: [] }] x-read-only-ok: true parameters: - $ref: "#/components/parameters/project_id" - $ref: "#/components/parameters/namespace_key" - name: lang in: query required: true description: The target language to check. schema: { type: string, example: sv } - name: include_null in: query required: false description: Set `false` to skip rows with no recorded source version. schema: { type: boolean, default: true } responses: "200": description: The stale keys. content: application/json: example: { lang: sv, stale_keys: ["welcome.title", "btn.save"] } "404": { description: Unknown namespace, or `lang` is not on for the project. } /api/retranslate/{project_id}/latest/{language}/{namespace_key}: post: tags: [Translations] operationId: retranslateKeys summary: Retranslate keys with the project's AI description: | Regenerates the translations of the listed keys in `language` with the project's configured model and rules, and stores the result. Ten keys per call — the model runs inside the request — so chunk larger sets. Sixty calls a minute per project. Spend shows on Usage like any other AI work; on a BYOK workspace it runs on your key. security: [{ projectKey: [] }, { pat: [] }] parameters: - $ref: "#/components/parameters/project_id" - $ref: "#/components/parameters/language" - $ref: "#/components/parameters/namespace_key" requestBody: required: true content: application/json: schema: { type: array, items: { type: string }, maxItems: 10 } example: ["welcome.title", "btn.save"] responses: "200": description: The new values, and what could not be done. content: application/json: example: lang: sv retranslated: { "welcome.title": "Välkommen tillbaka", "btn.save": "Spara ändringar" } skipped_missing_source: [] failed: [] "422": description: Too many keys, or the project's AI is not configured. content: application/json: schema: { $ref: "#/components/schemas/Error" } "429": { description: Over sixty calls in a minute. } /api/{project_id}/sync_page_strings/{namespace_key}: post: tags: [Translations] operationId: syncPageStrings summary: Register page text as source strings description: | For sites where the source text is the key — hosted builders and the Embed script. Creates a source row per string that is new to the namespace, key equal to value, and never touches an existing row. Creates the namespace on first use. Sixty calls a minute per project. security: [{ projectKey: [] }, { pat: [] }] parameters: - $ref: "#/components/parameters/project_id" - $ref: "#/components/parameters/namespace_key" requestBody: required: true content: application/json: schema: type: object properties: source_strings: { type: array, items: { type: string } } example: { source_strings: ["Book a demo", "Read the guide"] } responses: "200": description: Counts. content: application/json: example: { created: 2, skipped: 0, source_language: en } "403": { description: The key is read-only. } "422": content: application/json: schema: { $ref: "#/components/schemas/Error" } description: The strings could not be stored. /api/{project_id}/redeploy: post: tags: [Translations] operationId: triggerRedeploy summary: Refresh the cache and dispatch the project's redeploy description: | Refreshes the project's translation cache, then dispatches the GitHub Actions workflow configured under Connect → Redeploy, so a build-time site rebuilds with fresh strings. An audited human action: personal access tokens only, a project key answers 403 `pat_required`. Every attempt is listed under Recent redeploys on the Connect page. security: [{ pat: [] }] parameters: - $ref: "#/components/parameters/project_id" responses: "200": description: Dispatched. content: application/json: example: { ok: true, dispatched_at: "2026-07-28T12:00:00Z" } "403": description: A project key was used. content: application/json: example: { error: pat_required } "422": description: GitHub is not configured for the project, or its key is bad. content: application/json: example: { ok: false, error: not_configured } "502": { description: GitHub refused the dispatch. } /api/me: get: tags: [Account] operationId: whoAmI summary: The token's owner and what it reaches description: | A project token answers with its one project; a workspace token (`pat_ws_…`) answers with the workspace and every active project in it. The cheapest way to check a token before wiring it anywhere. security: [{ pat: [] }] responses: "200": description: Owner, and project or workspace. content: application/json: example: user: { id: "6f1c…", email: "you@example.com" } project: id: "4d1e…" title: "Marketing site" source_language: en enabled_languages: [nb, sv] editable_languages: [nb, sv, da] namespaces: [common, marketing] deploy_configured: true integration_type: astro auto_translate_languages: [nb, sv] "401": { description: Not a valid personal access token. } /api/mcp: post: tags: [MCP] operationId: mcpWorkspace summary: MCP — the whole workspace description: | Workspace tokens only (`pat_ws_…`). JSON-RPC 2.0 in the body, one call per POST, `Accept: application/json`. No session is minted — every request stands alone, which is what lets it run on many dynos; `tools/list` and `tools/call` work without a prior `initialize`. Tools name a project per call. Send the header `X-BeMyWords-Read-Only: 1` and only the read tools are offered. security: [{ pat: [] }] parameters: - name: X-BeMyWords-Read-Only in: header required: false description: Any value restricts the connection to read tools. schema: { type: string } requestBody: required: true content: application/json: example: { jsonrpc: "2.0", id: 1, method: tools/list } responses: "200": description: The JSON-RPC result. content: application/json: example: { jsonrpc: "2.0", id: 1, result: { tools: [{ name: list_projects }] } } "401": { description: Not a workspace token. } /api/{project_id}/mcp: post: tags: [MCP] operationId: mcpProject summary: MCP — one project description: | Any token for the project: an API key or a personal access token. The same protocol and tools as the workspace endpoint, with the project fixed, so a builder connected here never names one. A read-only API key is offered read tools only, whatever the client declares. security: [{ projectKey: [] }, { pat: [] }] x-read-only-ok: true parameters: - $ref: "#/components/parameters/project_id" - name: X-BeMyWords-Read-Only in: header required: false description: Any value restricts the connection to read tools. schema: { type: string } requestBody: required: true content: application/json: example: { jsonrpc: "2.0", id: 1, method: tools/call, params: { name: get_project_status, arguments: {} } } responses: "200": { description: The JSON-RPC result. } "401": { description: Not a token for this project. } /api/embed/resolve: post: tags: [Embed] operationId: embedResolve summary: Host → project (the first-load handshake) description: | Called once by the Embed script with the workspace's public token and the page's host. Answers the project for that host, creating it on first sight — the zero-config install. Thirty calls a minute per IP. Answers 503 while the emergency switch is off. requestBody: required: true content: application/json: schema: type: object required: [token, host] properties: token: { type: string, description: The workspace's public token, as printed in the script tag. } host: { type: string, example: www.example.com } page_lang: { type: string, description: The page's `lang` attribute, used as the source language on first sight. } languages: { type: array, items: { type: string }, description: Languages to seed on a new project. } responses: "200": content: application/json: example: { project_id: "4d1e…" } description: The project. "402": { description: The plan's host limit is reached. } "403": { description: The host is not authorized for this account. } "404": { description: Unknown public token. } "422": { description: Not a host name. } /api/embed/{project_id}/harvest: post: tags: [Embed] operationId: embedHarvest summary: Report page strings the project does not know description: | The script posts text it found on the page and could not translate — up to fifty strings a call, two to five thousand characters each — and they become source rows, key equal to value. Always answers 204, also when the origin is not permitted or the project is not in Embed mode; nothing about the outcome is disclosed to a page. Sixty calls a minute per IP. parameters: - $ref: "#/components/parameters/project_id" requestBody: required: true content: application/json: example: { strings: ["Book a demo", "Read the guide"] } responses: "204": { description: Taken. } /api/{project_id}/dictionary/{language}/{namespace_key}: get: tags: [Embed] operationId: embedDictionary summary: The public swap dictionary description: | A flat `{ text_on_the_page: text_it_should_say }` map for one namespace and language, read by the script on every page view and swapped into the DOM. Two kinds of entry, and which you get depends on `src`: * **source → current** — for a page still showing the text as authored. * **prior → current** — for a page whose translations were baked into its build before someone corrected them. Present only while the project has *keep my site up to date between deploys* on. A regional tag falls back to its base language (`en-GB` → `en`). Carries an `ETag` that varies with `src`; unchanged answers 304. Refused with 403 from an origin the project has not allowed. Counts as visitor demand for the language. 240 calls a minute per IP. parameters: - $ref: "#/components/parameters/project_id" - $ref: "#/components/parameters/language" - $ref: "#/components/parameters/namespace_key" - name: src in: query required: false schema: { type: string, enum: ["0"] } description: | `0` asks for the prior → current entries only. Send it when the page's translations came from its own build, so the swap corrects stale strings without rewriting text from source. Omit it for the full map — which is what every embed built before this parameter existed does. responses: "200": description: The dictionary. content: application/json: example: { "Book a demo": "Bestill en demo", "Read the guide": "Les guiden" } "304": { description: Unchanged. } "403": { description: Origin not allowed. } "404": { description: Unknown project, namespace or language. } /api/{project_id}/changes/{language}/{namespace_key}: get: tags: [Embed] operationId: embedChanges summary: What changed since a moment description: | Translations that changed after `since` (ISO 8601, at most seven days back), each with the prior values that are safe to swap for the current one. Used by the editor and by `?bw-live` previews. A visitor's page never calls it: the dictionary carries recent prior values, so one fetch both translates the page and corrects anything stale on it. At most 500 changes; `truncated` says when there were more. 120 calls a minute per IP. parameters: - $ref: "#/components/parameters/project_id" - $ref: "#/components/parameters/language" - $ref: "#/components/parameters/namespace_key" - name: since in: query required: true schema: { type: string, format: date-time, example: "2026-09-12T06:00:00Z" } responses: "200": description: The changes. content: application/json: example: since: "2026-09-12T06:00:00Z" server_time: "2026-09-12T15:40:12Z" changes: [{ key: "welcome.title", current: "Velkommen tilbake", priors: ["Velkommen"] }] truncated: false "400": { description: "`since` is missing, unparsable or in the future." } "404": { description: Unknown project, namespace or language. } /api/{project_id}/install_ping: post: tags: [Embed] operationId: embedInstallPing summary: We see your site description: | One ping per browser session so the dashboard can show that the script is live, kept seven days, no analytics. Sixty a minute per IP. parameters: - $ref: "#/components/parameters/project_id" responses: "204": { description: Noted. } "404": { description: Unknown project. } /api/{project_id}/public_info: get: tags: [Embed] operationId: embedPublicInfo summary: Languages for the switcher description: | The project's source language, the languages live for visitors, and whether the footer switcher shows — what the script needs to draw the dropdown without baking a list into the tag. Cached five minutes. 120 calls a minute per IP. parameters: - $ref: "#/components/parameters/project_id" responses: "200": content: application/json: example: source_language: en enabled_languages: [nb, sv] show_switcher: true capabilities: { mode: auto, jit_harvest: true, auto_apply: true } description: The project's public shape. "404": { description: Unknown project. } webhooks: translation.approved: post: summary: Outbound — a translation was approved, created or updated description: | Configure an endpoint under Connect → Webhooks. Each delivery is a POST with `X-Webhook-Secret` carrying your shared secret verbatim — compare it before trusting the body. At-least-once: a failed delivery is retried three times with backoff, the last outcome per webhook shows on the Connect page, endpoints get ten seconds, and private or loopback addresses are refused. Events: `translation.approved`, `translation.created`, `translation.updated`. requestBody: content: application/json: example: event: translation.approved timestamp: "2026-07-28T12:00:00Z" data: { key: "home.hero.kicker", locale: nb, project: "Marketing site" } responses: "200": { description: Any 2xx within ten seconds counts as delivered. }