Errors
Read the problem document every API error returns and branch on its type.
Every error from api.transglot.ai is an RFC 7807 problem document with Content-Type: application/problem+json. A client never has to parse prose to know what went wrong.
The problem format
{
"type": "/problems/locale-not-configured",
"title": "Locale not configured",
"status": 422,
"detail": "Locale \"pt-PT\" is not configured on this project."
}| Member | What it is |
|---|---|
type | /problems/ followed by a slug. Branch on this. It is a stable identifier, not a URL to fetch |
title | The slug in words: hyphens become spaces and the first letter is capitalised, so /problems/rate-limited has the title Rate limited |
status | The HTTP status, repeated |
detail | A sentence for a person reading a log. Its wording can change; do not parse it |
Some types add extension members beside these four, listed in the tables below. A 5xx answer never carries internal detail: its detail is always An unexpected error occurred., and an unexpected failure has the type /problems/internal-error. Retry a 5xx with backoff.
Error types
Authentication and access
type | Status | When | Extensions |
|---|---|---|---|
/problems/unauthenticated | 401 | No token, or an unknown, revoked or expired one | |
/problems/missing-ability | 403 | The token lacks the ability this endpoint requires | ability |
/problems/plan-feature-required | 403 | The account's plan does not include API access (or, on the CDN read endpoints, CDN delivery) | |
/problems/plan-limit | 403 | The endpoint needs a plan feature the account does not have: quality checks and glossary extraction need Team or higher; the glossary, webhooks and CDN publishing need Starter or higher | resource, cap, upgrade_hint |
/problems/ai-paused | 403 | The workspace may not spend on AI or take in new work right now. Reads and single-value writes still work | |
/problems/workspace-read-only | 403 | The workspace is read-only. Reads still work | |
/problems/workspace-closed | 403 | The workspace is not available | |
/problems/free-account-held | 403 | AI translation is paused for this workspace | |
/problems/email-verification-required | 403 | AI translation starts once an owner of the account confirms their email address |
Requests
type | Status | When | Extensions |
|---|---|---|---|
/problems/not-found | 404 | No such route, or the batch, term, key or endpoint is not in this project. Another project's ids answer 404, never 403 | |
/problems/method-not-allowed | 405 | The path exists but not with this HTTP method | |
/problems/validation-failed | 422 | A field is missing or invalid, or a pushed file does not parse in the format you named | errors: field name to a list of messages |
/problems/locale-not-configured | 422 | The locale is not a language of this project. A push never adds languages | |
/problems/active-batch | 409 | A translation batch is already running for this project | batch_id of the running batch |
Push and pull
type | Status | When | Extensions |
|---|---|---|---|
/problems/sync-removals-requires-source | 422 | mode=sync_removals on a target-locale push | |
/problems/import-requires-target | 422 | import=repo on a source-locale push | |
/problems/metadata-requires-source | 422 | A metadata sidecar on a target-locale push | |
/problems/metadata-format-unsupported | 422 | A metadata sidecar with a format that carries descriptions in the file itself | |
/problems/metadata-invalid | 422 | The sidecar is not valid, or was sent as a form field instead of a file part | errors (each with path, code, message, at most 50) and error_count |
/problems/nested-key-collision | 422 | A nested format cannot hold keys such as checkout and checkout.submit together | keys |
/problems/format-cannot-serialize | 422 | The format cannot carry this project's keys | format |
Glossary, webhooks and CDN
type | Status | When | Extensions |
|---|---|---|---|
/problems/glossary-term-exists | 422 | An active term already uses this source term. Update that term instead | |
/problems/glossary-import-failed | 422 | The import file could not be read | |
/problems/glossary-import-conflict | 422 | Another write took a source term while the import ran. Retry | |
/problems/webhook-url-not-allowed | 422 | The endpoint URL is not a public HTTPS address on port 443 | |
/problems/cdn-key-required | 401 | A CDN read sent no key | |
/problems/cdn-key-invalid | 401 | The CDN key is unknown or has been revoked | |
/problems/not-published | 404 | The locale, version or format has no published bundle | |
/problems/invalid-format | 422 | The format query parameter on a CDN read is not a known format |
Limits
type | Status | When | Extensions |
|---|---|---|---|
/problems/rate-limited | 429 | Too many requests. See Rate limits | Headers: Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset |
/problems/quota-exceeded | 429 | The account has no AI words left this cycle, or translation beyond the plan is paused | quota: reason, scope, requested, resets_at |
/problems/extract-cap-reached | 429 | The account, or this workspace's share of it, has used its glossary extractions for this cycle | cap: kind, scope, resets_at |
A quota-exceeded answer looks like this. quota.reason is plan_allowance, spend_cap or overage_paused, and resets_at is null when the reset is not known:
{
"type": "/problems/quota-exceeded",
"title": "Quota exceeded",
"status": 429,
"detail": "The account this workspace belongs to has no AI words left for this cycle. They reset on 1 Nov 2026.",
"quota": {
"reason": "plan_allowance",
"scope": "account",
"requested": 4820,
"resets_at": "2026-11-01T00:00:00+00:00"
}
}Two answers that are not problem documents
- A push whose translation run could not start.
POST /v1/pushstill applies the file and answers200. The body says why the run did not start:quota_exceeded: true, or a code intranslation_paused. - A conditional write that lost.
PUT /v1/translationswith a staleexpected_hashanswers409with{"error": "row-changed", "message": "...", "row": {...}}, whererowis the value as it stands now.
Branching on errors
Decide by status first, then by type. A 403 or a 422 does not change by retrying; a 429 and a 5xx can.
type Problem = { type: string; title: string; status: number; detail: string; [extension: string]: unknown };
export async function startRun(token: string) {
const res = await fetch('https://api.transglot.ai/v1/translate', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ scope: 'missing' }),
});
if (res.ok) return res.json(); // 202: the batch
const problem = (await res.json()) as Problem;
switch (problem.type) {
case '/problems/active-batch':
// Another run is going. Watch it instead of starting a second one.
return { watch: problem.batch_id };
case '/problems/rate-limited': {
const seconds = Number(res.headers.get('Retry-After') ?? '1');
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
return startRun(token);
}
case '/problems/quota-exceeded':
// Lifts on its own at quota.resets_at (when set). Do not loop on it.
throw new Error(`No AI words left: ${problem.detail}`);
case '/problems/missing-ability':
throw new Error(`This token needs the "${problem.ability}" ability.`);
default:
throw new Error(`${problem.status} ${problem.type}: ${problem.detail}`);
}
}