REST API

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

422 Unprocessable Content
{
  "type": "/problems/locale-not-configured",
  "title": "Locale not configured",
  "status": 422,
  "detail": "Locale \"pt-PT\" is not configured on this project."
}
MemberWhat it is
type/problems/ followed by a slug. Branch on this. It is a stable identifier, not a URL to fetch
titleThe slug in words: hyphens become spaces and the first letter is capitalised, so /problems/rate-limited has the title Rate limited
statusThe HTTP status, repeated
detailA 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

typeStatusWhenExtensions
/problems/unauthenticated401No token, or an unknown, revoked or expired one
/problems/missing-ability403The token lacks the ability this endpoint requiresability
/problems/plan-feature-required403The account's plan does not include API access (or, on the CDN read endpoints, CDN delivery)
/problems/plan-limit403The 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 higherresource, cap, upgrade_hint
/problems/ai-paused403The workspace may not spend on AI or take in new work right now. Reads and single-value writes still work
/problems/workspace-read-only403The workspace is read-only. Reads still work
/problems/workspace-closed403The workspace is not available
/problems/free-account-held403AI translation is paused for this workspace
/problems/email-verification-required403AI translation starts once an owner of the account confirms their email address

Requests

typeStatusWhenExtensions
/problems/not-found404No 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-allowed405The path exists but not with this HTTP method
/problems/validation-failed422A field is missing or invalid, or a pushed file does not parse in the format you namederrors: field name to a list of messages
/problems/locale-not-configured422The locale is not a language of this project. A push never adds languages
/problems/active-batch409A translation batch is already running for this projectbatch_id of the running batch

Push and pull

typeStatusWhenExtensions
/problems/sync-removals-requires-source422mode=sync_removals on a target-locale push
/problems/import-requires-target422import=repo on a source-locale push
/problems/metadata-requires-source422A metadata sidecar on a target-locale push
/problems/metadata-format-unsupported422A metadata sidecar with a format that carries descriptions in the file itself
/problems/metadata-invalid422The sidecar is not valid, or was sent as a form field instead of a file parterrors (each with path, code, message, at most 50) and error_count
/problems/nested-key-collision422A nested format cannot hold keys such as checkout and checkout.submit togetherkeys
/problems/format-cannot-serialize422The format cannot carry this project's keysformat

Glossary, webhooks and CDN

typeStatusWhenExtensions
/problems/glossary-term-exists422An active term already uses this source term. Update that term instead
/problems/glossary-import-failed422The import file could not be read
/problems/glossary-import-conflict422Another write took a source term while the import ran. Retry
/problems/webhook-url-not-allowed422The endpoint URL is not a public HTTPS address on port 443
/problems/cdn-key-required401A CDN read sent no key
/problems/cdn-key-invalid401The CDN key is unknown or has been revoked
/problems/not-published404The locale, version or format has no published bundle
/problems/invalid-format422The format query parameter on a CDN read is not a known format

Limits

typeStatusWhenExtensions
/problems/rate-limited429Too many requests. See Rate limitsHeaders: Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
/problems/quota-exceeded429The account has no AI words left this cycle, or translation beyond the plan is pausedquota: reason, scope, requested, resets_at
/problems/extract-cap-reached429The account, or this workspace's share of it, has used its glossary extractions for this cyclecap: 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:

429 quota-exceeded
{
  "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/push still applies the file and answers 200. The body says why the run did not start: quota_exceeded: true, or a code in translation_paused.
  • A conditional write that lost. PUT /v1/translations with a stale expected_hash answers 409 with {"error": "row-changed", "message": "...", "row": {...}}, where row is 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.

handle-problem.ts
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}`);
  }
}

On this page