TRANLY

API docs

Everything the service does, in the order you will need it.

v1Get a key

Authentication

Authenticate every request with an API key (create one on the API Keys page) sent in the x-api-key header.

The dashboard itself uses short-lived bearer tokens (Authorization: Bearer …); the public API described here uses API keys.

Two routes need no key: GET /v1/meta/languages and GET /v1/meta/plans. Everything that touches a document, a job or an organization needs one.

Headers
x-api-key: sk_live_9f2c…
Content-Type: application/json
POST/v1/translate/document

Translate a document

Send a document and get it back translated, with its layout intact. The call is synchronous by default: the finished document comes back inside the job on a 200.

A large scan can take longer than a connection should be held. syncTimeoutMs caps the wait — from 1000 to 55000 milliseconds. Leave it out and our own budget applies: 55 seconds, sized down for a small document. It can lower that budget and never raise it, so a value above the ceiling is refused with a 400 rather than capped. If the wait passes you get a 202 with the job still running — the work is never cancelled, and GET /translate/jobs/:id has the document once it finishes. Send an Idempotency-Key as well: that is what stops a client with a short timeout from paying twice for the same document.

Which mode to use

There are two modes, and the size of the document decides. delivery: "sync" is the default: it holds the connection and hands back the finished document. delivery: "webhook" answers 202 at once, then posts the finished document to the address you name — see Webhooks below.

Above 2 MB, or for any scan, use "webhook". A large document takes minutes, and a 95-page scan takes about a quarter of an hour. A synchronous call gives up before that and answers 202, so you poll for the result anyway. Nothing is refused for being large: the webhook mode saves you the polling.

outputFormat decides the shape of the result and defaults to "json": the document as positioned blocks, each with its source text, its translation, a normalized bounding box and its style. "text" is the plain text, "pdf" is a rendered PDF, and "original" is the source file rewritten in place. The first two are inlined in the result; the last two arrive as a downloadUrl.

What counts as text follows the format. A CSV header row is text, so it is translated with the table under it — read the output by column position, not by column name. JSON keys are not text: only the values change. Subtitle timings and an AWS Transcribe result's own ids come back exactly as they arrived.

Body params
NameNotes
source.kindrequiredOne of auto · text · textract · transcribe · pdf · image · docx · pptx · xlsx · html · xml · markdown · csv · srt · vtt · json
source.contentBase64optionalBase64-encoded bytes for binary kinds. Text-ish kinds (including textract JSON) can use source.text instead.
targetLangrequiredBCP-47 code of the target language, e.g. pt-BR. It must be one GET /v1/meta/languages carries — over a hundred — or the request is a 400 rather than a translation into something else. A region we do not list still works when its language is one we do (en-AU). A tag is a language, optionally with a script and a region, and nothing beyond that: en-US-INVALID is refused rather than translated into English.
outputFormatoptionalOne of json (the default) · text · pdf · original. original returns the source file rewritten in place (same format).
promptIdoptionalNames a saved house style from this organization. Raw instructions in the body are rejected.
deliveryoptional"sync" (the default) returns the finished document in the response. "webhook" answers 202 and posts it to your endpoint instead.
syncTimeoutMsoptionalHow long to hold the connection, in milliseconds, with delivery: "sync". From 1000 to 55000, which is the longest this server holds one; a larger value is refused, not capped. Refused with any other mode.
webhookUrloptionalWhere to post the finished translation. Required with delivery: "webhook", and refused without it. Must be https on port 443, and must resolve to a public address.
usePremiumEngineoptionalRedraws every page of a PDF, an image, and the pictures a report, a deck or a spreadsheet carries, with an image model instead of the standard engine. On a PDF the model may move a box, an arrow or a line break so a longer or shorter translation keeps the size of the text it replaces. A page with nothing to translate is not redrawn. Costs 5 credits instead of 1 for each one it redraws, and only for those: the rest of the document is priced exactly as it always was. It needs outputFormat "pdf" or "original" (an office file: "original"). Anything we could not redraw is translated the standard way, charged the standard rate, and reported as premium_engine_unavailable in result.notices.
translateImagesoptionalReads the words printed inside a picture that stands beside text of its own — a page of a PDF, a report, a deck, a spreadsheet — a diagram, a chart, a screenshot — and puts the translation back over them. true when you leave it out, so send false to have those pictures returned exactly as they arrived. A logo, a signature block and a QR code are never read whatever you send, and an image sent as the document itself is always read.
modeoptional"standard" (the default) or "certified": a draft of a certified translation for a sworn translator to sign. Names, numbers and dates are copied exactly, stamps, seals and signatures are described in brackets, a passage that could not be translated is marked, and your organization's localization settings do not apply. The draft comes back as docx unless you name outputFormat. Pro plan and above; a lower plan gets a 403.
certificationoptionalOnly with mode: "certified". translatorName, optional registration, and includeStatement: true to end the document with the translator's statement. The place and the date stay blank for the translator to fill in.
curl -X POST "https://api.tranly.co/v1/translate/document" \
  -H "x-api-key: $TRANLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": {
      "kind": "pdf",
      "contentBase64": "JVBERi0xLjcKJ...",
      "filename": "contract.pdf"
    },
    "targetLang": "pt-BR",
    "outputFormat": "original"
  }'
