API reference

This document matches the behaviour of the Express app in api/app.js and the route handlers under api/routes/.

Limits and behaviour

ItemValue
JSON body sizeUp to 2 MB (express.json({ limit: '2mb' }))
Targets per request1–36 language codes
Batch items1–100 items per batch request
Modelsstandard (default) or advanced (paid tiers only; see below)

Monthly token allowance (free tier): Before calling the model, the API estimates tokens as roughly ceil(content_length / 4) × (number_of_targets + 1) and, for free tier only, rejects the request with 429 / token_limit_reached if the estimate would exceed the remaining monthly grant (FREE_TIER_MONTHLY_TOKENS, default 100000). Paid tiers are not blocked by this pre-check in enforceTokenCap; usage is still logged.

Rate limits: When Upstash Redis is configured (UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN, and URL does not contain the placeholder your-instance), per-minute limits apply by tier: free 5, starter 30, growth 60, scale 120, enterprise unlimited. On limit, response is 429 with error: "rate_limit_reached". If Redis is not configured, rate limiting is skipped (see rateLimit.js).

Successful rate-limited responses may include X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.


GET /health

No authentication.

Response 200

{
  "status": "ok",
  "timestamp": "2025-03-23T12:00:00.000Z"
}

GET /languages

No authentication.

Returns the canonical list of supported languages (code, display name, RTL flag). There are 36 entries; codes are the only values accepted in targets on translate endpoints.

Response 200

{
  "languages": [
    { "code": "en", "name": "English", "rtl": false },
    { "code": "ar", "name": "Arabic", "rtl": true }
  ]
}

Source: api/utils/languages.js.


POST /translate

Requires Authorization: Bearer <api_key>.

Translates a single content string into every language listed in targets. The model returns a single JSON object whose keys are exactly the requested language codes and whose values are translated strings (see formatPrompts.js).

Request body

FieldTypeRequiredDescription
contentstringYesNon-empty string to translate.
targetsstring[]YesNon-empty array of valid language codes (max 36).
formatstringNoOne of plain, markdown, json, html. If omitted, format is auto-detected from content.
sourcestringNoSource language hint for the model; optional.
modelstringNostandard (default) or advanced. advanced requires a paid tier (403 on free).

Response 200

{
  "translations": {
    "es": "...",
    "fr": "..."
  },
  "usage": {
    "input_tokens": 120,
    "output_tokens": 340,
    "total_tokens": 460,
    "model": "standard",
    "detected_format": "markdown",
    "detection_confidence": 0.95
  }
}

detected_format and detection_confidence appear only when format was omitted and auto-detection ran.

Example (cURL)

curl -sS -X POST "https://api.usepolylingo.com/v1/translate" \
  -H "Authorization: Bearer $POLYLINGO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "{\"title\":\"Hello\"}",
    "format": "json",
    "targets": ["fr", "de"]
  }'

Example (Python 3)

pip install requests
import os, requests

url = "https://api.usepolylingo.com/v1/translate"
headers = {
    "Authorization": f"Bearer {os.environ['POLYLINGO_API_KEY']}",
    "Content-Type": "application/json",
}
r = requests.post(url, json={
    "content": "<p>Hello <strong>world</strong></p>",
    "format": "html",
    "targets": ["es"],
}, timeout=120)
r.raise_for_status()
print(r.json()["translations"]["es"])

POST /translate/batch

Requires Authorization: Bearer <api_key>.

Processes each item sequentially (one model call per item). If any item fails, the API returns 500 and does not return partial results for that request.

Request body

FieldTypeRequiredDescription
itemsarrayYesEach element: id (string), content (string), optional format.
targetsstring[]YesSame rules as /translate.
sourcestringNoOptional source language hint.
modelstringNostandard or advanced (same rules as /translate).

Response 200

{
  "results": [
    { "id": "welcome", "translations": { "fr": "...", "de": "..." } },
    { "id": "goodbye", "translations": { "fr": "...", "de": "..." } }
  ],
  "usage": {
    "total_tokens": 900,
    "input_tokens": 400,
    "output_tokens": 500,
    "model": "standard"
  }
}

Example

curl -sS -X POST "https://api.usepolylingo.com/v1/translate/batch" \
  -H "Authorization: Bearer $POLYLINGO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      { "id": "a", "content": "Hello", "format": "plain" },
      { "id": "b", "content": "## Title", "format": "markdown" }
    ],
    "targets": ["es", "it"]
  }'

POST /jobs

Requires Authorization: Bearer <api_key>.

Enqueues a translation job and returns immediately with a job_id. The translation runs in the background — no HTTP timeout risk regardless of content size. Poll GET /jobs/:id for the result.

Use this endpoint instead of POST /translate when translating large documents (long Markdown, many target languages) where the request duration might exceed your HTTP client or proxy timeout.

Request body

