Skip to main content

QA checks, runs and reports

The API reference lists every endpoint with its request and response schema, rendered from the OpenAPI document; this page explains how the calls behave.

QA adapter​

A small REST contract that runs the rule checks and the AI review on one bilingual file. Any TMS or pipeline can use it. Enqueue a check, then poll until it is completed. The file is never modified: only findings come back.

How the profile is picked. A profile you name wins. Otherwise the profile is matched in this order: account, division and language pair; account and language pair; account and division; account alone; language pair alone; then the file name; then the content, with the generic profile last. Send account and targetLang, plus division and sourceLang when you know them, to land on the client's own profile.

Health and the token check. GET /qa-adapter/v1/health answers without a key. POST /qa-adapter/v1/auth answers {"token": "ok", "expires_in": 86400} when your Bearer key is valid.

POST /qa-adapter/v1/check​

Enqueue a check of one bilingual file, sent as multipart form data in the file field. The file carries the CAT tool's own metadata (locks, match rates, notes), so this is the primary endpoint. Returns a checkId right away. A JSON body gets 400 here; send segments to /check-segments instead.

curl -X POST https://app.stillneedsahuman.com/qa-adapter/v1/check \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "file=@job_en_tr.sdlxliff" \
-F "projectRef=PRJ-1042" \
-F "sourceLang=en" \
-F "targetLang=tr"
{ "checkId": "ww_8f3c2b1a...", "status": "pending" }

The answer is pending when the AI review runs in the background (the call asked for it or the matched profile has it on), and the final rule-only result at once otherwise. Over the workspace's rate limit the call is a 429 with Retry-After in seconds: back off and retry, it is not a failed check.

The form fields​

FieldTypeNotes
filefileRequired. One bilingual file of at most 200 MB: .wsxz, .zip, .xlz, .mqxlz, .sdlrpx, .sdlppx, .sdlxliff, .xliff, .xlf, .mxliff, .mqxliff, .txlf or a bilingual spreadsheet (.xlsx, .xlsm, .xltx, .xls, .csv, .tsv) with a source column and a target column. Headers such as Source and Target, Kaynak and Hedef or the language codes are mapped on their own. A software string file: a gettext .po, or an i18n .json, .resx, .strings, .stringsdict or .properties in a .zip with its source file (a lone one-language file is checked as a file with no source and the report says so). Findings name each string by its key.
sourceLangstringLeft out, the profile's own, else what the file declares.
targetLangstringLeft out, the profile's own, else what the file declares or a confident detection from the text.
projectRefstringOptional. Your reference, stored with the run.
profilestringOptional. Runs this profile and skips the matching above.
account, divisionstringOptional. Used to match the profile.
enableAitrue or falseOptional. true, 1, yes or on turns the AI review on, unless the workspace turned AI checks off or the profile has its AI review off; false, 0, no or off turns it off for this check. Left out, the profile decides.
aiModestringOptional. full also judges style, tone, register and naturalness; critical looks for meaning and data errors only. Left out, the profile's setting. An unknown value is ignored, never an error.
skipExactMatchestrue or falseOptional. Leave 100 percent (exact) matches out. true or false wins over the profile for this check (since 4 November 2026 an explicit false checks them even where the profile skips them; before, a false was read as not sent). Left out or empty, the profile decides; a profile that never set it checks them.
skipContextMatchestrue or falseOptional. Leave ICE (in-context exact, 101 percent or context) matches out. true or false wins over the profile for this check. Left out or empty, the profile decides; a profile that never set it skips them.
skipLockedtrue or falseOptional. Leave locked segments out, and units the file marks not for translation (translate="no" with no lock of its own; with false these are still only checked for a changed target). true or false wins over the profile for this check (since 4 November 2026 an explicit false checks locked segments even where the profile skips them; before, a false was read as not sent). Left out or empty, the profile decides; a profile that never set it skips them. A changed do-not-translate unit is reported whatever this says.
spellchecktrue or falseOptional. Turns the AI-assisted spelling check on.
localeFormattrue or falseOptional. Turns the locale format check on, as a warning.
noCachetrue or falseOptional. Asks every AI prompt afresh instead of reusing the identical-prompt memo, the answer to an identical earlier prompt. For measurement: two passes over the same file with the same model otherwise give the remembered answers with no provider call. The result reports this in aiCache.

