Status codes and webhooks
The API reference lists every endpoint with its request and response schema, rendered from the OpenAPI document; this page explains how the calls behave.
Status codes
On an error every endpoint returns a JSON body with a detail string that names the cause. The codes:
| Code | Meaning |
|---|---|
200 / 202 | OK, or accepted. A check, job or run may still be pending / queued; poll it until it settles. |
400 | Bad input: a missing field, malformed JSON or multipart, an unreadable or unsupported file or a container package (.wsxz / .zip) where a single bilingual file is expected. The detail says which. |
401 | The Bearer token is missing or invalid. |
403 | The key lacks the scope this call needs (they do not imply one another, so qa:write does not open qa:read); or the workspace is suspended; or the call is a write that the workspace's plan or modules do not allow. That covers a view-only plan, a trial that has ended and a module the workspace does not have. Reads are not refused for a view-only or expired plan, and QA reads are not refused for a missing module. |
404 | No object with that id in your workspace. Ids never cross workspaces, so another workspace's id also returns 404. |
409 | An idempotency-key conflict, a second call with the same key while the first is still running. |
413 | Too large: a QA upload over 200 MB, a check-segments body over its caps, or an LQA upload over 64 MB or 50,000 segments. |
422 | A malformed body, for example an LQA template that does not validate; the answer carries the reasons. |
429 | A rate limit or a workspace ceiling, or a generate cap on Eval. The response says which, and carries Retry-After in seconds. Back off and retry; it is not a failed check. See Rates and ceilings. |
503 | The result store is briefly unavailable. Safe to retry. |
504 | The analytics query took longer than 15 seconds. Add a filter or shorten the date range. Only GET /lqa/v1/analytics/v2 and the two CSV exports answer it, and a CSV export only before its first row. |
Webhooks
Instead of polling, register a webhook and receive a signed POST when work completes. Webhooks are available on Team and above.
Register
Register a webhook over the API with a key that holds webhooks:manage: POST /api/admin/webhooks with an https url, a secret you choose and the events to receive. The answer is the webhook with its id; the secret is never returned. The platform operator can also add one for you in Settings › Developer.
curl -X POST https://app.stillneedsahuman.com/api/admin/webhooks \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url":"https://your-tms.example.com/hooks/ww",
"secret":"whsec_your_shared_secret",
"events":["run.completed","qa.completed","lqa.finalized","eval.finalized","compare.completed"]}'
Manage
The same scope manages the workspace's webhooks. A webhook id of another workspace is a 404.
| Endpoint | What it does |
|---|---|
GET /api/admin/webhooks | The workspace's webhooks (never the secret) and the list of events. |
PATCH /api/admin/webhooks/{id} | Change url, secret, events or active. A new URL is checked as on registration. |
DELETE /api/admin/webhooks/{id} | Delete the webhook and its delivery log. |
POST /api/admin/webhooks/{id}/test | Send one signed ping event now and answer with the delivery result (one attempt, no retry). |
GET /api/admin/webhooks/{id}/deliveries | The webhook's recent deliveries, newest first: status, response code and attempts. |
Delivery and signature
Each delivery is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
X-WW-Event | The event name, for example qa.completed. |
X-WW-Event-Id | A unique id for this delivery, for example evt_.... |
X-WW-Timestamp | Unix seconds when the delivery was signed. |
X-WW-Tenant | Your workspace id. |
X-WW-Signature | v1,<hex>, an HMAC-SHA256 of the event id, the timestamp and the raw body. |
Webhooks are not signed like the finalize callback. Each delivery carries X-WW-Event-Id, X-WW-Timestamp and X-WW-Signature: v1,<hex>, where the hex is HMAC-SHA256 with your webhook secret over the event id, a dot, the timestamp, a dot and then the raw request body. Compare in constant time and reject a stale timestamp.
import hmac, hashlib
def verify(raw_body: bytes, headers, secret: str) -> bool:
signed = (headers["X-WW-Event-Id"] + "." + headers["X-WW-Timestamp"] + ".").encode() + raw_body
expected = "v1," + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, headers["X-WW-Signature"])
The same check in Node:
const crypto = require("crypto");
function verify(secret, headers, rawBody) {
const eventId = headers["x-ww-event-id"];
const ts = headers["x-ww-timestamp"];
const signed = Buffer.concat([Buffer.from(`${eventId}.${ts}.`), rawBody]);
const expected = "v1," + crypto.createHmac("sha256", secret).update(signed).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(headers["x-ww-signature"] || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Retries. A delivery is retried up to 3 times with a growing wait on a network error, a 5xx or any other answer outside 2xx. Answer with a 2xx to acknowledge it. The target must be a public https host: private, loopback and link-local addresses are refused.
Events
The payload is intentionally lean (ids plus a summary, never the full content); pull the complete result from the matching GET endpoint with your own Bearer.
| Event | Fires when |
|---|---|
qa.completed | A QA check via POST /qa-adapter/v1/check reaches completed or failed. |
lqa.finalized | An LQA job is submitted and scored. |
eval.finalized | An Eval run finishes with a ranking. |
compare.completed | A change report via POST /qa-adapter/v1/compare finishes. |
run.completed | Any QA run reaches its final state, after its AI review and the AI check of rule findings: from the panel, POST /qa-adapter/v1/runs, Dropbox, a connector or the extension. Carries the run id, file name, result, score, counts and links. Delivered at least once, so a repeat has the same runId. |
A run.completed payload names the run and links to it; read the findings from links.report with your qa:read key.
{ "event": "run.completed", "runId": "a1b2c3d4e5f6",
"createdAt": "2026-10-13T10:04:11+00:00", "source": "watcher",
"fileName": "job.xliff", "profile": "generic", "status": "REVIEW",
"verdict": "review", "result": "Needs review", "score": 96.4,
"errors": 0, "warnings": 4, "infos": 1,
"aiFindings": 3, "openCriticals": 0,
"links": { "report": "/qa-adapter/v1/reports/a1b2c3d4e5f6", "download": { ... } },
"tenant": "acme" }
A compare.completed payload is {event, compareId, status, summary, tenant} with no diff inside; fetch the report from GET /qa-adapter/v1/compare/{compareId}.
An lqa.finalized payload is {event, jobId, externalRef, score, verdict, scale, wordCount, byCategory, bySeverity, vendorId, translatorId, template, disputes, tenant}. byCategory and bySeverity cover the findings that count toward the score: byCategory maps each category code to {count, penalty} and bySeverity maps each severity code to a count. The codes come from the job's template and differ between templates, so do not hard-code them. disputes counts the job's disputes: raised, resolved and open (overdue ones included). Fetch the findings and the disputes from GET /lqa/v1/jobs/{jobId}.
{ "event": "lqa.finalized", "jobId": "lqa_2a9f...", "externalRef": "PO-9912",
"score": 98.2, "verdict": "pass", "scale": "points", "wordCount": 1500,
"byCategory": { "mistranslation": {"count": 1, "penalty": 5} },
"bySeverity": { "major": 1 },
"vendorId": "vendor-42", "translatorId": "ada",
"template": { "id": "mqm_core", "version": 1 },
"disputes": { "raised": 2, "resolved": 1, "open": 1 },
"tenant": "acme" }
An eval.finalized payload is {event, runId, status, account, targetLang, recommendation, candidates, tenant}; candidates lists each engine's name, rank, quality and verdict. Fetch the full comparison from GET /eval/v1/runs/{runId}/comparison.