DDoTriX

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 (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):

AttributeDescription
data-keyYour widget key (dtx_pub_...). Required.
data-langInterface language: en or ro (default: the page's language).
data-source / data-targetPreselected languages, e.g. en → ro.
data-accentMain color, e.g. #2f5d50.

Visitors sign in with their email (a 6-digit code, no password) right in the widget. You choose who pays:

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

  1. Sign in on DoTriX → Connected sites → create a key for your site.
  2. 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
  1. Wait for it to finish (webhook, or poll with ?wait=) and download result_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:

FieldDescription
filerequiredThe document. See formats.
source_langrequiredLanguage of the document, e.g. en.
target_langrequiredLanguage to translate into, e.g. ro.
external_refoptionalYour own reference (order id, customer id). Returned unchanged; you can filter by it.
callback_urloptionalWebhook 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.

HTTPcodeMeaning
400invalid_requestMissing or invalid field.
400unsupported_languageLanguage code not supported.
400same_languageSource and target are the same.
400unsupported_formatFile type not accepted.
400empty_file / unreadable_fileThe file is empty or can't be opened.
400invalid_webhook_urlWebhook must be a public https URL.
401missing_api_key / invalid_api_keyNo key, wrong key, or revoked key.
402insufficient_creditNot enough pages left (or the free trial doesn't cover this document).
404not_foundNo such translation for this key.
409not_ready / in_progressNot finished yet.
410goneFiles were deleted.
413file_too_largeOver the upload limit.
429rate_limitedToo 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

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);
}