POST /qa-adapter/v1/check-segments​

The same check for segments sent as JSON instead of a file. Each segment takes source and target. Optional fields are id, locked, matchPct, matchKind and note. It returns the same checkId, which you poll the same way. Submit a file where you have one: it carries the CAT tool's own metadata (locks, match rates, segment notes), which this body can only approximate.

The other fields of /check (projectRef, profile, account, division, enableAi, aiMode, the three skip flags, spellcheck, localeFormat, noCache) go in as JSON keys. A body holds at most 5,000 segments and 20,000 characters per side; a malformed segment is a 400 that names its position. The targetLang you send wins over the matched profile's own, so a Russian file is never judged as Turkish; leave it out and the profile's own target language is used. With no profile, the profile is matched from the account, the division and the language pair, as on /check.

{
"sourceLang": "en", "targetLang": "ru",
"profile": "generic",
"segments": [
{ "id": "1", "source": "Sign in to continue.", "target": "Войдите, чтобы продолжить." },
{ "id": "2", "source": "Tap Next.", "target": "Нажмите Далее.",
"locked": false, "matchPct": 100, "matchKind": "exact", "note": "UI string" }
]
}
curl -X POST https://app.stillneedsahuman.com/qa-adapter/v1/check-segments \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"projectRef": "PRJ-1042",
"sourceLang": "en",
"targetLang": "tr",
"segments": [
{"id": "1", "source": "Add to cart", "target": "Sepete ekle"}
]
}'
{ "checkId": "ww_8f3c2b1a...", "status": "completed" }

GET /qa-adapter/v1/check/{checkId}​

Poll the check (qa:read). status is pending, completed or failed. The AI review reads every segment, so a long file can stay pending for a moment before it settles; poll every few seconds, or register a webhook. A check id of another workspace is a 404, so nobody learns that it exists. Results are stored, so a later poll answers the same.

curl https://app.stillneedsahuman.com/qa-adapter/v1/check/ww_8f3c2b1a... \
-H "Authorization: Bearer YOUR_TOKEN"
{
"checkId": "ww_8f3c2b1a...",
"status": "completed",
"qualityScore": 96.5,
"errorsCount": 1,
"errors": [
{
"errorId": "e_4471",
"category": "Accuracy",
"subcategory": "Numbers",
"severity": "critical",
"segmentText": "Sepete 3 ekle",
"errorDescription": "Number mismatch: source has no digit",
"penaltyPoints": 5
}
]
}

A fuller answer, with the AI fields:

{
"checkId": "ww_ab12cd34...",
"status": "completed",
"errors": [
{
"errorId": "e_00007",
"category": "Accuracy",
"subcategory": "Numbers",
"severity": "critical",
"segmentText": "Zahlen Sie 100 EUR ...",
"errorDescription": "Number 100 in source not found in target",
"penaltyPoints": 5
}
],
"qualityScore": 97.5,
"errorsCount": 1,
"aiStatus": "ok",
"aiReviewed": true,
"profile": "generic",
"models": { "main": ["gemini:gemini-flash-example"],
"second": ["openai:gpt-mini-example"],
"consensusRequired": false },
"consensus": { "checked": 0, "confirmed": 0, "dropped": 0, "available": true },
"cached": false,
"aiCache": { "enabled": true, "hits": 0 },
"fallback": { "from": "gpt-example", "to": "gemini:gemini-flash-example",
"calls": 43, "ownCalls": 0, "all": true,
"kind": "quota", "status": 429,
"reason": "429 from openai: insufficient_quota" }
}

The error shape​