Response
HTTP/1.1 200 OK

{
  "job": {
    "id": "4f8a1c92-6b3e-4d77-9a05-1e2f3b4c5d6e",
    "status": "succeeded",
    "createdAt": "2026-07-08T12:00:00.000Z",
    "completedAt": "2026-07-08T12:00:07.000Z",
    "error": null,
    "result": {
      "outputFormat": "original",
      "downloadUrl": "data:application/pdf;base64,JVBERi0...",
      "filename": "contract.pt-BR.pdf"
    },
    "usage": {
      "credits": 12,
      "pages": 12,
      "ocrPages": 0,
      "inputChars": 31402,
      "outputChars": 33915
    },
    "webhook": null
  }
}
The default result
{
  "outputFormat": "json",
  "document": {
    "target_lang": "pt-BR",
    "pages": [
      {
        "number": 1,
        "width": 612,
        "height": 792,
        "blocks": [
          {
            "id": "p1_b0",
            "type": "line",
            "text": "Invoice",
            "translation": "Fatura",
            "bbox": { "x": 0.1, "y": 0.1, "width": 0.3, "height": 0.03 },
            "style": { "font": "Helvetica", "size": 12, "bold": true },
            "confidence": 0.99
          }
        ]
      }
    ],
    "metadata": { "source_format": "pdf" }
  }
}
curl -X POST "https://api.tranly.co/v1/translate/document" \
  -H "x-api-key: $TRANLY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "source": { "kind": "pdf", "contentBase64": "JVBERi0xLjcKJ..." },
    "targetLang": "pt-BR",
    "delivery": "sync",
    "syncTimeoutMs": 30000
  }'
POST/v1/translate/documents

Bulk translate

Send one document and get it back in several languages, in one call — targetLangs is the list. The response is a batch: batch.jobs is keyed by the language code you asked for, and each value is a whole job — its own status, its own result, its own cost. This endpoint is API-only; the dashboard has no screen for it.

A batch always answers 202, and there is no delivery field to change that: a batch multiplies the work by the number of languages, so a synchronous call could not honour its own name. Poll GET /v1/translate/batches/:id and read batch.status. queued and processing mean ask again; succeeded, partial and failed come back on a 200. partial is the one only a batch has — some languages landed and some did not, and each failure is written on the language it belongs to. batch.usage is the total, and stays null until the batch stops.

Give webhookUrl to be called back instead of polling. That is one delivery per language, each carrying that language's finished document, and it needs no delivery field here — unlike the single-document route, where a URL without delivery: "webhook" is refused. The signing secret, the verification rules and the retry schedule are the ones under Webhooks below.

Every language costs a whole document. 5 languages of a 12-page PDF is 60 credits, with no batch discount: the engine runs once per language and reads the document again each time. The credits for the whole batch are held before any language starts, so a batch that does not fit your allowance is refused whole rather than half-accepted.

usePremiumEngine is accepted here too, and it is the one field on this endpoint that multiplies with the list: a batch of 5 languages redraws the same pages 5 times, at 5 credits a page each. The whole cost is held before any language starts, so a batch that does not fit your allowance is refused whole. It needs outputFormat "pdf" or "original", exactly as on the single-document route.