FieldTypeRequiredDescription
contentstringYesNon-empty string to translate.
targetsstring[]YesNon-empty array of valid language codes (max 36).
formatstringNoOne of plain, markdown, json, html. Auto-detected if omitted.
sourcestringNoSource language hint; optional.
modelstringNostandard (default) or advanced.

Response 202

{
  "job_id": "a1b2c3d4-...",
  "status": "pending",
  "created_at": "2025-03-23T12:00:00.000Z"
}

GET /jobs/:id

Requires Authorization: Bearer <api_key>.

Polls the status of a job submitted via POST /jobs. Poll every 5–10 seconds. Jobs are owned by the submitting user — other users receive 404.

Response (pending / processing)

{
  "job_id": "a1b2c3d4-...",
  "status": "pending",
  "created_at": "2025-03-23T12:00:00.000Z",
  "updated_at": "2025-03-23T12:00:00.000Z",
  "completed_at": null,
  "queue_position": 3
}

status is pending (waiting for a worker) or processing (worker has claimed it). queue_position (1-based) is how many pending or processing jobs were created strictly before this one — use it for progress UI. Omitted when the count query fails.

Response (completed)

{
  "job_id": "a1b2c3d4-...",
  "status": "completed",
  "created_at": "2025-03-23T12:00:00.000Z",
  "updated_at": "2025-03-23T12:00:02.000Z",
  "completed_at": "2025-03-23T12:00:02.000Z",
  "translations": {
    "es": "...",
    "fr": "..."
  },
  "usage": {
    "input_tokens": 120,
    "output_tokens": 340,
    "total_tokens": 460,
    "model": "standard"
  }
}

Response (failed)

{
  "job_id": "a1b2c3d4-...",
  "status": "failed",
  "error": "Model returned invalid JSON"
}

Example (JavaScript)

const API = 'https://api.usepolylingo.com/v1'
const headers = {
  'Authorization': `Bearer ${process.env.POLYLINGO_API_KEY}`,
  'Content-Type': 'application/json',
}

// 1. Submit
const submit = await fetch(`${API}/jobs`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ content: longMarkdown, format: 'markdown', targets: ['de', 'fr'] }),
})
const { job_id } = await submit.json()

// 2. Poll
while (true) {
  await new Promise(r => setTimeout(r, 10_000))
  const poll = await fetch(`${API}/jobs/${job_id}`, { headers })
  const job = await poll.json()
  if (job.status === 'completed') { console.log(job.translations); break }
  if (job.status === 'failed')    { throw new Error(job.error) }
  // Optional: show progress (queue_position is 1-based, omitted when not queued)
  if (job.queue_position != null) console.log(`Queue position: ${job.queue_position}`)
}

GET /usage

Requires Authorization: Bearer <api_key> (standard key lookup — not the internal bypass-only path).

Returns token usage for the current calendar month for the authenticated user.

Response 200

{
  "period_start": "2025-03-01T00:00:00.000Z",
  "period_end": "2025-03-31T23:59:59.000Z",
  "tokens_used": 12000,
  "tokens_included": 100000,
  "tokens_remaining": 88000,
  "overage_tokens": 0,
  "tier": "free"
}

tokens_included and tokens_remaining are null for enterprise (unlimited grant in reporting).


Content formats

Supported format values: plain, markdown, json, html.

FormatPreservedTranslated
plainLine breaks / paragraphsAll visible text
markdownSyntax, links (URL unchanged), fenced code (verbatim)Prose and link text
jsonKeys, structure, non-string typesString values only
htmlTags and attributesText nodes and appropriate attributes (see prompts)

RTL and direction in your app

For plain and markdown output, the API returns translated text only — it does not add dir="rtl" or wrapper elements. Set text direction in your UI (CSS direction, a parent element’s dir attribute, or your framework’s i18n layout) when displaying Arabic, Hebrew, or Persian.

For html format, translated markup may include dir="rtl" where appropriate for RTL targets; see formatPrompts.js and the HTML tests in scripts/test-translation.js.


Error responses

Errors are JSON when possible:

{
  "error": "invalid_request",
  "message": "Human-readable detail"
}
HTTPerrorWhen
400invalid_requestMissing/invalid body fields (e.g. empty content, bad targets)
400invalid_formatformat not in the supported set
400invalid_languageUnknown code in targets
401invalid_api_keyMissing/malformed Authorization, unknown key, revoked key
403advanced_not_availablemodel: "advanced" on free tier
429token_limit_reachedFree tier monthly cap would be exceeded (pre-check)
429rate_limit_reachedPer-minute RPM limit (when Redis enabled)
500translation_errorModel/network failure; safe to retry
404not_foundGET /jobs/:id — job does not exist or belongs to another user
500server_errorPOST /jobs — failed to enqueue; safe to retry

GET /usage may return 500 with a generic message if Supabase query fails.


Underlying models (informational)

The API exposes only standard and advanced. Actual OpenAI model IDs are configured in api/utils/modelRouter.js and are not returned in API responses.

API reference | PolyLingo | PolyLingo