API reference
The Locavello REST API — everything the dashboard and CLI do, you can do.
Base URL
https://locavello.forjio.com/api/v1
Authentication
Every request carries an API key as a bearer token:
Authorization: Bearer lv_live_xxx
Create keys in Dashboard → Developers (or via
POST /api-keys). The plaintext is returned once at
creation; Locavello stores only a SHA-256 hash. Keys are scoped to your
account and act on the projects it owns. A revoked or unknown key gets
401 INVALID_API_KEY; a request with no credentials gets
401 AUTH_REQUIRED.
Response envelope
Every response — success or error — uses the family-standard envelope:
{
"data": { "id": "prj_01jxxxxxxxxxxxxxxxxxxxxxxx", "name": "My app" },
"error": null,
"meta": { "requestId": "req_xxx", "timestamp": "2026-08-01T00:00:00.000Z" }
}
On error, data is null and error carries an UPPER_SNAKE_CASE
code, a human-readable message, and sometimes the offending
param:
{
"data": null,
"error": {
"code": "PLACEHOLDER_MISMATCH",
"message": "translation must use exactly the source placeholders (missing: [count], extra: [])",
"param": "value"
},
"meta": { "requestId": "req_xxx", "timestamp": "2026-08-01T00:00:00.000Z" }
}
Error codes
| Code | Status | Meaning |
|---|---|---|
AUTH_REQUIRED |
401 | No usable credentials |
INVALID_API_KEY |
401 | Unknown or revoked lv_ key |
VALIDATION_ERROR |
400/422 | Body or query failed validation (param names the field) |
NOT_FOUND |
404 | Resource missing or not owned by your account |
CONFLICT |
409 | Uniqueness conflict (duplicate slug, locale, namespace, glossary term) |
PLACEHOLDER_MISMATCH |
422 | Translation drops/invents ICU placeholders — see placeholder safety |
UPGRADE_REQUIRED |
403 | The requested agent run would exceed your plan's agent-word budget |
INVALID_URL / FETCH_FAILED / NO_TEXT / PREVIEW_BUDGET_EXHAUSTED |
422/503 | Public preview failures (see below) |
INTERNAL_ERROR |
500 | Unexpected server error |
Pagination
List endpoints take ?limit= (1–100, default 20) and ?cursor=. The
response's meta carries cursor (pass it back to get the next page)
and hasMore.
Projects
POST /projects
{ "slug": "my-app", "name": "My app", "sourceLocale": "en", "mode": "sdk" }
mode is "sdk" or "proxy" (website). Proxy projects require
siteUrl. sourceLocale defaults to "en". Duplicate slugs → 409.
GET /projects
Paginated list of your projects.
GET /projects/:id
The project plus per-locale completion stats (keyCount, approved,
machine, needsReview, missing per locale), its namespaces, and
the most recent release.
PATCH /projects/:id
Update name and/or siteUrl.
Locales
POST /projects/:id/locales
{ "tag": "pt-BR", "fallback": "pt", "rtl": false }
tag is a BCP-47 tag. The source locale is implicit — add targets
only. fallback builds the resolution chain used by serving and pull.
PATCH /projects/:id/locales/:tag
Update fallback, rtl, or enabled.
GET /projects/:id/locales
Per-locale completion stats.
Namespaces
POST /projects/:id/namespaces
{ "name": "legal", "reviewPolicy": "gated" }
reviewPolicy is "standard" (default) or "gated" — gated
namespaces publish approved translations only. Every project starts
with a default namespace; extract auto-creates namespaces it
discovers.
PATCH /projects/:id/namespaces/:name
Update reviewPolicy.
Keys
PUT /projects/:id/keys
The extract/push endpoint — bulk-upserts up to 2,000 keys:
{
"keys": [
{
"namespace": "default",
"name": "cart.items",
"sourceText": "{count, plural, one {# item} other {# items}}",
"description": "Cart badge label",
"maxLength": 40,
"context": { "usages": [{ "file": "src/cart.tsx", "line": 12 }] }
}
],
"prune": false
}
Returns { "created": n, "updated": n, "archived": n }. Declared ICU
placeholders are derived from sourceText server-side, so the
placeholder gate can never disagree with the source of truth. With
"prune": true, keys in the same namespaces that are absent from
the payload are archived — only send it with a complete picture.
GET /projects/:id/keys
The workbench list. Filters: ?namespace=, ?q= (substring on name /
source text), ?locale= + ?status= (translation state for that
locale; status=missing selects keys with no translation row),
?archived=true. Paginated.
PATCH /keys/:keyId
Update key context metadata: description, maxLength,
screenshotUrl.
Translations
PUT /keys/:keyId/translations/:locale
{ "value": "{count, plural, one {# barang} other {# barang}}", "status": "approved" }
status: "needs_review" (default), "machine", or "approved".
The placeholder-safety gate runs here, on every write — a value
that drops or renames an ICU placeholder is rejected with
422 PLACEHOLDER_MISMATCH and never enters the database. In gated
namespaces, machine writes are downgraded to needs_review.
Approving feeds the translation memory. The response includes a
lengthWarning when the value's estimated display length exceeds the
key's maxLength.
GET /projects/:id/review-queue
Everything with status machine or needs_review, oldest first, with
the key context a reviewer needs. Filter ?locale=. Paginated.
POST /translations/:id/approve
Marks approved, records the reviewer, upserts the translation memory.
POST /translations/:id/reject
{ "reason": "Wrong register — too formal for a button." }
Rejected translations are excluded from publishing and re-queued for the next agent pass.
Releases
POST /projects/:id/releases
{ "locale": "id" }
Builds the publishable catalog for the locale and freezes it as an
immutable, content-hashed release. Approved translations always ship;
machine / needs_review values ship only from standard-policy
namespaces — never from gated ones. Publishing identical content
returns the existing release with "unchanged": true instead of
creating a duplicate.
GET /projects/:id/releases
Release history (id, locale, contentHash, keyCount,
createdBy, createdAt). Filter ?locale=. Paginated.
GET /projects/releases/:releaseId
One release including its frozen catalog.
GET /projects/releases/:a/diff/:b
Key-level diff between two releases:
{
"data": {
"a": { "id": "rel_xxx", "hash": "…" },
"b": { "id": "rel_yyy", "hash": "…" },
"added": ["checkout.title"],
"removed": [],
"changed": [{ "key": "cart.items", "from": "…", "to": "…" }]
},
"error": null,
"meta": { "requestId": "req_xxx", "timestamp": "2026-08-01T00:00:00.000Z" }
}
Pull and check
GET /projects/:id/pull
What locavello pull calls. Returns the source catalog, each enabled
locale's latest released catalog (?draft=true includes a draft build
for locales without a release), the fallback map, and — with
?pseudo=true — a synthesized en-XA catalog (free, generated on the
fly).
GET /projects/:id/check
What locavello check calls: { ok, errors, warnings, stats } with
mechanical issues only — missing_key and placeholder_mismatch as
errors; length_overflow, glossary_violation, and unreviewed as
warnings.
Agent jobs
POST /projects/:id/translate
{ "locale": "id" }
Queues a machine first pass for every untranslated (or rejected) key in
the locale. Returns the job immediately with an upfront estimate
(stats.estimatedKeys, stats.estimatedWords) so the cost is visible
before the run. If a job for the locale is already queued or running,
it is returned with "alreadyQueued": true.
The estimate is checked against your plan's agent-word budget before
anything is queued — a run that would exceed the words you have left
is rejected with 403 UPGRADE_REQUIRED (agent words are metered even
during early access; translation-memory hits don't count against the
budget).
POST /projects/:id/crawl
Queues a website crawl (proxy-mode projects with a siteUrl only).
GET /projects/:id/pages
Per-page crawl status: path, status
(discovered / crawled / error), keyCount, lastCrawledAt,
lastError.
GET /projects/:id/jobs · GET /projects/jobs/:jobId
Job history and a single job. Statuses: queued, running, done,
failed.
Glossary
POST /glossary
{ "term": "Locavello", "projectId": null, "locale": null, "translation": null }
translation: null = do-not-translate. Set locale +
translation for a forced translation in that locale. projectId: null applies the term account-wide.
GET /glossary
All terms; ?projectId= returns that project's terms plus the
account-wide ones.
DELETE /glossary/:id
Remove a term.
Translation memory
GET /tm/suggest?text=&target=
Workbench suggestions for one source text: the exact match (by source hash) plus up to 5 fuzzy candidates. TM is account-wide — matches come from all your projects.
GET /tm/search?q=&target=
Free-text search across source and target text (up to 50 rows).
API keys
POST /api-keys
{ "name": "CI" }
Returns the key row plus plaintext (lv_live_xxx) — shown once,
store it now.
GET /api-keys
Your keys: name, display prefix, lastUsedAt, revokedAt.
DELETE /api-keys/:id
Revokes the key (it stops authenticating immediately; the row is kept for audit display).
Public endpoints (no auth)
POST /public/preview
The homepage's instant preview. No authentication; tightly capped (40 strings / 500 words per run, plus a global daily word budget for the anonymous surface).
{ "url": "https://example.com", "targetLocale": "id" }
Agent runs take longer than proxy timeouts allow, so the preview is
asynchronous: the POST fetches the page, extracts its strings, and
dispatches the translation run, then returns 202 in a few seconds:
{
"data": {
"previewId": "tj_01jxxxxxxxxxxxxxxxxxxxxxxx",
"url": "https://example.com/",
"targetLocale": "id",
"stringsOnPage": 63,
"previewedWords": 214
},
"error": null,
"meta": { "requestId": "req_xxx", "timestamp": "2026-08-01T00:00:00.000Z" }
}
Failures on the POST:
| Code | Status | Meaning |
|---|---|---|
INVALID_URL |
422 | Not a fetchable public http(s) URL |
FETCH_FAILED |
422 | The page couldn't be fetched |
NO_TEXT |
422 | No translatable text found on the page |
PREVIEW_BUDGET_EXHAUSTED |
503 | The free preview's daily word budget is spent — try again tomorrow |
VALIDATION_ERROR |
400 | Bad input (e.g. targetLocale is not a BCP-47 tag) |
GET /public/preview/:previewId
Poll the preview (every ~3 seconds is polite). Returns one of:
{ "data": { "status": "running" } }
{
"data": {
"status": "done",
"url": "https://example.com/",
"targetLocale": "id",
"pairs": [{ "original": "Get started", "translated": "Mulai" }]
}
}
{ "data": { "status": "failed", "error": "…" } }
Unknown or non-preview ids return 404 NOT_FOUND. Runs usually finish
in under a minute.
GET /public/projects/:id/catalog?locale=
The serving endpoint for the website snippet. Returns the latest
published release for the locale with its fallback chain
pre-flattened, plus enabledLocales and sourceLocale. CORS-open
(Access-Control-Allow-Origin: *) and cacheable (max-age=60,
s-maxage=300). Drafts are never served.
Health
GET /health
No auth. Service name, version, and dependency checks — the same shape across every Forjio service.