How many languages per call
Body params
NameNotes
sourcerequiredThe document, exactly as the single-document route takes it — same kind, same contentBase64 or text.
targetLangsrequiredThe target languages, as a list. Each one must be a code GET /v1/meta/languages carries, and naming the same language twice is a 400 rather than two charges. For a single language use targetLang on POST /v1/translate/document.
outputFormatoptionalOne of json (the default) · text · pdf · original. original returns the source file rewritten in place (same format).
promptIdoptionalNames a saved house style from this organization. Raw instructions in the body are rejected.
webhookUrloptionalWhere to post each finished language. Accepted on its own here, with no delivery field. Same rules as a single webhook: https on port 443, a public address, and a signing secret already created.
usePremiumEngineoptionalRedraw every page of a PDF, an image, and the pictures an office file carries, in every language, with an image model. 5 credits a page instead of 1, per language. Refused on an outputFormat that carries no pixels.
translateImagesoptionalReads the words printed inside a picture that stands beside text of its own — a page of a PDF, a report, a deck, a spreadsheet — a diagram, a chart, a screenshot — and puts the translation back over them. true when you leave it out, so send false to have those pictures returned exactly as they arrived. A logo, a signature block and a QR code are never read whatever you send, and an image sent as the document itself is always read.
curl -X POST "https://api.tranly.co/v1/translate/documents" \
  -H "x-api-key: $TRANLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": { "kind": "pdf", "contentBase64": "JVBERi0xLjcKJ..." },
    "targetLangs": ["pt-BR", "es", "fr"],
    "outputFormat": "original"
  }'

# 202, always. Then poll the batch until its status leaves queued/processing:
curl "https://api.tranly.co/v1/translate/batches/b7e1d3a4-9c22-4f18-8e6b-2a5c7d9f0e13" \
  -H "x-api-key: $TRANLY_API_KEY"
Response
HTTP/1.1 202 Accepted

{
  "batch": {
    "id": "b7e1d3a4-9c22-4f18-8e6b-2a5c7d9f0e13",
    "createdAt": "2026-08-16T09:12:44.031Z",
    "status": "processing",
    "usage": null,
    "jobs": {
      "pt-BR": { "id": "4f8a1c92-…", "status": "succeeded", "error": null,
                 "result": { "outputFormat": "original",
                             "filename": "contract.pt-BR.pdf",
                             "downloadUrl": "data:application/pdf;base64,JVBERi0..." },
                 "usage": { "credits": 12, "pages": 12, "ocrPages": 0,
                            "inputChars": 31402, "outputChars": 33915 } },
      "es":    { "id": "9b2f5c81-…", "status": "queued",
                 "result": null, "usage": null, "error": null },
      "fr":    { "id": "c41a7e60-…", "status": "processing",
                 "result": null, "usage": null, "error": null }
    }
  }
}
Once every language has stopped
HTTP/1.1 200 OK

{
  "batch": {
    "id": "b7e1d3a4-9c22-4f18-8e6b-2a5c7d9f0e13",
    "status": "partial",
    "usage": { "credits": 24, "pages": 24, "ocrPages": 0,
               "inputChars": 62804, "outputChars": 67830 },
    "jobs": {
      "pt-BR": { "status": "succeeded", "error": null,
                 "usage": { "credits": 12, "pages": 12, "ocrPages": 0,
                            "inputChars": 31402, "outputChars": 33915 } },
      "es":    { "status": "succeeded", "error": null,
                 "usage": { "credits": 12, "pages": 12, "ocrPages": 0,
                            "inputChars": 31402, "outputChars": 33915 } },
      "fr":    { "status": "failed", "usage": null,
                 "error": "The translation service is temporarily unavailable." }
    }
  }
}

Webhooks

Ask for delivery: "webhook" and we answer 202 straight away, then post the finished translation to your endpoint. The event carries the document itself, in the same result shape the synchronous call returns — there is nothing to fetch afterwards.

