Lingivio

Developer reference

REST API

Three steps: upload a file, start a translation, retrieve the result. Everything the app does, your code can do — the same pipeline, the same layout guarantees.

Base URL

https://staging-api.lingivio.com Create a key Download openapi.json

API access starts at the Business plan. A key on any other plan still signs in and still reads GET /v1/me, but every other API endpoint answers with the error class plan.api_access_required and names the plan that includes it, so you always know why.

On this page

Authentication

Create a key in Settings → API keys. It is shown once and never recoverable. Send it as a bearer token on every request.

Shell
curl https://staging-api.lingivio.com/v1/me -H "Authorization: Bearer llk_your_key_here"

Quickstart

Copy this verbatim. It uploads a PDF, reads back its verified type, detected language and credit cost, translates it side by side into Arabic, polls to completion and downloads the result.

Shell
# 1. Create a key in Settings → API keys (paid plans only), then:
export KEY="llk_your_key_here"
export API="https://staging-api.lingivio.com"

# 2. Upload the file. The response carries the VERIFIED content type, the detected
#    source language and the real credit count — so you can price the job first.
#    (`page_count` is the same number under its old, misleading name; prefer `credits`.)
FILE=$(curl -s -X POST "$API/v1/files" \
  -H "Authorization: Bearer $KEY" \
  -F "file=@report.pdf")
echo "$FILE" | jq '{id, content_type, detected_source_language, credits}'
FILE_ID=$(echo "$FILE" | jq -r .id)