FieldNotes
category / subcategoryAccuracy, Terminology, Completeness, Style, Fluency, Consistency, Client checklist. Links and email addresses are not placeholders: a changed or missing link comes as Accuracy / URL and a changed address as Accuracy / Email (since 8 October 2026; a link came as Placeholders before). An agreement error the AI finds (gender, number, pronoun or case) comes as Fluency / Grammar (since 17 October 2026; it came as Accuracy / Mistranslation before). Every rule code of the engine has its own category and subcategory since 17 October 2026; before that a code outside the map came as Accuracy / General, which now only a profile's own code does.
severitycritical, major, minor or preferential: the word the report prints for the same finding (since 18 October 2026; before, only numbers, placeholders, tags, brand terms and protected terms came as critical and every other critical finding as major). Preferential is a note on the file or the run that costs nothing.
penaltyPointsWhat the finding costs in the score, by the report's own weights: critical 5, major 1, minor 0, preferential 0. A finding the AI disputed costs half its points and carries aiVerdict dispute, or nothing when the workspace leaves disputed findings out (disputeScoring).
aiVerdictagree, dispute or unsure: the main model's verdict on a critical or major rule finding. A dispute on a mechanical rule (tags, numbers, placeholders and the like) stands only when a second model from another provider disputes it too; otherwise it reads unsure. Present only when that check ran on the finding. An AI finding carries agree only when a second model confirmed it.
secondOpiniontrue when the AI check of rule findings ran on this check (the field keeps its old name). It runs unless the workspace turned it off (Settings, AI review) or the call sent secondOpinion: false, so false means no finding carries aiVerdict and nothing was disputed or agreed.
errorIdStable id, so you can track ignore or fixed status across polls.
qualityScore0 to 100, the score the report of the same run shows: 100 minus the penalty points per 1000 words, with identical findings grouped. The words counted are those of the segments the check covered: segments the skip flags take out are not counted. null when the file has no source words to score.
skippedWhat the check left out, by reason: {"100%": 3, "ICE": 12, "locked": 40, "not for translation": 2}, with fuzzy<N> for fuzzy matches below a set percentage. A reason is counted whether the request, the profile or the default left those segments out (a profile that never set its boxes skips ICE and locked segments). {} when nothing was left out. Since 4 November 2026, on /check and /check-segments.
scoreWithheldtrue when the report withholds its number for a sample this small (under 150 words, 20 segments or 10 distinct strings); qualityScore still carries it. Absent otherwise.
emptyFiletrue when the file was read but held no translatable segments (an empty export, or the wrong file). status is then failed and detail says why.
errorsCountThe number of findings.
disputedCountRule findings the AI disputed. They stay in errors and count in qualityScore as disputeScoring says. Absent when there were none.
disputeScoringhalf or exclude: the workspace's setting for disputed findings. half counts each at half its weight; exclude leaves it out of the score.
verdictschecked, agree, dispute and unsure: counts of the AI's verdicts on the rule findings. Present only when that pass ran.
aiStatusok, partial, failed, pending, skipped or disabled. pending means the rule pass answered before a queued AI review ran; poll again.
aiModecritical or full: the scope the AI pass used. Present only when the AI ran. Two results compare only when this matches.
aiReviewedErrorSegmentsSegments that carried a rule error and were read by the AI review too. A segment a rule flagged always goes to the AI review, so this is there whenever the AI ran.
aiErrorSegmentsNotReviewedResults stored before 15 October 2026 only, and never beside the field above: segments the AI was not shown because a rule had flagged them. Their meaning is unchecked, and aiStatus ok does not clear them. Runs from before 15 October 2026 did not send these segments to the AI review. New runs always do.
modelsWhat produced the result: the main and second models, so you never need the HTML report to know which models ran. consensusRequired is always false.
consensusRetired on 30 September 2026 (it counted a second model confirming the AI's own findings); its counts are always 0.
cached, aiCachecached is true when any AI answer came from an identical earlier prompt, with the count in aiCache.hits. aiCache.enabled says whether that memory was consulted at all, so noCache can be seen to reach the engine even on a run with no AI. Together they tell two identical results apart as a deterministic answer or a remembered one.
fallbacknull unless the configured model did not answer. all: true means it answered nothing, and aiStatus is then failed even though findings came back, because they are another model's.

models, cached, aiCache and fallback are present on every answer, a rule-only one included, so an absent field never has to be read.

Runs and reports​

Everything a reviewer does with a report in the panel, over the API: run a file into a report, find runs, read and download a report, decide on its findings, share it and delete it. A key stands for the workspace, so it sees every run a reviewer sees. A report is signed off by a person in the panel; a key can read the sign-off but never make one.

POST /qa-adapter/v1/runs​

Run one bilingual file as Run a check does and keep the report (qa:write). The run appears in Reports, shown as API, with its HTML, Excel and CSV files. The form fields are those of /check, plus an optional reviewer. An optional secondOpinion set to true turns the AI check of rule findings on for this run and false turns it off. Left out, the workspace setting decides: on unless the workspace turned it off. It answers at once with the run id; the AI review and the AI check of rule findings when it is on run in the background, so poll the report until status is no longer ai_running or subscribe to run.completed. The word count and the plan's allowances apply as in the panel. Use it when you want the report itself; /check stays the lighter call that answers findings only.

curl -X POST https://app.stillneedsahuman.com/qa-adapter/v1/runs \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "file=@job_en_tr.sdlxliff" \
-F "profile=acme_tr"
{ "runId": "a1b2c3d4e5f6", "status": "ai_running", "profile": "generic",
"aiRunning": true, "aiReviewRunning": true, "secondOpinionRunning": false,
"aiOffReason": null, "aiOffCode": null, "wordAllowanceExceeded": false,
"links": { "report": "/qa-adapter/v1/reports/a1b2c3d4e5f6",
"download": { "html": ".../download?format=html", "xlsx": "...",
"csv": "...", "json": "...", "junit": "...",
"sarif": "..." } } }

aiReviewRunning and secondOpinionRunning say which background pass is still going. aiOffReason and aiOffCode say why the AI did not run when it did not, and wordAllowanceExceeded is true when the month's allowance was used up.

GET /qa-adapter/v1/reports​

The workspace's runs, newest first, with the Reports page's own filters (qa:read): q (file name, job number, account or run id), profile, account, source (panel, api, dropbox or extension), state (unsigned, signed, failed or blocked), findings (critical, ai_found or ai_confirmed), createdBy, dateFrom and dateTo (YYYY-MM-DD), sort and dir, limit (up to 200) and offset. Each row carries the result, score, words, state and sign-off. Answers {reports, total, limit, offset}.

QueryMeaning
qA case-insensitive search in the file name, job number, account or run id.
profileThe runs of one profile, by its exact name.
stateblocked is an unsigned run with an open critical.
createdByWho ran it: a member's email, or api:<key prefix> for a run started with a key.
dateFrom, dateToBoth days included, in UTC.
sort, dirA column (when, the default, file, profile, who, findings, errors, warnings, words, score or trigger) and asc or desc.
limit, offsetlimit is 50 when left out, at most 200.

A run another workspace shared with yours is in the list with "shared": true and shared_from.

{ "reports": [ { "id": "a1b2c3d4e5f6", "created_at": "2026-09-17T18:04:11+00:00",
"source": "panel", "original_name": "job.xliff", "project": "job.xliff",
"profile": "acme_ru", "status": "REVIEW", "state": "unsigned",
"verdict": "review", "segments": 89, "words": 1240, "score_value": 96.4,
"errors": 0, "warnings": 4, "infos": 1, "ai_findings": 3,
"ai_confirmed": 2, "open_criticals": 0, "created_by": "[email protected]",
"signed_by": null, "signed_outcome": null, "signed_at": null } ],
"total": 812, "limit": 50, "offset": 0 }

GET /qa-adapter/v1/reports/{runId}​

One report: its stored JSON (the summary, the findings with their error_id and the AI disclosures, as the panel's report view reads it) in report, where a finding about several segments (a file-wide check or the whole-file review) also carries segments (every segment to fix, segment_id first) and reference_segments, and a whole-file review finding found_by: "file_review", and beside it the status, fileName, score, openCriticals, the reviewers' decisions (findingStatuses), the sign-off (signOff, null while unsigned) and the links to its files. While the AI review runs, status is ai_running and report holds the rule findings so far. A run of another workspace is a 404.

GET /qa-adapter/v1/reports/{runId}/download?format=xlsx​

A report file, byte for byte what Download gives in the panel (qa:read): html (the default: the report as the app shows it, one read-only file with the report data inside, which opens offline), xlsx, csv, json, junit (JUnit XML, one test case per finding) or sarif (SARIF 2.1.0, one result per finding). Another format is a 400. For junit, fail_on (critical, the default, major or minor) says which findings count as failures. The two CI formats, and recipes for GitHub Actions and GitLab CI, are in CI pipelines.

curl -o report.xlsx "https://app.stillneedsahuman.com/qa-adapter/v1/reports/a1b2c3d4e5f6/download?format=xlsx" \
-H "Authorization: Bearer YOUR_TOKEN"

GET /qa-adapter/v1/reports/{runId}/corrected​

The corrected copy (qa:read, the Solo plan and up): the file the run was checked from with the edits made in the report written into it, byte for byte what the report's Download, Corrected file gives. POST /qa-adapter/v1/reports/{runId}/corrected answers the same. Only your workspace's own runs; a run of another workspace or one shared into yours is a 404. The copy is always a new file: your original is never changed. A software string file has a corrected copy too: a gettext .po file, or a zip with the source and the translated .json, .resx, .strings, .stringsdict or .properties file, comes back with only the changed values written; a pair checked as two separate files is a 409 unsupported_format, so check the pair as a zip.

The header X-WW-Change-Report says what was written: written=<n>; skipped=<m>, then the skip reasons with their counts when there are any, then output_sha256=<hex>. With preview=1 the answer is JSON instead, what would be written: will_write, skipped (each segment_id and reason) and output_sha256; for a package each skipped row also names its file and members gives the counts per file inside the package. For a single SDLXLIFF, track_changes=1 gives the copy with each edit as a Trados tracked change, named name.corrected.tracked.sdlxliff; for another file type it is a 409. A 409 with a code says why no copy can be made: unsupported_format (a file type without a writer), source_missing (the stored original has expired), nothing_to_write, file_changed or not_preserved.

curl -s "https://app.stillneedsahuman.com/qa-adapter/v1/reports/a1b2c3d4e5f6/corrected?preview=1" \
-H "Authorization: Bearer YOUR_TOKEN"
# -> {"will_write":2,"skipped":[{"segment_id":"14","reason":"locked"}],"output_sha256":"4f1c..."}
curl -o corrected.xlf "https://app.stillneedsahuman.com/qa-adapter/v1/reports/a1b2c3d4e5f6/corrected" \
-H "Authorization: Bearer YOUR_TOKEN"

POST /qa-adapter/v1/reports/{runId}/findings​

Mark findings fixed, ignored (a false positive) or open again (qa:write). Body: status, findingIds (each finding's error_id) and an optional note. One id is the single action, so an error-level finding can be changed. Several ids are the bulk action, which leaves error-level findings as they are and lists them in skippedIds, because clearing them in one call could open the sign-off gate. The note is for a single finding. A signed report is locked (409). Only your workspace's own runs: a shared run is read-only.

curl -X POST https://app.stillneedsahuman.com/qa-adapter/v1/reports/a1b2c3d4e5f6/findings \
-H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
-d '{"status":"ignored","findingIds":["9f2c4e1a7b3d5e60"],"note":"brand name"}'
# -> {"ok":true,"status":"ignored","changed":1,"skippedErrors":0,
# "changedIds":["9f2c4e1a7b3d5e60"],"skippedIds":[],"summary":{...}}

POST /qa-adapter/v1/reports/{runId}/share-link​

A view-only link anyone can open without signing in for 30 days, as Share makes it (qa:write): {"ok": true, "url": "https://.../api/report/{runId}?k=...&e=..."}. DELETE /qa-adapter/v1/reports/{runId}/share-link revokes it for good and answers {"revoked": true}; a revoked report cannot be shared again, so a new link is then a 409.

DELETE /qa-adapter/v1/reports/{runId}​

Delete a report for good, as the panel's Delete does: the run, its files, its finding decisions and its stored copy (qa:delete, which a key holds only when a workspace admin ticked it). The words it used stay counted for the month. Answers {"ok": true, "deleted": "{runId}", "counts": {...}}; a run that is not your workspace's is a 404.

GET /qa-adapter/v1/profiles​

The profiles this workspace may run (qa:read), with the name, description, account, division, language pair and whether the AI review is on, so a caller can pick one for profile. Read-only: profiles are made and edited in the panel. aiChecks is false when the workspace turned AI checks off in Settings, AI review. Every check then runs the rule checks only. No run field such as enableAi or secondOpinion turns the AI back on.

A profile another workspace shared with yours is listed too, with "shared": true and sharedFrom naming that workspace. Name it on a check like any other: it runs with its owner's terms and rules, while the run and its report stay in your workspace. It cannot be edited, and it leaves the list the moment the share is revoked.

GET /qa-adapter/v1/profiles/{name} and GET /qa-adapter/v1/usage​

The first reads one profile's full settings, as the editor shows them (its rules and severities, glossary and term lists, AI review, scope and owner) in {"profile": {...}}; add ?sharedFrom= with the owner workspace's id for a shared profile. An unknown name is a 404. The guidance lines that come with the language's default profile are in the answer only for a key that also holds qa:write; a check still uses them either way. The second gives the month's runs, words in scope and in total, AI words and the plan's allowances; add ?month=YYYY-MM for another month (UTC, this month when left out). Both need qa:read. ai_words is what counts against the allowance. ai_words_by_stage gives the source words each AI pass sent that month, with every pass present: review, confirm (the confirm lane), rule_check (the AI check of rule findings), second_opinion, spelling, fix_completion, file_review and transcreation. Only the review counts today, so the passes do not add up to ai_words.

{ "month": "2026-10", "tenant_id": "acme", "runs": 12,
"words_in_scope": 41200, "words_total": 58000, "ai_words": 18400,
"ai_words_by_stage": { "review": 18400, "confirm": 0, "rule_check": 2100,
"second_opinion": 0, "spelling": 900, "fix_completion": 0,
"file_review": 0, "transcreation": 0 }, ... }

Compare two versions​

Diff two versions of the same bilingual file into a stored change report: which target segments were edited, added or removed between an earlier and an edited version. CPU only, no AI, so it returns the completed summary in one call, then fetch the full diff. Base path /qa-adapter/v1.

POST /qa-adapter/v1/compare​

Enqueue a comparison. Send either a multipart fileA (the earlier version) + fileB (the edited one) or a JSON fileAUrl + fileBUrl (the server fetches them from allow-listed hosts only and with a size cap; a private address is refused). Bearer qa:write. An Idempotency-Key header replays the same report: a replay answers the same compareId with "idempotent": true. Fires the compare.completed webhook. The skip flags go in as form fields or JSON keys: skipExactMatches covers 100 percent matches and with them context, ICE and perfect matches, skipContextMatches only the context ones (101 percent). The number they drop is summary.excluded.

With files (multipart)​

curl -X POST https://app.stillneedsahuman.com/qa-adapter/v1/compare \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "name=PRJ-1042 round 2" \
-F "skipLocked=true"

With URLs (JSON)​

curl -X POST https://app.stillneedsahuman.com/qa-adapter/v1/compare \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: PRJ-1042-cmp-1" \
-d '{
"name": "PRJ-1042 round 2",
"fileAUrl": "https://files.example.com/PRJ-1042/v1.xliff",
"fileBUrl": "https://files.example.com/PRJ-1042/v2.xliff",
"skipLocked": true
}'
{
"compareId": "cmp_7d1a...",
"status": "completed",
"summary": {
"compared": 58, "changed": 12, "added": 3, "removed": 1,
"unchanged": 42, "changed_locked": 2, "excluded": 40, "pct_touched": 27.6,
"avg_edit_ratio": 0.214
}
}
FieldNotes
fileA+fileB or fileAUrl+fileBUrlOne pair is required. Both versions of the SAME bilingual file (xliff family, or a spreadsheet with source and target columns). A container package (.wsxz / .zip) or an unreadable file returns 400.
skipExactMatches, skipContextMatches, skipLockedLeave UNCHANGED 100 percent, ICE or locked segments out of the report. Each is separate; left out while skipExactMatches is true, skipContextMatches follows it, as before. An edited frozen segment is still shown, counted as changed_locked.
nameReport title. Optional.
changed_locked (response)Frozen segments (100 percent / ICE / locked) that were edited anyway, the highest-risk change.
excluded (response)Untouched frozen segments dropped by the skip flags.
summary (response)summary.compared (segments matched in both versions), summary.changed, summary.added, summary.removed, summary.unchanged, summary.changed_locked, summary.excluded, summary.pct_touched (changed over compared, as a percentage) and summary.avg_edit_ratio (the edit rate: the average share of words edited in a changed segment, 0 to 1).
Header Idempotency-KeySafe retries: the same key returns the original report instead of diffing again.

GET /qa-adapter/v1/compare/{id}​

Fetch a stored report (Bearer qa:read). JSON by default (meta, summary and the per-segment rows); ?format=csv, xlsx or html returns a download instead. The CSV opens with five summary lines (Compared, Changed, Touched %, Edit rate, Changed locked, the two rates as percentages with one decimal), an empty line, then the header and one row per segment with its edit_ratio; the Excel file has a Summary sheet first with the same lines plus the two file names and the alignment confidence, then the Changes sheet. A foreign id returns 404.

{ "compareId": "cmp_7d1a...", "status": "completed",
"summary": { ... },
"report": { "meta": { ... }, "summary": { ... }, "rows": [ ... ] } }
curl "https://app.stillneedsahuman.com/qa-adapter/v1/compare/cmp_7d1a...?format=xlsx" \
-H "Authorization: Bearer YOUR_TOKEN" -o change_report.xlsx
note

The compare.completed webhook carries only the id and summary, never the diff itself; pull the full report from this endpoint with your own Bearer (see Status codes and webhooks).

Audit trail​

Every workspace keeps a record of who did what and when: who signed in, who changed a role or a key, who exported a report or downloaded a corrected copy, who decided on a finding, who changed an LQA finding, raised or resolved a dispute, finalized, reopened or exported a scorecard. Each row names the person (their address as it was at the time, api:<key prefix> for a key, system for the nightly retention sweep or platform operator for a retention change we made on your written request), the action, what it acted on and the fields that changed, as short values such as a status, a severity, a role or a format. A row never holds the text of your segments, a comment, a suggestion, a note or the reason of a dispute. The rows of a workspace form a hash chain, so a trail that was cut or edited fails the check below. Nothing is written for a request that was refused, and the trail never makes an action fail.

The three routes are for a workspace admin signed in to the panel; an API key cannot read the trail yet. A deleted workspace's trail is deleted with it, and when a person deletes their account their address in the trail becomes a fingerprint that still verifies.

GET /api/audit​

The trail, newest first: {rows, head, enabled}. Filters: action, target_kind, target_id, actor, since and until (a date, YYYY-MM-DD, or an ISO 8601 time, in UTC), limit (200 when left out, at most 1000) and before_id (the smallest id of the page before, to read further back).

GET /api/audit.csv​

The trail as a CSV file, oldest first, for since and until as above. The columns are id, at, actor, action, target_kind, target_id, before, after, reason, ip_hash, prev_hash and hash. Times are ISO 8601 in UTC; ip_hash is a fingerprint of the address the request came from, never the address itself.

GET /api/audit/verify​

Walks the chain from the first row: {ok, rows, broken_at, reason, head}. ok is false when a row was edited (reason hash), removed (link) or the last rows were cut (head), and broken_at names the first row that fails.

The actions​

ActionWhat it acts onWhat the row carries
login.ok, login.failed, logoutuser, the addressThe fingerprint of the request's address only
user.invite, user.role_change, user.removeuser, the addressrole
api_key.create, api_key.revokeapi_key, the key idname, scopes
webhook.create, webhook.deletewebhook, the hook idevents, url_host (the host only)
share.create, share.revokeshare, the share idkind, object_id, the receiving workspace
report.exportrun, the run idformat (html, xlsx, xlsx_full, csv, json or edits_xlsx), via
report.corrected_copyrun, the run idformat (the file type), written, skipped, track_changes
report.corrected_sendrun, the run idchannel (dropbox, email, crowdin or smartling) and the counts
report.signoff, report.signoff_deleterun, the run idstate, outcome
finding.decisionfinding, run id:finding idoutcome (open, ignored or fixed)
lqa.finding_update, lqa.finding_addlqa_finding, the finding idstatus, severity, category
lqa.dispute_raise, lqa.dispute_resolvelqa_dispute, the dispute idfinding_id, original_severity for a raise; resolution, new_severity for a resolve
lqa.finalize, lqa.reopenlqa_job, the job idscore, verdict for a finalize; status
lqa.exportlqa_job, the job id, or - for the CSV exportformat (xlsx or csv); what and with_text for the CSV
lqa.template_save, lqa.template_deletelqa_template, the template idversion
lqa.policy_setlqa_policy, -The policy fields that changed
lqa.template_rules_setlqa_rules, -rules, the number of rules
retention.setworkspace, the workspace idretention_days; a change we make on your written request names platform operator as the actor
retention.sweepworkspace, the workspace idretention_days, reports, scorecards, failed, remaining and dry_run; the actor is system
file_keep.setworkspace, the workspace idfile_keep_days; we set it on the keep we agreed with your workspace, so the actor is platform operator
quality.exporttrend, the window (from..to)weeks, rows and whether a profile, account or language filter was set; the Quality over time CSV

Retention​

A workspace admin sets how many days the workspace keeps its QA reports and finalized LQA scorecards. Every night a sweep deletes the reports made and the scorecards finalized before that, with what is theirs: the report files, the decisions on its findings, the review editor's edits and kept original and the sign-offs; a scorecard goes with its findings, items, disputes, assignments and calibration rows. Unset, the default, keeps everything until someone deletes it. The audit trail is not covered by the setting, uploaded files keep their 24 hour rule (a workspace can agree a longer keep with us, which we set per workspace), open scorecards are never swept and bake-off records are not covered yet.

In the panel the setting is the Retention card under Settings, Security and data: an admin changes it there and a PM sees it read only. The two routes are for a member signed in to the panel; an API key cannot reach them.

GET /api/workspace/retention​

A PM or an admin reads the setting: {retention_days, min_days, max_days, nightly_cap, exempt, would_delete, next_sweep_at, last_sweep}. retention_days is null when unset. would_delete is null when unset, else {reports, reports_eligible, scorecards, cutoff} with the setting as it is now: reports is what the next sweep deletes, at most nightly_cap (2,000). reports_eligible is every report older than the cutoff, so a large backlog takes several nights. next_sweep_at is when the next sweep runs (04:00 UTC each night), in ISO 8601. last_sweep holds the counts of the newest sweep and its time at, or null before the first.

PUT /api/workspace/retention​

An admin sets it with {"retention_days": 90} or clears it with {"retention_days": null}. The number is a whole number of days from 30 to 3650; anything else, an empty string included, is a 422 with the reason. The change takes effect at the next nightly sweep, never at once, so read would_delete in the answer before the night. The answer is the same as the GET's, and the change writes one retention.set row to the audit trail. A change we make on your written request writes one too, with platform operator as the actor; sending the same value again writes none. Each sweep writes one retention.sweep row per workspace with a setting, even when nothing was due. A sweep deletes at most 2,000 reports a night and leaves the rest for the nights after. remaining in the row says how many are left. A report whose files could not be removed is kept whole for the next night and counted in failed.