There are two event types, so branch on type rather than assuming: translation.succeeded carries the document in data.result, and translation.failed carries data.result: null with the reason in job.error. job.usage is null when nothing was measured, which is most failures, and says what the job cost when the engine had already run before it failed — usually zero, and not always. That is what makes it add up to GET /v1/usage. Both are signed the same way.

Create a signing secret first, on the API keys page of your dashboard. That page is the only door: the route behind it takes a signed-in session, never an API key, because a key that could read what signs our deliveries could forge one. Without a secret a webhook translation is refused with a 400 rather than delivered unsigned. Rotating takes effect immediately, with no overlap window.

Verifying a delivery
  • Compute the HMAC-SHA256 of <t>.<raw body> with your secret, over the body exactly as received. Parsing and re-serializing changes the bytes, and the signature will not match.
  • Compare in constant time — hash_equals, hmac.compare_digest, timingSafeEqual.
  • Refuse a t more than 5 minutes old. That is what makes a captured delivery useless to replay.
  • Tranly-Event-Id is the same on every attempt of one delivery: use it to recognise a retry you already handled. Ignore signature pairs you do not know, so a future v2= cannot break your receiver.

A delivery is accepted on any 2xx within 10 seconds. Anything else is retried on the schedule opposite, up to six attempts; 410 Gone stops us for good. We never follow redirects, so a 3xx is a failed attempt — give us the final address.

We deliver to https on port 443 only, and only to a public address: localhost, 127.0.0.1 and the private ranges are refused, and the address is checked again inside the connection itself. While you develop, that means a public tunnel. Nothing of ours travels with the request except the signature. Track it on the job: job.webhook carries the status, the attempt count, and why the last attempt failed.

curl -X POST "https://api.tranly.co/v1/translate/document" \
  -H "x-api-key: $TRANLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": { "kind": "pdf", "contentBase64": "JVBERi0xLjcKJ..." },
    "targetLang": "pt-BR",
    "outputFormat": "original",
    "delivery": "webhook",
    "webhookUrl": "https://acme.com/hooks/tranly"
  }'
Response
HTTP/1.1 202 Accepted

{
  "job": {
    "id": "4f8a1c92-6b3e-4d77-9a05-1e2f3b4c5d6e",
    "status": "queued",
    "result": null,
    "webhook": {
      "url": "https://acme.com/hooks/tranly",
      "status": "pending",
      "attempts": 0,
      "lastAttemptAt": null,
      "lastError": null,
      "deliveredAt": null,
      "nextAttemptAt": null
    }
  }
}
The delivery
POST https://acme.com/hooks/tranly
Tranly-Signature: t=1785499200,v1=5257a869e7...
Tranly-Event-Id: evt_5f4dcc3b5aa765d61d8327deb882cf99
Tranly-Delivery-Attempt: 1

{
  "id": "evt_5f4dcc3b5aa765d61d8327deb882cf99",
  "type": "translation.succeeded",
  "createdAt": "2026-07-31T18:04:19.901Z",
  "data": {
    "job": {
      "id": "4f8a1c92-6b3e-4d77-9a05-1e2f3b4c5d6e",
      "status": "succeeded",
      "filename": "contract.pdf",
      "sourceKind": "pdf",
      "targetLang": "pt-BR",
      "outputFormat": "original",
      "createdAt": "2026-07-31T18:03:52.010Z",
      "completedAt": "2026-07-31T18:04:19.884Z",
      "error": null,
      "usage": { "credits": 12, "pages": 12, "ocrPages": 0,
                 "inputChars": 31402, "outputChars": 33915 }
    },
    "result": {
      "outputFormat": "original",
      "filename": "contract.pt-BR.pdf",
      "downloadUrl": "data:application/pdf;base64,JVBERi0..."
    }
  }
}
POST /hooks/tranly HTTP/1.1
Content-Type: application/json
Tranly-Signature: t=1785499200,v1=5257a869e7...
Tranly-Event-Id: evt_9f2c4b...
Tranly-Delivery-Attempt: 1

