Skip to main content

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:

CodeMeaning
200 / 202OK, or accepted. A check, job or run may still be pending / queued; poll it until it settles.
400Bad 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.
401The Bearer token is missing or invalid.
403The 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.
404No object with that id in your workspace. Ids never cross workspaces, so another workspace's id also returns 404.
409An idempotency-key conflict, a second call with the same key while the first is still running.
413Too 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.
422A malformed body, for example an LQA template that does not validate; the answer carries the reasons.
429A 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.
503The result store is briefly unavailable. Safe to retry.
504The 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.

EndpointWhat it does
GET /api/admin/webhooksThe 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}/testSend one signed ping event now and answer with the delivery result (one attempt, no retry).
GET /api/admin/webhooks/{id}/deliveriesThe 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:

HeaderValue
X-WW-EventThe event name, for example qa.completed.
X-WW-Event-IdA unique id for this delivery, for example evt_....
X-WW-TimestampUnix seconds when the delivery was signed.
X-WW-TenantYour workspace id.
X-WW-Signaturev1,<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.

EventFires when
qa.completedA QA check via POST /qa-adapter/v1/check reaches completed or failed.
lqa.finalizedAn LQA job is submitted and scored.
eval.finalizedAn Eval run finishes with a ranking.
compare.completedA change report via POST /qa-adapter/v1/compare finishes.
run.completedAny 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.