# 3. Start the translation. Idempotency-Key makes a retry safe: the same key with the
#    same body returns the SAME translation and is billed once.
JOB=$(curl -s -X POST "$API/v1/translations" \
  -H "Authorization: Bearer $KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "content-type: application/json" \
  -d '{"file_id":"'"$FILE_ID"'","target_language":"ar","side_by_side":true}')
JOB_ID=$(echo "$JOB" | jq -r .id)

# 4. Poll. progress.done / progress.total are REAL segment counts.
until [ "$(curl -s "$API/v1/translations/$JOB_ID" -H "Authorization: Bearer $KEY" | jq -r .status)" = "done" ]; do
  sleep 3
done

# 5. Download.
curl -s "$API/v1/translations/$JOB_ID/output" -H "Authorization: Bearer $KEY" -o report.ar.pdf

Large Files

Above 25 MiB, upload in parts. Each part except the last must be exactly the min_part_bytes the session reports; re-sending a part number replaces it, so an interrupted upload resumes rather than restarts.

Shell
# Files above 25 MiB use the resumable path.
SESSION=$(curl -s -X POST "$API/v1/files/uploads" \
  -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"filename":"scan.pdf","bytes":'$(wc -c < scan.pdf)'}')
UPLOAD_ID=$(echo "$SESSION" | jq -r .upload_id)
PART=$(echo "$SESSION" | jq -r .min_part_bytes)

split -b "$PART" scan.pdf part-
i=1
for f in part-*; do
  curl -s -X PUT "$API/v1/files/uploads/$UPLOAD_ID/parts/$i" \
    -H "Authorization: Bearer $KEY" --data-binary "@$f" > /dev/null
  i=$((i + 1))
done

curl -s -X POST "$API/v1/files/uploads/$UPLOAD_ID/complete" -H "Authorization: Bearer $KEY"

Client Snippets

The same flow in the language you are starting in.

curl
# 1. Create a key in Settings → API keys (paid plans only), then:
export KEY="llk_your_key_here"
export API="https://staging-api.lingivio.com"

# 2. Upload the file. The response carries the VERIFIED content type, the detected
#    source language and the real credit count — so you can price the job first.
#    (`page_count` is the same number under its old, misleading name; prefer `credits`.)
FILE=$(curl -s -X POST "$API/v1/files" \
  -H "Authorization: Bearer $KEY" \
  -F "file=@report.pdf")
echo "$FILE" | jq '{id, content_type, detected_source_language, credits}'
FILE_ID=$(echo "$FILE" | jq -r .id)

# 3. Start the translation. Idempotency-Key makes a retry safe: the same key with the
#    same body returns the SAME translation and is billed once.
JOB=$(curl -s -X POST "$API/v1/translations" \
  -H "Authorization: Bearer $KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "content-type: application/json" \
  -d '{"file_id":"'"$FILE_ID"'","target_language":"ar","side_by_side":true}')
JOB_ID=$(echo "$JOB" | jq -r .id)

# 4. Poll. progress.done / progress.total are REAL segment counts.
until [ "$(curl -s "$API/v1/translations/$JOB_ID" -H "Authorization: Bearer $KEY" | jq -r .status)" = "done" ]; do
  sleep 3
done

# 5. Download.
curl -s "$API/v1/translations/$JOB_ID/output" -H "Authorization: Bearer $KEY" -o report.ar.pdf
JavaScript
const API = 'https://staging-api.lingivio.com';
const KEY = process.env.LINGIVIO_KEY;
const auth = { Authorization: `Bearer ${KEY}` };

// 1. Upload
const form = new FormData();
form.append('file', new File([await Bun.file('report.pdf').arrayBuffer()], 'report.pdf'));
const file = await fetch(`${API}/v1/files`, { method: 'POST', headers: auth, body: form })
  .then((r) => r.json());
console.log(file.content_type, file.detected_source_language, file.credits);

// 2. Translate (idempotent)
let job = await fetch(`${API}/v1/translations`, {
  method: 'POST',
  headers: { ...auth, 'content-type': 'application/json', 'Idempotency-Key': crypto.randomUUID() },
  body: JSON.stringify({ file_id: file.id, target_language: 'ar', side_by_side: true }),
}).then((r) => r.json());

// 3. Poll on REAL segment counts
while (job.status !== 'done' && job.status !== 'failed') {
  await new Promise((r) => setTimeout(r, 3000));
  job = await fetch(`${API}/v1/translations/${job.id}`, { headers: auth }).then((r) => r.json());
  console.log(job.progress.done, '/', job.progress.total);
}
if (job.status === 'failed') throw new Error(job.error.class); // stable, machine-readable

// 4. Download
const out = await fetch(`${API}/v1/translations/${job.id}/output`, { headers: auth });
await Bun.write('report.ar.pdf', await out.arrayBuffer());
Python
import os
import time
import uuid
import httpx

API = "https://staging-api.lingivio.com"
auth = {"Authorization": f"Bearer {os.environ['LINGIVIO_KEY']}"}

with httpx.Client(base_url=API, headers=auth, timeout=120) as http:
    # 1. Upload
    with open("report.pdf", "rb") as fh:
        file = http.post("/v1/files", files={"file": ("report.pdf", fh)}).raise_for_status().json()
    print(file["content_type"], file["detected_source_language"], file["credits"])

    # 2. Translate (idempotent)
    job = http.post(
        "/v1/translations",
        headers={"Idempotency-Key": str(uuid.uuid4())},
        json={"file_id": file["id"], "target_language": "ar", "side_by_side": True},
    ).raise_for_status().json()

    # 3. Poll on REAL segment counts
    while job["status"] not in ("done", "failed"):
        time.sleep(3)
        job = http.get(f"/v1/translations/{job['id']}").raise_for_status().json()
        print(job["progress"]["done"], "/", job["progress"]["total"])
    if job["status"] == "failed":
        raise RuntimeError(job["error"]["class"])  # stable, machine-readable

    # 4. Download
    out = http.get(f"/v1/translations/{job['id']}/output").raise_for_status()
    open("report.ar.pdf", "wb").write(out.content)

Endpoint Reference

Every operation the API publishes, projected from the same OpenAPI document your client generator reads. Paths are relative to the base URL above.

Files

POST /v1/files Accepts Idempotency-Key

Upload a file (single shot)

Upload a document as multipart/form-data with the bytes in a file part. The response carries the file's VALIDATED content type (read from the bytes, not the extension), its detected source language and its real billing page count, so a job can be priced before it is started. Bodies above 25 MiB must use the resumable path (POST /v1/files/uploads).

Request body

multipart/form-data

Responses

  • 201 the stored file PublicFile
  • 400 malformed request
  • 403 free plan, or an unconfirmed address ErrorEnvelope
  • 409 the idempotency key is in use for a different body
  • 413 over a size or storage limit ErrorEnvelope
  • 415 unsupported format, or the bytes are not what the name claims ErrorEnvelope
  • 422 corrupt, encrypted or rejected content ErrorEnvelope
  • 429 rate limited
POST /v1/files/uploads

Start a resumable upload

Begin a multi-part upload for a large input. Upload each part with PUT /v1/files/uploads/{uploadId}/parts/{number}, then POST .../complete. Every part except the last must be exactly min_part_bytes.

Request body

application/json

Responses

  • 201 the open session PublicUploadSession
  • 400 malformed request
  • 403 free plan, or an unconfirmed address ErrorEnvelope
  • 413 over a size or storage limit ErrorEnvelope
  • 415 unsupported format ErrorEnvelope
  • 429 rate limited
PUT /v1/files/uploads/{uploadId}/parts/{number}

Upload one part

Store one part of a resumable upload. The body is the raw bytes of the part. Re-uploading a part number REPLACES it, so an interrupted client resends only what is missing.

Parameters

  • uploadId string path required
  • number string path required

Request body

application/octet-stream

Responses

  • 200 the stored part
  • 400 bad part number, or an empty body
  • 404 no such session for this caller
  • 409 the session is closed or expired
POST /v1/files/uploads/{uploadId}/complete

Complete a resumable upload

Assemble the parts, run the content-based intake gate, count pages and detect the source language. Safe to retry: a completed session answers with the file it produced.

Parameters

  • uploadId string path required

Responses

  • 201 the stored file PublicFile
  • 404 no such session for this caller
  • 409 no parts, or the part sizes are invalid
  • 413 over a size limit ErrorEnvelope
  • 415 the bytes are not what the name claims ErrorEnvelope
  • 422 corrupt, encrypted or rejected content ErrorEnvelope
DELETE /v1/files/uploads/{uploadId}

Abandon a resumable upload

Abort the R2 upload and release the reserved file id. Idempotent.

Parameters

  • uploadId string path required

Responses

  • 200 aborted
  • 404 no such session for this caller
GET /v1/files

List uploaded files

The caller's files that are still inside the 24h retention window, newest first.

Responses

  • 200 a page of files
  • 403 free plan, or an unconfirmed address ErrorEnvelope
GET /v1/files/{id}

Read one file

Fetch a file's metadata. A file belonging to another account answers 404 — never 403 — so an id cannot be used to probe other tenants.

Parameters

  • id string path required

Responses

  • 200 the file PublicFile
  • 404 no such file for this caller
DELETE /v1/files/{id}

Delete a file early

Remove the stored bytes before the 24h retention deadline. The row survives while a translation still references it, so job history stays intact.

Parameters

  • id string path required

Responses

  • 200 deleted
  • 404 no such file for this caller

Translations

POST /v1/translations Accepts Idempotency-Key

Translate an uploaded file

Start an asynchronous translation of a file uploaded through POST /v1/files. Credits are reserved from the engine's own page count — never from anything in this request. Send Idempotency-Key to make a retry safe: a replay resolves to the job the first call created and bills once. Attach a callback_url to be told when the job finishes instead of polling: this API POSTs one JSON notification per terminal state (done, failed, canceled) to that https URL, signed Layoutlock-Signature: ts=<epoch seconds>;h1=<hex HMAC-SHA256 of "${ts}:${rawBody}"> with your account's signing secret, and identified by a stable Layoutlock-Event-Id — verify the signature, reject a timestamp outside your own tolerance, and dedupe on the event id, because a retry repeats the same id and the same bytes. Delivery is retried on a bounded backoff until your endpoint answers 2xx.

Request body

application/json PublicTranslationRequest

Responses

  • 201 the queued translation PublicTranslation
  • 400 malformed request, or an unsupported target language
  • 402 not enough page credits
  • 403 free plan, or an unconfirmed address ErrorEnvelope
  • 404 no such file or glossary for this caller
  • 409 the idempotency key is in use for a different body
  • 413 over the per-job page ceiling ErrorEnvelope
  • 422 the document could not be processed ErrorEnvelope
  • 429 rate limited, or the concurrency limit is reached
  • 503 translation is paused, or the page count is unavailable
POST /v1/translations/batches Accepts Idempotency-Key

Translate several files as one batch

Start translations for up to 20 already-uploaded files as ONE act. The credits for the whole batch are reserved atomically, so a batch can never half-run out of credits partway through; members are held pending and start as the plan's concurrency allows, so a batch queues behind the limit instead of bypassing it. A file that fails its own gate is reported in rejected and costs nothing — it never fails the batch. Send Idempotency-Key to make a retry safe: a replay resolves to the batch the first call created and bills once.

Request body

application/json PublicTranslationBatchRequest

Responses

  • 201 the created batch PublicTranslationBatch
  • 400 malformed request, or more than 20 files
  • 402 not enough page credits for the whole batch
  • 403 free plan, or an unconfirmed address ErrorEnvelope
  • 409 the idempotency key is in use for a different body
  • 422 no file in the batch could be processed
  • 429 rate limited
  • 503 translation is paused
GET /v1/translations/batches/{id}/output

Download a batch as a zip

Stream every FINISHED member of the batch as one zip archive. X-Batch-Files reports how many members the archive contains and X-Batch-Total how many the batch has, so a partial download is visible without unzipping it.

Parameters

  • id string path required

Responses

  • 200 the archive
  • 404 no such batch for this caller
  • 409 no member of the batch has finished yet
GET /v1/translations/batches/{id}

Read one batch

Per-file status, per-file error class and REAL per-file segment counts. There is deliberately no single batch status: progress carries the counts and each entry carries its own outcome, because one word over N documents cannot be honest.

Parameters

  • id string path required

Responses

  • 200 the batch PublicTranslationBatch
  • 404 no such batch for this caller
GET /v1/translations

List translations

The caller's translations, newest first, with status, page counts and error class.

Responses

  • 200 a page of translations
  • 403 free plan, or an unconfirmed address ErrorEnvelope
GET /v1/translations/{id}/output

Download the translated document

Stream the finished document. ?variant=dual serves the bilingual side-by-side PDF when the job produced one (output.variants says whether it did).

Parameters

  • id string path required
  • variant mono | dual query optional which output to download

Responses

  • 200 the translated document
  • 404 no such translation, or that variant was not produced
  • 409 the translation has not finished
POST /v1/translations/{id}/cancel

Cancel a translation

Stop a translation that has not finished. A queued job is cancelled immediately; a running one enters cancel_requested and holds its reserve until the engine confirms compute stopped. Retrying a cancel is not an error.

Parameters

  • id string path required

Responses

  • 200 the updated translation PublicTranslation
  • 404 no such translation for this caller
  • 409 the translation is already finished
GET /v1/translations/{id}

Read one translation

Status and REAL segment progress. progress.done/progress.total are the engine's own counts — there is no synthesised percentage.

Parameters

  • id string path required

Responses

  • 200 the translation PublicTranslation
  • 404 no such translation for this caller

Error Classes

Every failure carries a stable class, a retryable flag and what happened to your reserved credits. Branch on the class, never on the prose.

Class Status Retryable Credits
file.too_large 413 No release
file.too_many_pages 413 No release
file.unsupported_format 415 No release
file.type_mismatch 415 No release
file.corrupt 422 No release
file.password_protected 422 No release
file.zip_bomb 422 No release
file.malware_detected 422 No release
file.pagination_unavailable 422 No release
storage.quota_exceeded 413 No release
content.policy_violation 422 No release
scan.unavailable 503 Yes release
account.suspended 403 No release
pdf.no_text_layer 422 No release
pdf.too_many_scanned_pages 413 No release
pdf.encrypted 422 No release
ocr.failed 422 No release
llm.provider_down 503 Yes hold
llm.invalid_response 502 No release
verify.invariant_failed 422 No release
engine.timeout 504 Yes hold
engine.oom 500 Yes hold
engine.unexpected 500 No hold
privacy.export_rate_limited 429 Yes none
privacy.deletion_confirmation_failed 400 No none
privacy.consent_invalid 400 No none
auth.email_unverified 403 No none
auth.captcha_required 400 Yes none
auth.captcha_failed 403 Yes none
auth.verification_token_invalid 400 No none
auth.verification_token_expired 410 Yes none
auth.account_link_denied 403 No none
email.send_failed 502 Yes none
plan.api_access_required 403 No none
plan.format_not_included 403 No none
plan.file_too_large 413 No none
plan.batch_not_included 403 No none
credits.partial_unavailable 402 No none
spend.ceiling_reached 503 Yes none
spend.unavailable 503 Yes none
ops.dispatch_halted 503 Yes release
webhook.callback_url_invalid 400 No none

Rate limits

Limits are per key, in a fixed 60-second window. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; a 429 carries Retry-After. Two keys on one account have two separate budgets, so a noisy integration cannot starve a quiet one.

Idempotency

Send Idempotency-Key on any POST. The same key with the same body returns the resource the first call created and is billed exactly once; the same key with a different body is refused with 409. Keys are remembered for 24 hours.

Versioning

/v1 is stable. New fields, new optional parameters and new error classes can ship at any time — ignore what you do not recognise. A breaking change gets a new prefix, and /v1 keeps being served for at least 12 months with a Sunset header on every response.

Machine-Readable Spec

The OpenAPI 3.1 document is generated from the same schemas the server validates against, so it cannot drift. Point your client generator at it.

Download openapi.json

Try it

Upload a real document with your own key and watch the actual request sequence. Nothing here is simulated.
This runs a real translation and spends real credits from your balance.
1. KeyYour key stays in this browser tab only. It is never saved, never put in the address bar and never sent anywhere but the API.
2. UploadPOST /v1/files — the response tells you the verified type, the detected language and what it will cost in credits.
3. TranslatePOST /v1/translations — asynchronous, with an idempotency key so a retry cannot double-charge.

Cookies, analytics and diagnostics

Only strictly necessary cookies are used to sign you in and remember your preferences. We would also like your permission for analytics, so we can see which parts of the product are used, and for diagnostics, so browser errors are reported to us. Nothing non-essential runs unless you say yes, and you can change this at any time.Read the Cookie Policy