{
  "id": "evt_9f2c4b...",
  "type": "translation.succeeded",
  "createdAt": "2026-07-31T18:04:19.901Z",
  "data": {
    "job": { "id": "4f8a1c92-6b3e-4d77-9a05-1e2f3b4c5d6e", "filename": "contract.pdf", "status": "succeeded" },
    "result": {
      "outputFormat": "original",
      "filename": "contract.pt-BR.pdf",
      "downloadUrl": "data:application/pdf;base64,JVBERi0..."
    }
  }
}
Retries
attempt 1   immediately
attempt 2   +30 s
attempt 3   +2 min
attempt 4   +10 min
attempt 5   +1 h
attempt 6   +6 h

2xx          delivered, we stop
410 Gone     we stop for good
anything else (incl. 3xx, timeouts)  counts as a failed attempt
GET/v1/translate/jobs/:id

Retrieve a job

Fetches one translation by id. You need it in two cases: a synchronous call that ran out of time answered 202, and the document is here once it finishes; or you asked for a webhook and want to see how the delivery went, which job.webhook reports. Binary results carry a downloadUrl plus a suggested filename; text results are inlined.

downloadUrl is a data: URI, not a link to a file. The document travels inside the response, so there is nothing to fetch and nothing that expires — decode it rather than handing it to a downloader. It also means a big result is a big response: a 95-page scan is several megabytes of JSON.

result.notices says why part of a document came back in the language it went in as. The key is absent on a clean run, so its presence is the signal. Each entry carries a code and how much it covers: esign_record_kept (a signature certificate is an audit record, so we return it verbatim), segments_untranslated (passages we tried and did not land), no_translatable_text, wrong_target_language (passages delivered in neither the source's language nor the one you asked for), and premium_engine_unavailable (pages you asked the premium engine to redraw and it did not — translated the standard way, and charged the standard rate). Read it: the page looks finished either way.

The HTTP code says whether the job is finished; job.status says whether it worked. A translation that failed is still a 200.

curl "https://api.tranly.co/v1/translate/jobs/4f8a1c92-6b3e-4d77-9a05-1e2f3b4c5d6e" \
  -H "x-api-key: $TRANLY_API_KEY"
Response
{
  "job": {
    "id": "4f8a1c92-6b3e-4d77-9a05-1e2f3b4c5d6e",
    "status": "succeeded",
    "createdAt": "2026-07-08T12:00:00.000Z",
    "completedAt": "2026-07-08T12:00:07.000Z",
    "error": null,
    "result": {
      "outputFormat": "original",
      "downloadUrl": "data:application/pdf;base64,JVBERi0...",
      "filename": "contract.pt-BR.pdf"
    },
    "usage": {
      "credits": 12,
      "pages": 12,
      "ocrPages": 0,
      "inputChars": 31402,
      "outputChars": 33915
    },
    "webhook": null
  }
}
POST/v1/translate/text

Translate text

Synchronous plain-text translation — no job, the translation comes back in the response.

Body params
NameNotes
textrequiredThe text to translate.
targetLangrequiredBCP-47 code of the target language, e.g. pt-BR. It must be one GET /v1/meta/languages carries — over a hundred — or the request is a 400 rather than a translation into something else. A region we do not list still works when its language is one we do (en-AU). A tag is a language, optionally with a script and a region, and nothing beyond that: en-US-INVALID is refused rather than translated into English.
promptIdoptionalNames a saved house style from this organization. Raw instructions in the body are rejected.
curl -X POST "https://api.tranly.co/v1/translate/text" \
  -H "x-api-key: $TRANLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "text": "Hello, world!", "targetLang": "pt-BR" }'
Response
{
  "translation": "Olá, mundo!",
  "targetLang": "pt-BR",
  "usage": {
    "inputChars": 13,
    "outputChars": 11,
    "model": "gpt-4o-mini",
    "credits": 1
  }
}
POST/v1/redact/detect

Redact a document

Take personal data out of a PDF or an image. The data leaves the file — the text is removed, not painted over — and the rest of the page stays the same. Two calls: POST /redact/detect answers with the areas it proposes, page by page, and POST /redact/apply removes the areas you send back. Read a job with GET /redact/jobs/:id. POST /redact does both in one call, for an integration with no review.

A detection costs 1 credit a page. An apply that names a detection of the same file from the last 24 hours in detectionJobId costs nothing. The result is always a PDF, and we read it again before we hand it back: a job whose covered text is still readable fails instead. Pro plan and above. A lower plan gets one free test of a short document, and a 403 after it: GET /redact/trial answers with the page ceiling and whether the test is open. delivery, webhookUrl and Idempotency-Key work as on /translate/document.

Body params
NameNotes
sourcerequiredkind (pdf, image or auto), contentBase64 and an optional filename.
categoriesoptionalDetect only. What to find: person_name · email · phone · postal_address · date_of_birth · id_number · bank_account · custom_text.
customTextsoptionalDetect only. Exact texts to find, one per entry, with the custom_text category.
areasoptionalApply only. The areas to remove: each one an id, a page, a category and a bbox normalized to the page (0 to 1, from the top-left corner). Use manual for a box you drew yourself.
detectionJobIdoptionalApply only. The detection this apply follows. The same file within 24 hours makes the apply free.
# 1. Find the data. Each area is a page, a box and a category.
curl -X POST "https://api.tranly.co/v1/redact/detect" \
  -H "x-api-key: $TRANLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": { "kind": "pdf", "contentBase64": "JVBERi0xLjcKJ..." },
    "categories": ["person_name", "id_number", "email"]
  }'

