DoTriX API v1
Add document translation with the original layout intact to any website. Upload a document, get back a translated PDF that looks exactly like the original.
- Widget for any website
- API quick start
- Authentication
- How a translation flows
- Create a translation
- Get a translation
- Download files
- List translations
- Delete a translation
- Account balance
- Languages & formats
- Webhooks
- Errors
- Limits
- PHP & Node examples
Widget for any website (no server needed)
For static sites (plain HTML, site builders, landing pages) add the widget with one line. In your DoTriX account → Connected sites → Widget, enter the domains where it will appear, then paste the code you get:
<script src="https://DOTRIX_HOST/widget.js" data-key="dtx_pub_..." async></script>
The widget appears right after the script. To place it elsewhere, add
<div data-dotrix-widget></div> where you want it. Options (on the script or the div):
| Attribute | Description |
|---|---|
data-key | Your widget key (dtx_pub_...). Required. |
data-lang | Interface language: en or ro (default: the page's language). |
data-source / data-target | Preselected languages, e.g. en → ro. |
data-accent | Main color, e.g. #2f5d50. |
Visitors sign in with their email (a 6-digit code, no password) right in the widget. You choose who pays:
- Visitors pay — each visitor gets the free trial and buys their own documents. Costs you nothing.
- You pay — translations use your balance, with a daily limit for the widget and per visitor.
The widget key is public — that's fine. It only works on the domains you listed (browsers refuse to show it anywhere else) and can't be used with the server API.
API quick start
- Sign in on DoTriX → Connected sites → create a key for your site.
- Send a document from your server:
curl https://DOTRIX_HOST/v1/translations \ -H "Authorization: Bearer dtx_YOUR_KEY" \ -F file=@contract.docx \ -F source_lang=en \ -F target_lang=de
- Wait for it to finish (webhook, or poll with
?wait=) and downloadresult_url.
Authentication
Every request (except /v1/languages) needs your key in the Authorization header:
Authorization: Bearer dtx_...
Keys are secret. Call the API only from your server. Never put a key in browser JavaScript, a mobile app or a public repository. If a key leaks, revoke it in your account and create a new one.
Each key belongs to one site. A key only sees the translations it created, so your sites stay separate from each other. All keys of an account share that account's document balance.
How a translation flows
queued → processing → completed | failed
Translation is asynchronous: most documents finish in under a minute, larger ones can take several minutes. One document is charged only when it completes. A failed translation is never charged.
Create a translation
POST/v1/translations
multipart/form-data fields:
| Field | Description | |
|---|---|---|
file | required | The document. See formats. |
source_lang | required | Language of the document, e.g. en. |
target_lang | required | Language to translate into, e.g. ro. |
external_ref | optional | Your own reference (order id, customer id). Returned unchanged; you can filter by it. |
callback_url | optional | Webhook URL for this translation only (overrides the key's webhook). |
Optional header Idempotency-Key: <your unique id> — if you retry a request with the same
key, you get the original translation back (200) instead of a duplicate.
Response 202 Accepted:
{
"id": "e590bd9b4350edffda77950f",
"object": "translation",
"status": "queued",
"source_lang": "en",
"target_lang": "de",
"filename": "contract.docx",
"pages": null,
"external_ref": "order-77",
"error": null,
"created_at": "2026-10-16T09:14:12Z",
"completed_at": null,
"result_url": null,
"original_url": null
}
pages is known right away for PDFs and once processing starts for other formats.
Get a translation
GET/v1/translations/{id}
Add ?wait=30 (max 60) to hold the request open until the translation finishes or
the time runs out — the simplest way to wait without webhooks.
When status is completed, result_url and original_url are set.
When it is failed, error holds a code and a message you can show to your user.
Download files
GET/v1/translations/{id}/result
GET/v1/translations/{id}/original
Both return application/pdf and need the same Authorization header.
/original is the source document as PDF — useful for a side-by-side comparison.
Download the files to your server and serve them to your users from there.
List translations
GET/v1/translations?limit=20&before={id}&external_ref={ref}
Newest first. Returns {"data": [...], "has_more": true}. For the next page pass the
last id as before.
Delete a translation
DELETE/v1/translations/{id}
Permanently deletes the uploaded document and the translation from DoTriX. Use it once you have stored the result, if your users' documents must not stay on third-party servers.
Account balance
GET/v1/account
{
"key_name": "votrix.eu",
"pages_left": 48,
"trial_pages_left": 0,
"credit_batches": [{"pages_left": 48, "expires_at": "2026-11-15T09:14:12Z"}]
}
You're billed per page: a document consumes as many pages as it has. Pages come in packs valid 30 days; the batch that expires first is used first.
Languages & formats
GET/v1/languages
No key needed. Returns the language codes (ISO 639-1), the accepted formats and the upload size limit. Formats: PDF, DOCX, DOC, XLSX, XLS, PPTX, PPT, ODT, ODS, ODP, RTF, TXT. The result is always a PDF.
Webhooks
Set a webhook URL on the key (or callback_url per request). When a translation finishes
DoTriX sends a POST with JSON:
{
"type": "translation.completed", // or "translation.failed"
"data": { ...translation object... }
}
Reply with any 2xx status. Failed deliveries are retried after 10 s, 1 min and 5 min.
Webhooks can arrive more than once — use data.id to ignore duplicates.
Verify the signature
Each webhook has a header DoTriX-Signature: t=1760606052,v1=5f2b…. Compute
HMAC-SHA256(webhook_secret, t + "." + raw_body) as hex and compare it to v1.
Reject requests where t is older than 5 minutes.
Errors
Errors always look like this:
{"error": {"code": "insufficient_credit", "message": "No pages left — buy a pack to continue"}}
Branch on code (stable); message is human-readable and can change.
| HTTP | code | Meaning |
|---|---|---|
| 400 | invalid_request | Missing or invalid field. |
| 400 | unsupported_language | Language code not supported. |
| 400 | same_language | Source and target are the same. |
| 400 | unsupported_format | File type not accepted. |
| 400 | empty_file / unreadable_file | The file is empty or can't be opened. |
| 400 | invalid_webhook_url | Webhook must be a public https URL. |
| 401 | missing_api_key / invalid_api_key | No key, wrong key, or revoked key. |
| 402 | insufficient_credit | Not enough pages left (or the free trial doesn't cover this document). |
| 404 | not_found | No such translation for this key. |
| 409 | not_ready / in_progress | Not finished yet. |
| 410 | gone | Files were deleted. |
| 413 | file_too_large | Over the upload limit. |
| 429 | rate_limited | Too many requests — slow down. |
Failed translations (status: "failed") carry one of: unreadable_file,
too_many_pages, insufficient_credit, translation_failed, internal_error.
None of them is charged.
Limits
- 60 requests per minute per key.
- Upload size: see
max_upload_mbin/v1/languages(default 25 MB). - Up to 200 pages per document.
Examples
PHP (any site, incl. WordPress)
<?php
$key = getenv('DOTRIX_KEY'); // keep it in server config, not in code
$api = 'https://DOTRIX_HOST/v1';
// 1. send the document
$ch = curl_init("$api/translations");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bearer $key", "Idempotency-Key: order-77"],
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
'file' => new CURLFile('/tmp/contract.docx'),
'source_lang' => 'en',
'target_lang' => 'de',
'external_ref' => 'order-77',
],
CURLOPT_RETURNTRANSFER => true,
]);
$t = json_decode(curl_exec($ch), true);
// 2. wait (or use a webhook)
do {
$ch = curl_init("$api/translations/{$t['id']}?wait=30");
curl_setopt_array($ch, [CURLOPT_HTTPHEADER => ["Authorization: Bearer $key"], CURLOPT_RETURNTRANSFER => true]);
$t = json_decode(curl_exec($ch), true);
} while (in_array($t['status'], ['queued', 'processing']));
// 3. download
if ($t['status'] === 'completed') {
$ch = curl_init($t['result_url']);
curl_setopt_array($ch, [CURLOPT_HTTPHEADER => ["Authorization: Bearer $key"], CURLOPT_RETURNTRANSFER => true]);
file_put_contents('/tmp/contract.de.pdf', curl_exec($ch));
} else {
echo $t['error']['message'];
}
PHP — webhook receiver
<?php
$secret = getenv('DOTRIX_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_DOTRIX_SIGNATURE'] ?? ''), $sig);
$ok = isset($sig['t'], $sig['v1'])
&& abs(time() - (int)$sig['t']) < 300
&& hash_equals(hash_hmac('sha256', $sig['t'] . '.' . $body, $secret), $sig['v1']);
if (!$ok) { http_response_code(400); exit; }
$event = json_decode($body, true);
if ($event['type'] === 'translation.completed') {
// $event['data']['id'], $event['data']['external_ref'] → download result_url
}
http_response_code(200);
Node.js (18+)
import { readFile, writeFile } from "node:fs/promises";
const API = "https://DOTRIX_HOST/v1";
const auth = { Authorization: `Bearer ${process.env.DOTRIX_KEY}` };
const form = new FormData();
form.append("file", new Blob([await readFile("contract.docx")]), "contract.docx");
form.append("source_lang", "en");
form.append("target_lang", "de");
let t = await (await fetch(`${API}/translations`, { method: "POST", headers: auth, body: form })).json();
while (t.status === "queued" || t.status === "processing") {
t = await (await fetch(`${API}/translations/${t.id}?wait=30`, { headers: auth })).json();
}
if (t.status === "completed") {
const pdf = await fetch(t.result_url, { headers: auth });
await writeFile("contract.de.pdf", Buffer.from(await pdf.arrayBuffer()));
} else {
console.error(t.error.code, t.error.message);
}
Node.js — verify a webhook
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const p = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
if (Math.abs(Date.now() / 1000 - Number(p.t)) > 300) return false;
const mac = crypto.createHmac("sha256", secret).update(`${p.t}.${rawBody}`).digest("hex");
const a = Buffer.from(mac), b = Buffer.from(p.v1 || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}