LQA API
The API reference lists every endpoint with its request and response schema, rendered from the OpenAPI document; this page explains how the calls behave. LQA is in early access and switched on per workspace.
Jobs
Scored human review on a configurable, template-driven scorecard. Create a job from a file or a segments array, the engine runs a QA pre-pass, a reviewer confirms findings in the panel, then the job is finalized and scored. Base path /lqa/v1.
POST /lqa/v1/jobs
Create and start a job. Send either a multipart file or a JSON segments array. Optional: template, mode (qa, lqa or blind), sample_words, the skip flags and project_ref / translator for attribution.
With a file (multipart)
curl -X POST https://app.stillneedsahuman.com/lqa/v1/jobs \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "account=northwind" \
-F "target_lang=tr" \
-F "template=acme_mqm" \
-F "mode=lqa" \
-F "sample_words=1500" \
-F "skip_locked=true" \
-F "project_ref=PRJ-1042" \
With segments (JSON)
curl -X POST https://app.stillneedsahuman.com/lqa/v1/jobs \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: PRJ-1042-run-1" \
-d '{
"account": "northwind",
"target_lang": "tr",
"template": "acme_mqm",
"mode": "lqa",
"sample_words": 1500,
"callback_url": "https://your.app/hooks/lqa",
"project_ref": "PRJ-1042",
"translator": "[email protected]",
"segments": [
{"seg_id": "1", "source": "Add to cart", "target": "Sepete ekle"}
]
}'
{
"jobId": "lqa_2a9f...",
"status": "queued",
"mode": "lqa",
"template": { "id": "acme_mqm", "version": 3, "resolved_from": "explicit" }
}
| Field | Notes |
|---|---|
file or segments | One is required. Files: xliff, sdlxliff, mxliff, txlf, xlz, wsxz and similar, at most 64 MB or 50,000 segments (413 above). |
account, division, source_lang, target_lang, content_type, profile | Where the job belongs and how the QA pre-pass picks its profile and template. |
external_ref | Your own reference, echoed in the poll (externalRef) and in the finalized result. |
template | A template id (see templates below). Omit to resolve by account, division and language. |
mode | qa, lqa (default) or blind. |
sample_words | Cap the reviewed sample so a large file is never fully scored. 0 scores the whole file; leave it out to use the template's own sampling. |
skip_exact, skip_context, skip_locked | Exclude 100 percent (exact), ICE (in-context exact, 101 percent or context) or locked segments from the run. Each is separate. A field left out takes the profile's setting; a profile that never set it checks 100 percent matches and skips ICE matches and locked segments. |
project_ref, translator | Free-text attribution carried through to the scorecard and callback. |
vendor_id, translator_id | Ids from your vendor and translator registry. Without the registry, any id of up to 120 characters (422 above). With the registry on, each must name an active row (see vendors and translators). Returned as vendorId and translatorId. |
callback_url | Optional. We POST the signed result here on finalize (see below). Only the operator workspace can set it. Any other workspace gets the job created without it, with a note under warnings in the response; register an lqa.finalized webhook to receive the result instead. |
Header Idempotency-Key | Safe retries: the same key returns the original job instead of creating a new one. An idempotency_key field does the same. |
The callback_url is POSTed once, when the job is finalized. It is independent of the workspace's webhooks, which fire on finalize too. When no template is named and none matches, the default one is used and the answer says so in resolved_from ("resolved_from": "default").
Where the template came from. resolved_from is request (you named it), the profile scope that names one (profile, account, division and so on), content_type (your workspace's content type rule), org_default (the workspace default in Settings) or default (the built-in default).
GET /lqa/v1/jobs/{id}
Poll a job. Returns the status, the findings so far and a live score with its split by category and severity (liveScore.by_category, liveScore.by_severity). Once finalized, score and verdict are the frozen final values. A job of another workspace is a 404.
curl https://app.stillneedsahuman.com/lqa/v1/jobs/lqa_2a9f... \
-H "Authorization: Bearer YOUR_TOKEN"
{
"jobId": "lqa_2a9f...",
"status": "ready_for_review",
"mode": "lqa",
"account": "northwind",
"targetLang": "tr",
"template": { "id": "acme_mqm", "version": 3 },
"wordCount": 1500,
"score": 98.7,
"verdict": "pass",
"liveScore": { "score": 98.7, "verdict": "pass" },
"findings": [
{
"id": 88,
"seg_no": 12,
"category": "mistranslation",
"severity": "major",
"weight": 5,
"status": "suggested",
"comment": "Wrong tense",
"suggestion": "Sepete eklendi"
}
],
"disputes": []
}
disputes lists the job's disputes in the order they were raised, each with its status (see disputes).
GET /lqa/v1/jobs
List the workspace's jobs, newest first. Optional status, limit (50 when left out) and offset query params. With the registry on, vendor_id and translator_id filter the list too and each job carries vendorName and translatorName.
POST /lqa/v1/jobs/{id}/submit
Finalize a job (lqa:write): locks the score and the verdict, fires the job's callback_url when it has one and sends the lqa.finalized event to your webhooks. Optional JSON body: {"summary_comment": "..."}.
{ "finalized": true,
"result": { "job_id": "...", "external_ref": "PO-9912",
"score": 98.2, "verdict": "pass", "scale": "points" } }
GET /lqa/v1/jobs/{id}/export.xlsx
The scorecard as an Excel workbook in the usual MQM and DQF handover shape: a Summary sheet (account, division, languages, template, quality score, verdict, error counts by severity, penalty, words, error density) and a Details sheet with one row per finding (segment, source, target, category, severity, comment, suggestion). It opens for a reviewer signed in to the panel, not for a key: a headless integration takes the finalized score and verdict from the submit answer or the lqa.finalized webhook and leaves the workbook to the reviewer.
The rest of the LQA calls
Two tiers. The first opens with a key; the second belongs to a reviewer working in the panel and needs a signed-in session, because each call edits a person's scorecard.
| Endpoint | What it does | Opened by |
|---|---|---|
GET /lqa/v1/analytics | Dashboard data for your workspace: finalized counts, the average score, the pass, review and fail split, per account, the score over time and the errors by category and severity. | lqa:read |
GET /lqa/v1/calibration?account=&target_lang= | How far the engine and the reviewers agree, per scope: confirmed findings against false positives. | lqa:read |
GET /lqa/v1/accounts | The accounts and divisions in your workspace's profiles, for an intake form. | lqa:read |
GET /lqa/v1/disputes | The dispute queue of your workspace, newest first (see disputes). | lqa:read |
GET /lqa/v1/dispute-policy | Your workspace's dispute policy, with the defaults filled in when none is set. | lqa:read |
PUT /lqa/v1/dispute-policy | Set the dispute policy. | lqa:write |
GET /lqa/v1/template-rules | Your content type rules (see templates). | lqa:read |
PUT /lqa/v1/template-rules | Replace the content type rules. | lqa:write |
POST /lqa/v1/jobs/{id}/reopen | Re-open a finalized scorecard for more edits. It undoes a locked score whose callback already fired, so it takes the same gate as submit. | lqa:write |
GET /lqa/v1/health | Liveness, with no key. | anyone |
GET /lqa/v1/jobs/{id}/scorecard | The full scorecard behind the score. | panel reviewer |
POST /lqa/v1/jobs/{id}/findings | Add a finding by hand. It takes the same root_cause and responsibility as the edit below. | panel reviewer |
PATCH /lqa/v1/findings/{findingId} | Edit one finding: its category, severity or comment, its root_cause (translation, source, terminology, instructions, tooling or other) and its responsibility (vendor, client, shared or none). Another value is a 422; an empty value or null clears the field. | panel reviewer |
POST /lqa/v1/jobs/{id}/assign | Assign the job to a reviewer. Body: {"reviewer_id": "..."}. | panel reviewer |
POST /lqa/v1/jobs/{id}/disputes | Raise a dispute against a finding. Body: finding_id, reason, evidence and an optional deadline_at (ISO 8601, 422 when it is not a date-time). The raiser is the signed-in reviewer. Without a deadline_at, the policy's default deadline applies when it has one. The answer lists the job's disputes with their status. | panel reviewer |
POST /lqa/v1/disputes/{disputeId}/resolve | Resolve a dispute, which can change the severity of the finding behind it. Body: resolution, new_severity and an optional resolved_reason. The dispute records who resolved it (resolved_by) and why. Who may resolve follows the dispute policy. | panel reviewer |
Finalized means frozen. Adding, editing or re-grading a finding on a finalized job is a 409: the score was locked and the callback already fired, so the findings on screen must not drift away from it. Re-open the job first.
Disputes
A reviewer who disagrees with a finding raises a dispute against it; another reviewer resolves it, which can change the finding's severity. The record keeps who raised it (the signed-in reviewer), when it is due (deadline_at), who resolved it (resolved_by) and why (resolved_reason). Raising and resolving need a reviewer signed in to the panel; the queue and the policy open with a key.
Status. Every dispute carries a status: resolved once it is resolved, overdue when its deadline_at has passed, open otherwise. The scorecard, GET /lqa/v1/jobs/{id}, the queue and the CSV all say the same.
GET /lqa/v1/disputes
The queue for the person who resolves disputes: your workspace's disputes, newest first, each with its job's external_ref, account, target_lang, vendor_id, translator_id and job_status, the disputed finding's category and severity, and its status. Answer: {"disputes": [...]}.
| Query param | What it does |
|---|---|
status | open, overdue or resolved. Another value is a 422. |
from, to | Dates as YYYY-MM-DD on the day the dispute was raised, UTC. from counts, to does not. |
limit, offset | Paging: 50 a page when left out, 500 at most. With a status, the page counts the disputes of that status only. |
The dispute policy
With no policy set, a linguist or anyone above resolves a dispute, the reviewer who raised it may resolve it and there is no default deadline. PUT /lqa/v1/dispute-policy changes that for your workspace. A field you leave out keeps its value.
{ "arbiter_role": "quality_lead", "self_resolve": false, "deadline_days": 5 }
| Field | What it does |
|---|---|
arbiter_role | The lowest role that may resolve: linguist (the default), pm, quality_lead or admin. |
self_resolve | true (the default) or false. With false, the reviewer who raised a dispute cannot resolve it (403). |
deadline_days | null (the default) or 1 to 365. A dispute raised without a deadline_at is due that many days later. |
Another value is a 422. GET /lqa/v1/dispute-policy answers the same three fields.
Analytics by date and group
GET /lqa/v1/analytics/v2
Your finalized scorecards for a date range, grouped the way your own report needs them, with words, errors per 1,000 words, the pass rate and the disputes. It opens with lqa:read. The dashboard data above (GET /lqa/v1/analytics) stays as it is.
| Query param | What it does |
|---|---|
from, to | Dates as YYYY-MM-DD. from counts, to does not: from=2026-06-01&to=2026-07-01 is June. Left out, to is tomorrow and from is one year before to, so you get the last 12 months. Another form, or a from that is not before to, is a 422. |
group_by | Up to three of month, lang, account, content_type, project, vendor and translator, separated by commas (month when left out). An unknown key or a fourth key is a 422. group_by= with nothing after it gives the total only. |
target_lang, account, content_type, project_ref, vendor_id, translator_id | Filters, each an exact match. They combine. |
curl "https://app.stillneedsahuman.com/lqa/v1/analytics/v2?from=2026-06-01&to=2026-12-01&group_by=month,lang" \
-H "Authorization: Bearer YOUR_TOKEN"
{ "from": "2026-06-01", "to": "2026-12-01", "group_by": ["month", "lang"],
"rows": [
{ "key": {"month": "2026-11", "lang": "tr"},
"jobs": 12, "words": 48000, "avg_score": 96.1,
"verdicts": {"pass": 10, "review": 1, "fail": 1}, "pass_rate": 0.833,
"errors": 61, "errors_per_1000_words": 1.27,
"disputes": 3, "disputes_resolved": 2 }
],
"total": { "jobs": 12, "words": 48000, "avg_score": 96.1,
"verdicts": {"pass": 10, "review": 1, "fail": 1}, "pass_rate": 0.833,
"errors": 61, "errors_per_1000_words": 1.27,
"disputes": 3, "disputes_resolved": 2,
"by_category": [{"category": "mistranslation", "count": 30}],
"by_severity": [{"severity": "minor", "count": 40}] },
"names": { "vendor": {}, "translator": {} } }
What counts. A job counts once it is finalized, by the date it was finalized. Errors are the findings a reviewer accepted, added or edited. errors_per_1000_words is null when the jobs have no words; pass_rate is the passed jobs over all jobs. The rows add up to total, which also carries the errors by category and by severity.
Size. The answer carries every group it finds; there is no page and nothing is left out. A query with more than 50,000 groups answers 422 and a query that runs longer than 15 seconds answers 504, each with the message telling you to add a filter, use fewer keys or shorten the range. Neither happens with a filter or a date range of a year.
Months are UTC, and so are the from and to dates.
Vendors and translators. vendor and translator work as group keys and vendor_id and translator_id as filters for every workspace. Vendor and translator names appear when the registry is on for your workspace; the ids are always there. names maps the ids in the answer to their names.
Bulk CSV
GET /lqa/v1/export.csv
The same finalized scorecards as CSV, for Excel, Power BI, Looker Studio or your own tool. It opens with lqa:read and takes the parameters of the analytics above: from and to with the same defaults (the last 12 months), and the six filters.
| Query param | What it does |
|---|---|
what | jobs (one row per scorecard), findings (one row per finding, in every state) or disputes (one row per dispute). Anything else is a 422. |
from, to | Dates as YYYY-MM-DD, as for the analytics. |
target_lang, account, content_type, project_ref, vendor_id, translator_id | Filters, each an exact match. |
text | text=1 adds the source and target text of each finding; left out unless you ask, so a findings file stays small and carries no segment text. Read for what=findings only. |
curl -o jobs.csv "https://app.stillneedsahuman.com/lqa/v1/export.csv?what=jobs&from=2026-01-01&to=2027-01-01" \
-H "Authorization: Bearer YOUR_TOKEN"
The columns, in this order:
- jobs:
job_id,external_ref,created_at,finalized_at,status,account,division,source_lang,target_lang,content_type,project_ref,vendor_id,vendor_name,translator_id,translator_name,translator_text,template_id,template_version,mode,word_count,score,verdict,counted_errors,errors_per_1000_words,disputesanddisputes_resolved.counted_errorsare the findings a reviewer accepted, added or edited, as in the analytics. - findings:
finding_id,job_id,external_ref,finalized_at,account,target_lang,content_type,project_ref,vendor_id,translator_id,seg_no,category,severity,weight,status,origin,rule,root_cause,responsibility,comment,suggestion,reviewer_idandcreated_at, thensourceandtargetwithtext=1. - disputes:
dispute_id,job_id,external_ref,finalized_at,account,target_lang,finding_id,category,original_severity,raiser,reason,created_at,deadline_at,resolved_at,resolved_by,resolution,new_severity,resolved_reasonandstatus.statusisresolved,overdue(past itsdeadline_at) oropen.
The file. It starts with a byte order mark, so it opens in Excel as UTF-8. Every time is UTC, ISO 8601, ending in Z (2026-06-01T09:30:00Z). Numbers are written as they are and an empty cell means no value. A cell that starts with =, +, - or @ gets a leading ' so a spreadsheet shows it as text and never runs it. Vendor and translator names appear when the registry is on for your workspace; the ids are always there.
Large ranges. The file streams, so a year of findings is a long download, not an error. The file is complete for the range when the download completes; a download that stops early is reported by your client as incomplete, never as a shorter file. A job finalized while the download runs may or may not be in it.
Rate. Ten exports a minute a workspace, on top of the read rate. See Rates and ceilings.
Vendors and translators
A list of the vendors and translators who work for your workspace, so a job names them by id and the scorecard and the export show their names. The registry is off unless it is turned on for your workspace: ask us. While it is off, these routes answer 404 and jobs behave as described above.
| Endpoint | What it does | Opened by |
|---|---|---|
GET /lqa/v1/vendors | The active vendors. ?all=1 adds the inactive ones. Answer: {"vendors": [...]}. | lqa:read |
POST /lqa/v1/vendors | Register a vendor. Body: vendor_id, name and optional contact and notes. 201 with the row. An id that is already registered updates that row instead (200), so a repeated call is safe. | lqa:write |
PATCH /lqa/v1/vendors/{vendorId} | Change name, contact, notes or active. 404 for an unknown id. | lqa:write |
GET /lqa/v1/translators | The active translators, each with its vendor_name. Optional vendor_id filter; ?all=1 adds the inactive ones. Answer: {"translators": [...]}. | lqa:read |
POST /lqa/v1/translators | Register a translator. Body: translator_id, name and optional email, vendor_id, languages (a list of language tags such as de or pt-BR) and notes. A vendor_id must name an active vendor (422 otherwise). Same create or update rule as vendors. | lqa:write |
PATCH /lqa/v1/translators/{translatorId} | Change any of those fields or active. vendor_id: null clears the vendor. | lqa:write |
curl -X POST https://app.stillneedsahuman.com/lqa/v1/translators \
-H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
-d '{"translator_id": "ada", "name": "Ada Lovelace", "email": "[email protected]", "vendor_id": "vendor-42"}'
Ids. 1 to 64 letters, digits, dots, underscores or hyphens, starting with a letter or a digit (422 otherwise). Rows are never deleted: "active": false hides a vendor or a translator from the lists and from new jobs, and the jobs that name it keep their history.
On job create. A vendor_id or translator_id must name an active row, else 422 "unknown vendor_id" (or translator_id). A job with a translator and no vendor takes the translator's vendor. The free-text translator is still accepted and stored; when it equals a registered translator's id or email (case does not matter) and no translator_id is sent, the job gets that translator's id.
In the job. With the registry on, the job (GET /lqa/v1/jobs/{id} and the scorecard) carries "vendor": {"id", "name"} and "translator": {"id", "name"}, each null without an id, and the free text moves from translator to translatorText. The export's Summary sheet gains a Vendor row and a Translator row.
Older jobs. Jobs created before the registry name their translator as free text only. A one-off script maps that text onto registry translators once; we run it when we turn the registry on for your workspace.
Templates
A template defines the scorecard: categories, severities, weights, the scoring formula, sampling and how engine rules map onto categories. Seed templates ship with the product (for example a simple 2-category template and a 9-category MQM template). Workspaces can add their own.
Two seeds use the MQM core category names: mqm_core ("MQM core typology", with second-level categories such as mistranslation under accuracy and spelling under linguistic_conventions) and mqm_core_light ("MQM core, top level only"). A scorecard on either gives you findings sorted into MQM categories as evidence for your own quality process.
GET /lqa/v1/templates
curl https://app.stillneedsahuman.com/lqa/v1/templates \
-H "Authorization: Bearer YOUR_TOKEN"
POST /lqa/v1/templates
Create or update a custom template (admin or Bearer). The body is validated against the template schema; a body that fails comes back as 422 with the reasons and a body the server accepts. The example below is that body.
curl -X POST https://app.stillneedsahuman.com/lqa/v1/templates \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "acme_mqm",
"name": "Acme MQM",
"categories": [
{"code": "mistranslation", "label": "Mistranslation"},
{"code": "terminology", "label": "Terminology"}
],
"severities": [
{"code": "minor", "weight": 1},
{"code": "major", "weight": 5},
{"code": "critical", "weight": 10}
],
"scoring": {"method": "mqm_wordcount", "scale": "points", "pass_threshold": 97, "review_band": 90},
"sampling": {"mode": "stratified", "unit": "words", "size": 1500}
}'
| Field | Notes |
|---|---|
scoring.method | mqm_wordcount (default: penalty points per reference_words, 1000 by default), flat_penalty or points_per_n_words. |
scoring.scale | points (default; the pass mark and the review band are 0 to 100) or fraction (0 to 1). A value outside the scale's range is refused. |
scoring.pass_threshold, scoring.review_band | A score at or above pass_threshold passes; between review_band and pass_threshold it goes to review; below fails. review_band must not exceed pass_threshold. Defaults: 95 and 85 on the points scale. |
severities[].weight | Penalty per finding of that severity; hard_fail: true fails the job on a single finding. |
sampling | mode is all, random or stratified; unit is words or segments; size is required unless the mode is all. |
categories[].parent | Optional: the code of another category of the same template, so the category sits under it. One level only: a parent cannot have a parent itself. A parent may come later in the list. It groups categories for display and export; the score does not use it. |
DELETE /lqa/v1/templates/{id}
Delete a custom template (lqa:write). A built-in seed template, listed with "seed": true, is read-only and answers 400.
Content type rules
A job with a content_type can take its template from a rule of your workspace, without a profile scope. The rule applies below the profile scopes and above the workspace default: a template named in the request or by a profile scope still wins, and a job with no content type, or one no rule names, uses the workspace default as before. Scorecards made from Eval and from Compare do not read the rules.
curl -X PUT https://app.stillneedsahuman.com/lqa/v1/template-rules \
-H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
-d '{"by_content_type": {"ui": "mqm_core_light", "legal": "mqm_core"}}'
The body replaces every rule; {"by_content_type": {}} removes them all. A content type matches whatever its case and the spaces around it, 64 characters at most, 50 rules at most. Each value must name a seed or one of your own templates, else 422. GET /lqa/v1/template-rules answers the same shape. A job created through a rule says "resolved_from": "content_type".
Finalize callback
When a reviewer submits a job, we POST the final result to the job's callback_url. The request is signed and the destination is checked, so it cannot reach a private or loopback address. The callback is for the operator workspace only. Every other workspace receives the result through its own lqa.finalized webhook, which is signed with that webhook's secret.
The callback body also carries by_category, by_severity, vendor_id and translator_id: the scoring summary of the counted findings (category codes to {count, penalty}, severity codes to a count) and the job's vendor and translator ids.
Verify the signature
The callback is not signed with your token. It is signed with this deployment's callback signing secret, which the platform operator gives you on request. The request carries X-WW-Timestamp (Unix seconds) and X-WW-Signature. Compute HMAC-SHA256 with that secret over the timestamp, a dot and then the raw request body. Hex-encode it, prefix it with sha256= and compare it with the header. Reject a stale timestamp to defend against replay.
import hmac, hashlib
def verify(raw_body: bytes, headers, secret: str) -> bool:
signed = headers["X-WW-Timestamp"].encode() + b"." + raw_body
expected = "sha256=" + hmac.new(
secret.encode(), signed, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, headers["X-WW-Signature"])
A secret of your own. For a signature only your workspace can check, subscribe to the lqa.finalized webhook instead. It is signed with a secret you choose (see Webhooks).
Manual finalize. You can also finalize a job over the API with POST /lqa/v1/jobs/{id}/submit, which fires the same callback.