# 2. Remove the areas you kept. Naming the detection makes this call free.
curl -X POST "https://api.tranly.co/v1/redact/apply" \
  -H "x-api-key: $TRANLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": { "kind": "pdf", "contentBase64": "JVBERi0xLjcKJ..." },
    "detectionJobId": "9b1e...",
    "areas": [{ "id": "p1_a0", "page": 1, "category": "person_name",
                "bbox": { "x": 0.12, "y": 0.2, "width": 0.3, "height": 0.02 } }]
  }'
Response
{
  "job": {
    "id": "9b1e…",
    "kind": "redaction_detect",
    "status": "succeeded",
    "result": {
      "outputFormat": "json",
      "redaction": {
        "pages": [{
          "number": 1, "width": 595, "height": 842,
          "areas": [{
            "id": "p1_a0", "page": 1, "category": "person_name",
            "bbox": { "x": 0.12, "y": 0.2, "width": 0.3, "height": 0.02 }
          }]
        }]
      }
    },
    "usage": { "credits": 1, "pages": 1 }
  }
}
curl -X POST "https://api.tranly.co/v1/translate/document" \
  -H "x-api-key: $TRANLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": { "kind": "pdf", "contentBase64": "JVBERi0xLjcKJ..." },
    "targetLang": "en",
    "mode": "certified",
    "certification": {
      "translatorName": "Ana Lima",
      "registration": "JUCESP 1234",
      "includeStatement": true
    }
  }'

AWS Transcribe

Send an AWS Transcribe result and get the same result file back, translated. Upload it as any other document — a .json that carries results.transcripts or results.audio_segments is recognised on its own — or name it explicitly with source.kind: "transcribe". With outputFormat: "original" the response is a Transcribe result your existing pipeline can read without a change.

The translation unit is the speech segment, never the word. jobName, accountId, status and every speaker_label survive byte-exact, segment start_time/end_time are the recognition's own and never move, and results.transcripts, results.items and speaker_labels are rebuilt to keep agreeing with the translated segments.

One limit, stated plainly: word-level times are estimates. A translated sentence has a different number of words, so no word carries the old word's timestamp — we distribute the segment's real span across the new words by length. Segment boundaries are exact; if you build word-highlighted captions off items, that is the field to know about. confidence is likewise inherited from the segment, because the recognition was confident about speech we did not re-recognise.

Saved prompts

A prompt is your organization's house style — voice, register and terminology — applied on top of the engine's translation rules. Write and edit them on the Prompts page of your dashboard, then name one from a translation with promptId. Writing one takes a signed-in session; naming one, and reading one back, take any credential — so an integration only ever sends the id.

Prompts are named, never inlined: raw instructions in a translation body are rejected, so the text that reached the model is always a record you can look up. An id belonging to another organization is a 404.

The answer names the style that ran: promptId comes back on POST /translate/text and on every read of a job, and is null when none was named. Resolve it with GET /v1/prompts/:id — that read, and the list, take your API key as well as a session.

