Prepaid AI document translation for developers — upload a PDF, poll the job, download the watermark-free translated PDF.
The InKhmer Document Translator API translates PDF documents between Khmer and English (and other supported pairs) with the same engine that powers the inkhmer.com web tool. It is prepaid: every job debits credits from your InKhmer wallet when the job is created, and unused credits are refunded automatically if the job fails or is cancelled before it finishes.
Base URL: https://api.inkhmer.com/v1/public/document-translator
Create a job with one multipart POST, poll until the state reaches done, then download the PDF. Replace sk_test_… with your key and contract.pdf with your file.
# Create a job
curl -X POST https://api.inkhmer.com/v1/public/document-translator/jobs \
-H "Authorization: Bearer $INKHMER_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-F "file=@contract.pdf" \
-F "source=auto" \
-F "target=km"
# → 202 { "jobId": "019a…", "pagesTotal": 72, "tier": "t4",
# "priceCredits": 999, "walletAfter": 1301,
# "pollUrl": "/v1/public/document-translator/jobs/019a…" }
# Poll until done / error / cancelled (~every 2 s)
JOB_ID=019a… # from the 202 response
while :; do
STATE=$(curl -s https://api.inkhmer.com/v1/public/document-translator/jobs/$JOB_ID \
-H "Authorization: Bearer $INKHMER_KEY" | jq -r .state)
echo "$STATE"
[ "$STATE" = done ] && break
[ "$STATE" = error ] && exit 1
[ "$STATE" = cancelled ] && exit 1
sleep 2
done
# Download the watermark-free PDF
curl -o translation.pdf https://api.inkhmer.com/v1/public/document-translator/jobs/$JOB_ID/pdf \
-H "Authorization: Bearer $INKHMER_KEY"// Requires Node 18+ (global fetch / FormData / Blob).
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
const BASE = 'https://api.inkhmer.com/v1/public/document-translator';
const ORIGIN = 'https://api.inkhmer.com';
const KEY = process.env.INKHMER_KEY; // sk_test_… / sk_live_…
const headers = { Authorization: `Bearer ${KEY}` };
// 1. Create the job (multipart upload)
const form = new FormData();
form.append('file', new Blob([fs.readFileSync('contract.pdf')]), 'contract.pdf');
form.append('source', 'auto');
form.append('target', 'km');
const created = await fetch(`${BASE}/jobs`, {
method: 'POST',
headers: { ...headers, 'Idempotency-Key': randomUUID() },
body: form,
}).then((r) => r.json());
// 2. Poll the job (~every 2 s) until a terminal state.
// created.pollUrl is the relative /v1/… path from the 202 response.
while (true) {
const job = await fetch(`${ORIGIN}${created.pollUrl}`, { headers })
.then((r) => r.json());
if (job.state === 'done') break;
if (job.state === 'error' || job.state === 'cancelled')
throw new Error(`job ${job.state}: ${job.error}`);
await new Promise((r) => setTimeout(r, 2000));
}
// 3. Download the watermark-free PDF
const pdf = await fetch(`${BASE}/jobs/${created.jobId}/pdf`, { headers });
fs.writeFileSync('translation.pdf', Buffer.from(await pdf.arrayBuffer()));import os, time, uuid
import requests
BASE = 'https://api.inkhmer.com/v1/public/document-translator'
KEY = os.environ['INKHMER_KEY'] # sk_test_… / sk_live_…
HEADERS = {'Authorization': f'Bearer {KEY}'}
# 1. Create the job (multipart upload)
with open('contract.pdf', 'rb') as f:
created = requests.post(
f'{BASE}/jobs',
headers={**HEADERS, 'Idempotency-Key': str(uuid.uuid4())},
files={'file': ('contract.pdf', f, 'application/pdf')},
data={'source': 'auto', 'target': 'km'},
).json()
# 2. Poll the job (~every 2 s) until a terminal state
while True:
job = requests.get(
f"{BASE}/jobs/{created['jobId']}", headers=HEADERS
).json()
if job['state'] == 'done':
break
if job['state'] in ('error', 'cancelled'):
raise RuntimeError(f"job {job['state']}: {job['error']}")
time.sleep(2)
# 3. Download the watermark-free PDF
pdf = requests.get(
f"{BASE}/jobs/{created['jobId']}/pdf", headers=HEADERS
)
with open('translation.pdf', 'wb') as f:
f.write(pdf.content)Jobs with more than ~20 MiB of payload should use the direct-to-storage flow instead: POST /jobs/intent for a presigned upload URL, PUT the bytes, then POST /jobs/{jobId}/finalize. See the reference section for both flows.
Jobs are billed in wallet credits by page band — the same tiers as the inkhmer.com tool. 1 credit = $0.01 USD. The price is quoted at job creation and debited before translation starts; unused credits from failed or cancelled jobs are refunded automatically.
| Pages | Credits | USD | KHR |
|---|---|---|---|
| 1–5 | 99 | $0.99 | 20,000 ៛ |
| 6–20 | 199 | $1.99 | 40,000 ៛ |
| 21–50 | 499 | $4.99 | 100,000 ៛ |
| 51–100 | 999 | $9.99 | 200,000 ៛ |
| 101–200 | 1,999 | $19.99 | 400,000 ៛ |
| 201–500 | 3,999 | $39.99 | 800,000 ៛ |
| 501–1,000 | 7,999 | $79.99 | 1,600,000 ៛ |
| 1,001+ | — | — | — |
More than 1,000 pages per document? Contact us for a custom quote.
Every request carries a TosKey API key in the Authorization: Bearer header. Issue keys in the TosKey Console, InKhmer section (S5) — a new key selects all three translator scopes by default.
Authorization: Bearer sk_live_…
# scopes: translator:translate | translator:read | translator:canceltranslator:translate — create jobs, upload intents, finalize uploads.translator:read — poll job status and download PDFs.translator:cancel — cancel a running job and refund unused pages.translator:* — wildcard covering all three; selected by default on new keys.The TosKey gateway enforces per-key limits before a request reaches the API. Responses carry X-RateLimit-Limit / -Remaining / -Reset and, on 429, a Retry-After seconds hint.
| Endpoint | Scope | Per minute | Per hour | Per day |
|---|---|---|---|---|
| POST /v1/public/document-translator/jobs | translator:translate | 10 | 100 | 1000 |
| POST /v1/public/document-translator/jobs/intent | translator:translate | 10 | 100 | 1000 |
| GET /v1/public/document-translator/jobs/{jobId} | translator:read | 60 | — | — |
| POST /v1/public/document-translator/jobs/{jobId}/finalize | translator:translate | 10 | 100 | 1000 |
| GET /v1/public/document-translator/jobs/{jobId}/pdf | translator:read | 10 | — | — |
| GET /v1/public/document-translator/jobs/{jobId}/pdf-url | translator:read | 10 | — | — |
| POST /v1/public/document-translator/jobs/{jobId}/cancel | translator:cancel | 10 | — | — |
50 req/min — All keys belonging to one identity share an account-level burst limit of 50 requests/minute — request 51 within a minute is rejected at the gateway.
pro-translator-rate pro-translator-rate lifts the write limits to 100/min · 1,000/hour · 10,000/day per key. It is assigned by the operator in the TosKey Console — contact us to request it; it is not self-serve.
Read X-RateLimit-Remaining to back off before a hard stop, and honour Retry-After on 429 responses. X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-ResetRetry-After
Every operation below is rendered from the live OpenAPI document — request and response bodies, parameters, and the exact error examples the API returns.
idempotency-key — Mutating endpoints accept the header. Replaying the same key within 24 hours returns the original response without charging twice.
v1 has no webhooks — poll GET /jobs/{jobId} every ~2 seconds until the state is done, error, or cancelled. A job.done webhook is on the roadmap.
Pipeline time scales with page count and whether the document needs OCR. A clean digital PDF averages a few seconds per page; scanned pages take longer because every page passes through OCR before translation. A 50-page digital document typically finishes in a couple of minutes. Poll the job every two seconds — the state moves uploading → detecting → translating → rendering → done, and pagesDone tells you real progress. Long documents (500+ pages) can take fifteen minutes or more; poll less aggressively once past the first minute.
Not in v1. The API only accepts document bytes — either the multipart POST /jobs flow or the direct-to-storage intent/finalize flow for larger files. Fetching documents from caller-supplied URLs is a roadmap item we are tracking because it raises SSRF and provenance questions we want to answer properly first. For now, download the document on your side and POST the bytes.
PDF only, in v1 — the same constraint as the web tool. Both digital PDFs and scanned PDFs work; scanned pages are detected automatically and routed through OCR. Word (.docx) and image uploads are on the roadmap. If your source is images, wrap them into a PDF client-side first — one image per page at a reasonable DPI gives the best OCR results.
The page count detected when the job is created — the PDF page count, not the number of pages containing text. The price is quoted from the tier table before the debit happens, so POST /jobs always tells you pagesTotal, tier, priceCredits, and your walletAfter balance up front. If the engine detects more pages than the 1,000-page cap the job is rejected with payload_too_large before any debit.
Credits are refunded automatically. An internal_error or upstream_unavailable failure returns the full debit; cancelling a running job refunds the pages not yet translated (prorated against pagesDone). The refund lands in the same wallet — no support ticket needed. You can verify the refund on the job itself: POST /jobs/{jobId}/cancel reports refundedCredits, and your billing page shows the ledger entry.
Yes — scanned PDFs are detected during the detecting phase and every page passes through OCR before translation. You do not need to flag a document as scanned. OCR quality depends on scan quality: crisp 300 DPI scans translate well, while low-contrast phone photos of pages can yield garbled source text. If pagesDone stalls or the error field reports an OCR failure, re-scan at higher resolution and resubmit.
v1 accepts PDF only — there is no image endpoint. If you have standalone images (JPG/PNG scans or phone photos), package them into a PDF first: most platforms can print-to-PDF or use a small library (img2pdf in Python, pdf-lib in Node). Put each image on its own page, in reading order, and the OCR pipeline handles it like any scanned document.
Documents are retained for the minimum time needed to serve the result. The uploaded original is deleted after 24 hours; the translated PDF is available for download for 7 days, then purged. Document text is never written to application logs — job metadata (page counts, states, error codes) is logged, never content. Download your output within the 7-day window; after that, only a new job can regenerate it.
The gateway responds 429 with Retry-After (seconds to wait) and the X-RateLimit-* headers on every response show your remaining budget. Two buckets apply: per-key limits per endpoint, and a 50-request/minute account burst shared by all your keys. Back off for the Retry-After window and retry — the request never reached the API, so nothing was debited. For sustained higher throughput, ask about the operator-assigned pro-translator-rate tier.
One key per application or environment is the practical pattern — a live key and a test key at minimum. Per-key limits mean separate apps do not starve each other, and revoking a leaked key does not break your other integrations. All keys roll up under your identity for the 50/minute account burst and for billing — credits come from one wallet regardless of which key spent them.
Using the API means you accept the InKhmer API Terms — acceptable use, retention, and rate-limit enforcement. Read the API terms