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
| Field | Type | Notes |
|---|---|---|
file | file | Required. 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. |
sourceLang | string | Left out, the profile's own, else what the file declares. |
targetLang | string | Left out, the profile's own, else what the file declares or a confident detection from the text. |
projectRef | string | Optional. Your reference, stored with the run. |
profile | string | Optional. Runs this profile and skips the matching above. |
account, division | string | Optional. Used to match the profile. |
enableAi | true or false | Optional. 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. |
aiMode | string | Optional. 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. |
skipExactMatches | true or false | Optional. 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. |
skipContextMatches | true or false | Optional. 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. |
skipLocked | true or false | Optional. 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. |
spellcheck | true or false | Optional. Turns the AI-assisted spelling check on. |
localeFormat | true or false | Optional. Turns the locale format check on, as a warning. |
noCache | true or false | Optional. 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
| Field | Notes |
|---|---|
category / subcategory | Accuracy, 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. |
severity | critical, 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. |
penaltyPoints | What 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). |
aiVerdict | agree, 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. |
secondOpinion | true 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. |
errorId | Stable id, so you can track ignore or fixed status across polls. |
qualityScore | 0 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. |
skipped | What 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. |
scoreWithheld | true 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. |
emptyFile | true 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. |
errorsCount | The number of findings. |
disputedCount | Rule findings the AI disputed. They stay in errors and count in qualityScore as disputeScoring says. Absent when there were none. |
disputeScoring | half or exclude: the workspace's setting for disputed findings. half counts each at half its weight; exclude leaves it out of the score. |
verdicts | checked, agree, dispute and unsure: counts of the AI's verdicts on the rule findings. Present only when that pass ran. |
aiStatus | ok, partial, failed, pending, skipped or disabled. pending means the rule pass answered before a queued AI review ran; poll again. |
aiMode | critical or full: the scope the AI pass used. Present only when the AI ran. Two results compare only when this matches. |
aiReviewedErrorSegments | Segments 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. |
aiErrorSegmentsNotReviewed | Results 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. |
models | What produced the result: the main and second models, so you never need the HTML report to know which models ran. consensusRequired is always false. |
consensus | Retired on 30 September 2026 (it counted a second model confirming the AI's own findings); its counts are always 0. |
cached, aiCache | cached 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. |
fallback | null 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}.
| Query | Meaning |
|---|---|
q | A case-insensitive search in the file name, job number, account or run id. |
profile | The runs of one profile, by its exact name. |
state | blocked is an unsigned run with an open critical. |
createdBy | Who ran it: a member's email, or api:<key prefix> for a run started with a key. |
dateFrom, dateTo | Both days included, in UTC. |
sort, dir | A column (when, the default, file, profile, who, findings, errors, warnings, words, score or trigger) and asc or desc. |
limit, offset | limit 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,
"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
}
}
| Field | Notes |
|---|---|
fileA+fileB or fileAUrl+fileBUrl | One 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, skipLocked | Leave 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. |
name | Report 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-Key | Safe 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
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
| Action | What it acts on | What the row carries |
|---|---|---|
login.ok, login.failed, logout | user, the address | The fingerprint of the request's address only |
user.invite, user.role_change, user.remove | user, the address | role |
api_key.create, api_key.revoke | api_key, the key id | name, scopes |
webhook.create, webhook.delete | webhook, the hook id | events, url_host (the host only) |
share.create, share.revoke | share, the share id | kind, object_id, the receiving workspace |
report.export | run, the run id | format (html, xlsx, xlsx_full, csv, json or edits_xlsx), via |
report.corrected_copy | run, the run id | format (the file type), written, skipped, track_changes |
report.corrected_send | run, the run id | channel (dropbox, email, crowdin or smartling) and the counts |
report.signoff, report.signoff_delete | run, the run id | state, outcome |
finding.decision | finding, run id:finding id | outcome (open, ignored or fixed) |
lqa.finding_update, lqa.finding_add | lqa_finding, the finding id | status, severity, category |
lqa.dispute_raise, lqa.dispute_resolve | lqa_dispute, the dispute id | finding_id, original_severity for a raise; resolution, new_severity for a resolve |
lqa.finalize, lqa.reopen | lqa_job, the job id | score, verdict for a finalize; status |
lqa.export | lqa_job, the job id, or - for the CSV export | format (xlsx or csv); what and with_text for the CSV |
lqa.template_save, lqa.template_delete | lqa_template, the template id | version |
lqa.policy_set | lqa_policy, - | The policy fields that changed |
lqa.template_rules_set | lqa_rules, - | rules, the number of rules |
retention.set | workspace, the workspace id | retention_days; a change we make on your written request names platform operator as the actor |
retention.sweep | workspace, the workspace id | retention_days, reports, scorecards, failed, remaining and dry_run; the actor is system |
file_keep.set | workspace, the workspace id | file_keep_days; we set it on the keep we agreed with your workspace, so the actor is platform operator |
quality.export | trend, 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.