curl -X POST "https://api.tranly.co/v1/translate/document" \
  -H "x-api-key: $TRANLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": { "kind": "pdf", "contentBase64": "JVBERi0xLjcKJ..." },
    "targetLang": "pt-BR",
    "promptId": "1f3c9a2e-..."
  }'

Credits & plan limits

Everything is metered in credits. One credit is one page of a document, or 2,500 characters of plain text rounded up, with a minimum of one credit per request. A text translation reports what it cost in usage.credits; a document reports it in job.usage once the job finishes, with the page count and the OCR page count beside it.

A scanned page costs the same as a native one — there is no OCR surcharge, so a photographed invoice and a text PDF of the same length bill identically. Which model translates is ours to choose, so a page costs the same whichever one we happen to be running. The one exception is the premium image engine: usePremiumEngine redraws a page — every page of a PDF, an image, or a picture inside an office file — with an image model at 5 credits instead of 1, and only the pages it really drew are multiplied — job.usage.premiumPages counts them.

Running out returns 429 with code QUOTA_EXCEEDED. A file larger than your plan allows returns 400, and the message names both sizes: the ceiling is 12 MB on every plan, Free included, and base64 adds about a third to a file. GET /v1/usage answers your allowance and what is left of it — with a key or a session.

How a request is priced
credits = pages > 0 ? pages : max(1, ceil(chars / 2500))

pages    the page count decides wherever the source has pages,
         however much text is on them
chars    prices only a format that reports no pages at all —
         docx, html, markdown, csv, subtitles, plain text
OCR      a scanned page costs the same as a native one

Errors

Non-2xx responses share one envelope, a route that does not exist included. Branch on code: it is stable and upper-case, while the message is written for a person and may be reworded.

Every response carries the rate limit in force for the route you called, so you can pace yourself without waiting for a 429: x-ratelimit-limit, x-ratelimit-remaining, and x-ratelimit-reset in seconds. A 429 adds retry-after. The default is 300 requests per 60 seconds, per address, counted across every route including the public ones; sign-in is deliberately tighter. Read the headers rather than the number — they are the ones that answer for the route you are on.

Each API key also has a ceiling of requests per minute, set by your plan. It counts every request sent with x-api-key, in a window of 60 seconds per key. Two routes are not counted, so you can poll a result as often as you need: GET /v1/translate/jobs/:id and GET /v1/translate/batches/:id. A request past the ceiling returns 429 with code API_KEY_RATE_LIMITED and retry-after in seconds. A dashboard session is not counted.

Requests per minute on each API key

Idempotency-Key applies to POST /translate/document and POST /translate/documents, which leave a job or a batch to replay. POST /translate/text refuses the header with a 400: its answer is the translation itself, so there is nothing to replay, and accepting it would suggest a protection you do not have.

CodeHTTPWhat to do
UNAUTHORIZED401The key was revoked or mistyped.
BAD_REQUEST400The body, or the file inside it, was refused. The message names the field, or what about the file.
INVALID_TARGET_LANG400The tag names no language we translate into. Use a code from GET /v1/meta/languages.
REDACTION_TRIAL_TOO_LONG400The document is longer than the free test of redaction takes. The message states the page ceiling. Send a shorter document; the test stays open.
NOT_FOUND404The job or style id does not belong to this organization.
CONFLICT409An Idempotency-Key you already used, with a different body. Use a new key for a new document.
PAYLOAD_TOO_LARGE413The whole request body is over 30 MB. The per-plan file ceiling is lower, and returns 400.
PLAN_FEATURE_REQUIRED403Your plan does not include this feature (redaction, or the certified mode). The message names the cheapest plan that does.
QUOTA_EXCEEDED429Move up a tier, or wait for the monthly reset.
SYNC_LIMIT429Too many synchronous translations at once for this plan. Retry, or send the document with delivery: "webhook", which is never capped.
API_KEY_RATE_LIMITED429This API key passed the requests per minute of your plan. Wait the seconds in retry-after, or move up a tier.
RATE_LIMITED429Too many requests from one address. Slow down; this one clears on its own.
UPSTREAM_ERROR502The engine failed on our side. Nothing was charged — retry.
Error envelope
{
  "error": {
    "message": "Monthly credit limit reached for the starter plan.",
    "code": "QUOTA_EXCEEDED"
  }
}