Integration Manual
Public REST API and webhooks for integrators: auth, endpoints, webhook signing, the Go SDK, and failure modes. The code samples are live — point them at your own host and key in the console below.
Overview
The Localization Service exposes a REST API for programmatic translation: submit content, poll task status, receive webhooks, upload files, manage CMS configs, run static image localization (staticloc), and invoke published AI pre-edit modules.
Base URL
Public product host is https://krakenloc.com. Use the host only as the client base URL — do not append an API path suffix:
https://krakenloc.com
All API methods live under the prefix:
/internal/api/v1/...
Example full URL:
https://krakenloc.com/internal/api/v1/translate
Staging or private deployments may use a different host; keep the same path prefix.
Health check (no auth)
curl -sS "https://krakenloc.com/_health/live"
Wire format
- Content-Type:
application/json(except multipart file upload) - Encoding: UTF-8
- Field names: camelCase (
sourceLang,targetLangs,webhookUrl,taskId, …) - Enum / protocol values: often snake_case (
in_progress,human_review,context_request) - Dates: ISO 8601 where present
Official Go SDK
Module: github.com/simple-life-app/localizetion-service/sdk
go get github.com/simple-life-app/localizetion-service/sdk@latest
See Go SDK for install, methods, and examples.
Authentication
Every request to /internal/api/v1/* requires a department API key.
Scheme
Authorization: Bearer <api_key>
- Key format:
dk_…(department API key) - Middleware validates the Bearer token, attaches department/product context, and applies per-key rate limits
Do not send cookies for the external API plane. This is not the admin SSO surface.
Example
curl -sS -X POST "https://krakenloc.com/internal/api/v1/translate" \
-H "Authorization: Bearer dk_your_department_api_key" \
-H "Content-Type: application/json" \
-d '{
"department": "engineering",
"sourceLang": "en",
"targetLang": "ru",
"items": [{"type": "plain", "content": "Hello"}]
}'
Common auth errors
| Status | Meaning |
|---|---|
| 401 | Missing/invalid Authorization header, or expected format is not Bearer <api_key> |
| 401 | API key unknown, revoked, or inactive |
| 403 | Organization suspended or key not allowed for the requested scope |
Invalid format response body (illustrative):
{"error": "invalid authorization format, expected: Bearer <api_key>"}
Credentials
Where to create and manage secrets used by integrators.
Department API keys
- Admin UI: product-scoped API Keys page at
/api-keys - Keys look like
dk_… - Each key can have its own
RateLimitPerMinute - Keys are bound to a department (and product context in multi-product orgs)
Store keys in a secret manager. Never commit them to source control.
Webhook signing secrets
- Admin UI: department settings (Settings → Departments → department webhook secret)
- Secret format:
whsec_…(Standard Webhooks) - Rotation keeps the previous secret valid for a grace window so receivers can switch without downtime
- Permissions:
departments.webhook_secret.read/departments.webhook_secret.manage
Example secret (fake, for docs only):
whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
Webhook testing UI
- Path:
/developer/webhook-testing - Requires admin SSO + permission
- Can sign test deliveries with a department’s real secret (
"test": truein the payload)
Endpoints
All paths are relative to the host base URL. Full path = host + path below.
| Method | Path | Description |
|---|---|---|
GET |
/_health/live |
Liveness (no auth) |
POST |
/internal/api/v1/files/upload |
Upload a file; returns a URL/id for file_url items |
POST |
/internal/api/v1/cms/configs/upsert |
Upsert a CMS config from the external API |
POST |
/internal/api/v1/translate |
Submit translation work |
GET |
/internal/api/v1/tasks/:id |
Get task status / result |
GET |
/internal/api/v1/tasks/:id/segments |
List a task's translatable segments and their current/initial values |
GET |
/internal/api/v1/tasks/:id/segments/:key/versions |
Get one segment's full edit-history version chain |
GET |
/internal/api/v1/tags |
List tags (department + global) |
GET |
/internal/api/v1/language-pairs |
List language pairs available to the key’s department pipelines |
GET |
/internal/api/v1/languages |
List system languages |
POST |
/internal/api/v1/tasks/:id/context-response |
Provide context for a context_request webhook |
POST |
/internal/api/v1/staticloc/text-layout |
Start a text layout recognition task |
GET |
/internal/api/v1/staticloc/text-layout/tasks/:id |
Poll a text layout task |
POST |
/internal/api/v1/staticloc/text-erase |
Start a text erase task (image + optional mask) |
GET |
/internal/api/v1/staticloc/text-erase/tasks/:id |
Poll a text erase task |
GET |
/internal/api/v1/staticloc/tasks |
List staticloc tasks |
GET |
/internal/api/v1/staticloc/tasks/:id |
Get staticloc task |
GET |
/internal/api/v1/ai-modules |
List AI modules the key may invoke |
POST |
/internal/api/v1/ai-modules/:key/invoke |
Invoke a published AI module |
There is no separate “result” URL: completed translation data is returned on GET /internal/api/v1/tasks/:id and/or delivered via webhook.
Translate
POST /internal/api/v1/translate
Submit one or more content items for translation. Prefer targetLangs for multi-language; targetLang remains accepted for a single language.
When webhookUrl is set (or the pipeline is long-running), the HTTP response is often async (taskId + status: "queued"). Without a webhook, short jobs may return a sync body with items / meta (or multi-lang results).
Full request body (camelCase)
Synthetic example for a mobile checkout screen — shapes match the live DTO.
{
"department": "Product",
"sourceLang": "en",
"targetLangs": ["ru", "de", "fr"],
"webhookUrl": "https://hooks.acme-games.com/localization/kraken",
"type": "llm",
"items": [
{
"type": "plain",
"content": "Complete your purchase"
},
{
"type": "keyvalue",
"content": {
"checkout.title": "Checkout",
"checkout.pay": "Pay now",
"checkout.secure": "Secure payment",
"checkout.tax_note": "Tax included where applicable"
}
},
{
"type": "json",
"content": {
"banner": {
"headline": "Spring Sale",
"cta": { "label": "Shop now", "target": "/sale" }
},
"priority": 1
}
},
{
"type": "file_url",
"content": "https://cdn.krakenloc.com/files/doc/a1b2c3d4_release-notes.docx"
}
],
"existingTranslations": [
{
"language": "ru",
"items": [
{ "type": "plain", "content": "Завершите покупку" },
{
"type": "keyvalue",
"content": {
"checkout.title": "Оформление заказа",
"checkout.pay": "Оплатить"
}
},
{ "type": "json", "content": {} },
{ "type": "file_url", "content": "" }
]
}
],
"forceRetranslate": false,
"context": {
"message": "UI copy for mobile checkout. Keep CTAs short. Do not translate brand name Acme.",
"tags": ["checkout", "mobile", "release-2.4"],
"metadata": {
"ticket": "LOC-1842",
"appVersion": "2.4.0"
},
"imageUrls": [
"https://cdn.acme-games.com/design/checkout-v24.png"
]
},
"additionalInfo": {
"title": "Checkout strings · release 2.4",
"subtitle": "JIRA LOC-1842",
"description": "Keyvalue + banner JSON for iOS/Android checkout funnel",
"externalId": "1842",
"externalRef": "LOC-1842",
"icon": "shopping-cart",
"color": "#FF4D00",
"deadline": "2026-04-15T18:00:00+03:00",
"properties": {
"project": "mobile_app",
"branch": "release/2.4",
"priority": "high"
},
"tags": [
{ "label": "Production", "value": "prod", "type": "environment", "color": "green" },
{ "label": "P1", "value": "p1", "type": "priority", "color": "red" }
],
"links": [
{
"label": "Jira ticket",
"url": "https://jira.acme-games.com/browse/LOC-1842",
"type": "jira",
"primary": true
},
{
"label": "Figma",
"url": "https://figma.com/file/abc/checkout",
"type": "external"
}
]
}
}
Request context
| Field | Notes |
|---|---|
context.message |
Free-text brief passed to the linguist / pipeline |
context.tags |
Selects the pipeline — tag names must exist for your department (GET /internal/api/v1/tags). An unknown name fails the task with pipeline_not_found; a request without tags only matches pipelines that have no tags. Rules: Discovery → Tags |
context.metadata |
Opaque bag echoed back on the task and the webhook |
context.imageUrls / context.fileUrls |
Reference material for the translator |
additionalInfo.tags |
Free labels for the task card in the admin UI — not used for pipeline matching |
Item types
type |
content shape |
Notes |
|---|---|---|
plain |
string | Free-form UI / paragraph |
keyvalue |
object → string values | Keys are user data — never renamed by the wire layer |
json |
any JSON | String leaves translated; numbers/booleans/structure kept |
file_url |
string URL | From POST /files/upload |
Existing translations
- Array order of
existingTranslations[].itemsmust matchitems(same count / types) - Languages only in
existingTranslations(not intargetLangs) are passed through - For
keyvalue, existing may list a subset of keys; missing keys are translated forceRetranslate: trueignores existing values
Async response (queued)
HTTP 202 when the request is accepted asynchronously (typical when webhookUrl is set, or the job is long-running):
{
"taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"status": "queued",
"message": "Translation task queued for processing. Use GET /tasks/{id} to check status."
}
Poll GET /internal/api/v1/tasks/{taskId} or wait for the webhook.
Go SDK: Translate decodes both HTTP 200 and 202 into *TranslateResponse. On 202, Status is "queued" and TaskID / Message are set. Use resp.IsQueued() (Status == "queued") to branch:
resp, err := client.Translate(ctx, req)
if err != nil {
// handle error
}
if resp.IsQueued() {
// HTTP 202 — poll GetTask/WaitTask or wait for the webhook
taskID := resp.TaskID
_ = taskID
}
// else: sync 200 body with items/meta or multi-lang results
Sync response — single language
{
"taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"items": [
{ "type": "plain", "content": "Завершите покупку" },
{
"type": "keyvalue",
"content": {
"checkout.title": "Оформление заказа",
"checkout.pay": "Оплатить",
"checkout.secure": "Безопасная оплата",
"checkout.tax_note": "Налог включён, где применимо"
}
}
],
"meta": {
"sourceLang": "en",
"targetLang": "ru",
"itemsCount": 2,
"stringsCount": 5,
"processingTimeMs": 1840,
"tokensUsed": 312,
"partialSuccess": false,
"processingInfo": {
"pipeline": "product-ui",
"engine": "openai",
"model": "gpt-4o",
"schemaValidated": true
}
},
"metadata": {
"ticket": "LOC-1842"
}
}
Sync response — multi-language (results)
HTTP multi-lang uses the field results (not languageResults — that name is for webhooks).
{
"taskId": "parent-7c9e6679-7425-40de-944b-e07fc1f90ae7",
"taskIds": {
"ru": "a1111111-1111-4111-8111-111111111111",
"de": "a2222222-2222-4222-8222-222222222222",
"fr": "a3333333-3333-4333-8333-333333333333"
},
"results": {
"ru": {
"taskId": "a1111111-1111-4111-8111-111111111111",
"source": "mixed",
"keySources": {
"checkout.title": "provided",
"checkout.pay": "provided",
"checkout.secure": "translated",
"checkout.tax_note": "translated"
},
"items": [
{ "type": "plain", "content": "Завершите покупку" },
{
"type": "keyvalue",
"content": {
"checkout.title": "Оформление заказа",
"checkout.pay": "Оплатить",
"checkout.secure": "Безопасная оплата",
"checkout.tax_note": "Налог включён, где применимо"
}
}
],
"meta": {
"sourceLang": "en",
"targetLang": "ru",
"itemsCount": 2,
"stringsCount": 5,
"processingTimeMs": 1920
}
},
"de": {
"taskId": "a2222222-2222-4222-8222-222222222222",
"source": "translated",
"items": [
{ "type": "plain", "content": "Kauf abschließen" },
{
"type": "keyvalue",
"content": {
"checkout.title": "Zur Kasse",
"checkout.pay": "Jetzt bezahlen",
"checkout.secure": "Sichere Zahlung",
"checkout.tax_note": "Steuern ggf. inklusive"
}
}
],
"meta": {
"sourceLang": "en",
"targetLang": "de",
"itemsCount": 2,
"stringsCount": 5,
"processingTimeMs": 2010
}
}
},
"metadata": {
"ticket": "LOC-1842"
}
}
source on a language result: translated | provided | mixed.
curl — plain + webhook
curl -sS -X POST "https://krakenloc.com/internal/api/v1/translate" \
-H "Authorization: Bearer dk_your_department_api_key" \
-H "Content-Type: application/json" \
-d '{
"department": "Product",
"sourceLang": "en",
"targetLangs": ["ru", "de"],
"webhookUrl": "https://hooks.acme-games.com/localization/kraken",
"items": [
{ "type": "plain", "content": "Welcome back, commander." },
{
"type": "keyvalue",
"content": {
"home.play": "Play",
"home.settings": "Settings",
"home.shop": "Shop"
}
}
],
"additionalInfo": {
"title": "Home screen · EN→RU/DE",
"externalRef": "LOC-1901"
}
}'
CMS write-back
Pass cmsConfigId (from CMS upsert) so stored CMS content is reused and results write back into that config:
{
"department": "CMS - Configs",
"sourceLang": "en",
"targetLangs": ["ru", "de"],
"cmsConfigId": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
"items": [
{
"type": "keyvalue",
"content": {
"welcome.title": "Welcome",
"welcome.body": "Glad you are here"
}
}
]
}
Tasks
GET /internal/api/v1/tasks/:id
Poll translation task status and result. Prefer webhooks for production; use polling for scripts and debugging.
There is no separate /tasks/{id}/result URL — completed payload lives on this resource (resultData) and/or on the webhook.
Status values
| Status | Meaning |
|---|---|
new |
Accepted, not started |
in_progress |
Running |
human_review |
Needs human attention |
lqa |
Linguistic QA |
ready |
Successful terminal (with done) |
done |
Completed successfully |
rejected |
Rejected |
failed |
Failed |
canceled |
Canceled |
SDK helpers (Go):
- IsCompleted:
ready|done|rejected|failed|canceled - IsSuccess:
ready|done - IsFailed:
failed|rejected|canceled
isReadyToApply
Every response carries isReadyToApply: whether the task has progressed far enough for you
to apply its translation. Gate your write on this flag rather than on a status list of your own.
The bar is per-department and set by an admin (applyFromStatus on the department):
human_review by default — the first status in which a translation exists — or lqa, ready
or done when a team wants review, QA or delivery to finish first. A task that ended in
rejected, failed or canceled is never ready, whatever the threshold.
The flag is evaluated on every read, so it flips as the task advances — or as an admin changes the department's setting.
contextRequest
The response always carries an explicit contextRequest field (D10): the task's currently
active context request, or null when there is none — never omitted. A request belonging
to another org/product is also reported as null. See Context response for the
full context_request webhook + reply flow this is pulled from.
| Field | Notes |
|---|---|
id |
Context request id |
status |
e.g. pending |
message |
The question, when set |
targetKeys |
Keys the request is about — [] when none, never omitted |
createdAt |
RFC3339 |
curl
curl -sS "https://krakenloc.com/internal/api/v1/tasks/7c9e6679-7425-40de-944b-e07fc1f90ae7" \
-H "Authorization: Bearer dk_your_department_api_key"
Response — in progress
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"type": "translation",
"status": "in_progress",
"isReadyToApply": false,
"department": "Product",
"sourceLang": "en",
"targetLang": "ru",
"translationType": "llm",
"engineType": "llm",
"engineProvider": "openai",
"engineModel": "gpt-4o",
"itemsCount": 2,
"stringsCount": 5,
"sourceCharactersCount": 86,
"sourceWordsCount": 14,
"targetCharactersCount": 0,
"targetWordsCount": 0,
"tokensInput": 240,
"tokensOutput": 0,
"processingTimeMs": 0,
"contextRequest": {
"id": "c0ffee00-1111-4222-8333-444444444444",
"status": "pending",
"message": "What does the string \"Season Pass\" refer to?",
"targetKeys": ["shop.season_pass"],
"createdAt": "2026-04-10T09:12:05Z"
},
"requestData": {
"items": [
{ "type": "plain", "content": "Complete your purchase" },
{
"type": "keyvalue",
"content": {
"checkout.title": "Checkout",
"checkout.pay": "Pay now"
}
}
]
},
"createdAt": "2026-04-10T09:12:01Z",
"updatedAt": "2026-04-10T09:12:04Z",
"startedAt": "2026-04-10T09:12:03Z"
}
Response — done (with result)
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"type": "translation",
"status": "done",
"isReadyToApply": true,
"department": "Product",
"sourceLang": "en",
"targetLang": "ru",
"translationType": "llm",
"engineType": "llm",
"engineProvider": "openai",
"engineModel": "gpt-4o",
"itemsCount": 2,
"stringsCount": 5,
"sourceCharactersCount": 86,
"sourceWordsCount": 14,
"targetCharactersCount": 92,
"targetWordsCount": 13,
"tokensInput": 240,
"tokensOutput": 118,
"processingTimeMs": 1840,
"contextRequest": null,
"requestData": {
"department": "Product",
"sourceLang": "en",
"targetLangs": ["ru"],
"items": [
{ "type": "plain", "content": "Complete your purchase" },
{
"type": "keyvalue",
"content": {
"checkout.title": "Checkout",
"checkout.pay": "Pay now",
"checkout.secure": "Secure payment",
"checkout.tax_note": "Tax included where applicable"
}
}
]
},
"resultData": {
"items": [
{ "type": "plain", "content": "Завершите покупку" },
{
"type": "keyvalue",
"content": {
"checkout.title": "Оформление заказа",
"checkout.pay": "Оплатить",
"checkout.secure": "Безопасная оплата",
"checkout.tax_note": "Налог включён, где применимо"
}
}
],
"meta": {
"sourceLang": "en",
"targetLang": "ru",
"itemsCount": 2,
"stringsCount": 5,
"processingTimeMs": 1840
}
},
"sourceItemMetrics": [
{
"index": 0,
"type": "plain",
"words": 3,
"characters": 24,
"keys": 0
},
{
"index": 1,
"type": "keyvalue",
"words": 11,
"characters": 62,
"keys": 4,
"keyMetrics": [
{ "key": "checkout.title", "words": 1, "characters": 8 },
{ "key": "checkout.pay", "words": 2, "characters": 7 },
{ "key": "checkout.secure", "words": 2, "characters": 15 },
{ "key": "checkout.tax_note", "words": 6, "characters": 32 }
]
}
],
"targetItemMetrics": {
"ru": [
{
"index": 0,
"type": "plain",
"words": 2,
"characters": 17,
"keys": 0
},
{
"index": 1,
"type": "keyvalue",
"words": 10,
"characters": 70,
"keys": 4,
"keyMetrics": [
{ "key": "checkout.title", "words": 2, "characters": 18 },
{ "key": "checkout.pay", "words": 1, "characters": 8 },
{ "key": "checkout.secure", "words": 2, "characters": 18 },
{ "key": "checkout.tax_note", "words": 5, "characters": 26 }
]
}
]
},
"createdAt": "2026-04-10T09:12:01Z",
"updatedAt": "2026-04-10T09:12:08Z",
"startedAt": "2026-04-10T09:12:03Z",
"completedAt": "2026-04-10T09:12:08Z"
}
Response — failed
{
"id": "8d0f7780-8536-41ef-a55c-f18gd2g01bf8",
"type": "translation",
"status": "failed",
"isReadyToApply": false,
"department": "Product",
"sourceLang": "en",
"targetLang": "ja",
"errorMessage": "pipeline execution failed: engine timeout after 60s",
"itemsCount": 1,
"stringsCount": 1,
"processingTimeMs": 60120,
"requestData": {
"items": [{ "type": "plain", "content": "Season pass" }]
},
"createdAt": "2026-04-10T10:00:00Z",
"updatedAt": "2026-04-10T10:01:02Z",
"startedAt": "2026-04-10T10:00:01Z",
"completedAt": "2026-04-10T10:01:02Z"
}
Go SDK
// One-shot poll
task, err := client.GetTask(ctx, taskID)
// Block until terminal status
task, err = client.WaitTask(ctx, taskID, 2*time.Second)
if task.IsSuccess() {
// read task.ResultData
}
// Write it into your own system only when the department allows it
if task.IsReadyToApply {
// apply task.ResultData
}
Context response
When a linguist or pipeline needs more information, the service sends a webhook of type context_request. Your system answers with:
POST /internal/api/v1/tasks/:id/context-response
Inbound webhook (what you receive)
See Webhooks for headers and signing. Body shape:
{
"type": "context_request",
"taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"contextRequestId": "c0ffee00-1111-4222-8333-444444444444",
"message": "What does the string \"Season Pass\" refer to — a battle pass or a sports season ticket?",
"category": "terminology",
"targetKeys": ["shop.season_pass", "shop.season_pass.desc"],
"requestedBy": {
"name": "Anna Petrova",
"email": "[email protected]"
},
"taskInfo": {
"sourceLang": "en",
"targetLangs": ["ru", "de"],
"department": "Product"
},
"callbackUrl": "/api/v1/tasks/7c9e6679-7425-40de-944b-e07fc1f90ae7/context-response",
"test": false
}
callbackUrl is informational (path fragment). Prefer the authenticated external API call below with your department API key.
Reply request
{
"message": "Season Pass is a battle-pass style product in the in-game shop (not sports). Keep the English product name \"Season Pass\" untranslated in RU/DE UI; translate only the description.",
"fileUrls": [
"https://cdn.acme-games.com/docs/season-pass-brief.pdf"
],
"imageUrls": [
"https://cdn.acme-games.com/design/season-pass-store.png"
],
"tags": ["terminology", "brand-voice"],
"metadata": {
"contextRequestId": "c0ffee00-1111-4222-8333-444444444444",
"wiki": "https://wiki.acme-games.com/season-pass",
"decidedBy": "[email protected]"
}
}
| Field | Required | Notes |
|---|---|---|
message |
yes | Free-text context for the linguist / pipeline |
fileUrls |
no | Supporting documents |
imageUrls |
no | Screenshots / mockups |
tags |
no | Free labels on the answer — unrelated to context.tags on translate, never used for pipeline matching |
metadata |
no | Opaque bag — useful to echo contextRequestId |
Success response
{
"message": "Context response received successfully"
}
curl
curl -sS -X POST \
"https://krakenloc.com/internal/api/v1/tasks/7c9e6679-7425-40de-944b-e07fc1f90ae7/context-response" \
-H "Authorization: Bearer dk_your_department_api_key" \
-H "Content-Type: application/json" \
-d '{
"message": "Season Pass = in-game battle pass. Keep English product name.",
"imageUrls": ["https://cdn.acme-games.com/design/season-pass-store.png"],
"metadata": { "contextRequestId": "c0ffee00-1111-4222-8333-444444444444" }
}'
Go SDK
_, err = client.ProvideContext(ctx, wh.TaskID, &sdk.ProvideContextRequest{
Message: "Brand voice: friendly, short sentences. Do not translate product names.",
ImageURLs: []string{
"https://cdn.acme-games.com/style/screenshot.png",
},
Metadata: map[string]any{
"contextRequestId": wh.ContextRequestID,
"category": wh.Category,
},
Tags: []string{"brand-voice"},
})
Flow
- Receive signed
context_requestwebhook - Gather context on your side (CMS, wiki, design)
POST .../tasks/{taskId}/context-response- Pipeline continues; translation may re-run with new context
Pull alternative (no webhook receiver)
If you cannot run a webhook receiver, poll GET /internal/api/v1/tasks/:id instead: the
response's contextRequest field (D10) carries the same active context request — null
when there is none — so WaitTask/manual polling can detect a pending question without ever
subscribing to webhooks. Reply the same way, via POST .../tasks/{taskId}/context-response. See
Tasks for the field shape.
Files, CMS, Staticloc
File upload
POST /internal/api/v1/files/upload — multipart/form-data. Use the returned url as a file_url translation item (or store fileId on your side).
curl -sS -X POST "https://krakenloc.com/internal/api/v1/files/upload" \
-H "Authorization: Bearer dk_your_department_api_key" \
-F "file=@./release-notes-en.docx"
Response (201):
{
"files": [
{
"fileName": "release-notes-en.docx",
"fileType": "doc",
"url": "https://cdn.krakenloc.com/files/doc/9f3c2a1b_release-notes-en.docx",
"contentType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"size": 48213,
"fileId": "9f3c2a1b-4d5e-4f60-8a71-b2c3d4e5f607"
}
]
}
Then translate:
{
"department": "Product",
"sourceLang": "en",
"targetLangs": ["ru"],
"items": [
{
"type": "file_url",
"content": "https://cdn.krakenloc.com/files/doc/9f3c2a1b_release-notes-en.docx"
}
]
}
SDK: UploadFile, UploadFileFromReader.
CMS config upsert
POST /internal/api/v1/cms/configs/upsert — create/update a CMS config addressed by project + config slug. Pass returned configId as cmsConfigId on translate for reuse + write-back.
Request:
{
"project": "mobile-app",
"config": "onboarding",
"sourceLang": "en",
"targetLangs": ["ru", "de", "fr"],
"items": [
{ "key": "welcome.title", "value": "Welcome" },
{ "key": "welcome.body", "value": "Glad you are here" },
{ "key": "welcome.cta", "value": "Get started" },
{ "key": "permissions.camera", "value": "Allow camera access" }
]
}
Response:
{
"configId": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
"added": 2,
"updated": 1,
"unchanged": 1,
"markedNeedsUpdate": 3
}
| Field | Meaning |
|---|---|
added |
New keys (targets empty) |
updated |
Source changed → targets may need update |
unchanged |
Source identical |
markedNeedsUpdate |
Key×language pairs marked for translation |
SDK: UpsertCMSConfig.
Staticloc (image localization)
Async text layout recognition under /internal/api/v1/staticloc/*: send an image, get every text on it with font, color and pixel position, plus the boxes covering it (an erase mask). The model and prompt are configured once per organization by an administrator.
| Method | Path | Purpose |
|---|---|---|
POST |
/internal/api/v1/staticloc/text-layout |
Upload image (multipart field image) and start a text layout task |
GET |
/internal/api/v1/staticloc/text-layout/tasks/:id |
Poll a text layout task |
GET |
/internal/api/v1/staticloc/tasks |
List tasks (cursor page; textCount instead of the full result) |
GET |
/internal/api/v1/staticloc/tasks/:id |
Get task |
Terminal statuses: succeeded | failed | canceled. An organization without a configuration gets 422 {"error":"text_layout_not_configured"}; a configuration that went stale (engine deactivated, prompt lost its tag, grant revoked) gets 422 {"error":"text_layout_misconfigured"}.
Create task response (202):
{
"taskId": "8f0c6f0e-6d0a-4a7e-9b1f-2d4b6a8e0c13",
"status": "new",
"pollUrl": "/internal/api/v1/staticloc/text-layout/tasks/8f0c6f0e-6d0a-4a7e-9b1f-2d4b6a8e0c13"
}
Poll — succeeded:
{
"taskId": "8f0c6f0e-6d0a-4a7e-9b1f-2d4b6a8e0c13",
"type": "text_layout",
"status": "succeeded",
"createdAt": "2026-09-30T11:00:00Z",
"updatedAt": "2026-09-30T11:00:18Z",
"result": {
"image": { "width": 1200, "height": 628 },
"texts": [
{ "index": 0, "text": "SUMMER\nSALE", "x": 94, "y": 74, "fontFamily": "Montserrat",
"fontWeight": 800, "fontStyle": "normal", "fontSize": 59, "color": "#FFFFFF" }
],
"boxes": [
{ "textIndex": 0, "x": 94, "y": 74, "width": 530, "height": 59 },
{ "textIndex": 0, "x": 94, "y": 139, "width": 422, "height": 59 }
],
"warnings": []
}
}
List tasks:
{
"tasks": [ /* TaskResponse with textCount… */ ],
"nextCursor": "2026-09-30T11:00:00.123Z|8f0c6f0e-6d0a-4a7e-9b1f-2d4b6a8e0c13"
}
SDK: CreateStaticlocTextLayout, ListStaticlocTasks, GetStaticlocTask, WaitStaticlocTask.
Text erase
Async text removal: send an image (and optionally a mask) and get the same image without its text, in the input's format (PNG → PNG, JPEG → JPEG, WebP → PNG) and size. The model, prompt, quality and input fidelity are configured once per organization by an administrator.
| Method | Path | Purpose |
|---|---|---|
POST |
/internal/api/v1/staticloc/text-erase |
Upload image (png/jpeg/webp, ≤10 MB, each side ≤4096 px) and optional mask, start a text erase task |
GET |
/internal/api/v1/staticloc/text-erase/tasks/:id |
Poll a text erase task |
Mask (optional): a PNG of exactly the image size. If any pixel is transparent, transparent pixels mark the region to erase (the OpenAI convention); a fully opaque mask marks it with white pixels. With a mask only that region can change — pixels outside it are copied from the original. Without a mask (maskMode: "none") the model removes the overlaid design text of the whole image.
Errors carry a stable code in error plus a message: 400 invalid_image, image_too_large, mask_unsupported_type, mask_dimensions_mismatch, mask_empty, invalid_mask; 422 text_erase_not_configured, text_erase_misconfigured; 503 queue_unavailable.
Create task response (202):
{
"taskId": "3b9d1c4e-0f7a-4c2d-8e6b-5a1f2d3c4b5a",
"status": "new",
"maskMode": "masked",
"pollUrl": "/internal/api/v1/staticloc/text-erase/tasks/3b9d1c4e-0f7a-4c2d-8e6b-5a1f2d3c4b5a"
}
Poll — succeeded (url is presigned and valid for expiresInSec seconds; poll again for a fresh one):
{
"taskId": "3b9d1c4e-0f7a-4c2d-8e6b-5a1f2d3c4b5a",
"type": "text_erase",
"status": "succeeded",
"maskMode": "masked",
"createdAt": "2026-09-30T11:00:00Z",
"updatedAt": "2026-09-30T11:00:41Z",
"result": {
"imageId": "0e5c7a1d-2b3f-5d6e-9f8a-7b6c5d4e3f2a",
"url": "https://…",
"mimeType": "image/png",
"width": 1200,
"height": 628,
"expiresInSec": 900
}
}
A failed task carries errorType (e.g. content_rejected, mask_empty, edit_rejected, transient_exhausted) and a generic errorMessage. The task list (GET /staticloc/tasks?type=text_erase) shows text erase tasks with their maskMode.
SDK: CreateStaticlocTextErase, GetStaticlocTextEraseTask.
Discovery
Read-only helpers to discover languages, pairs, and tags available to your key.
| Method | Path | Scope |
|---|---|---|
GET |
/internal/api/v1/languages |
System-wide active languages and pairs (not department-scoped) |
GET |
/internal/api/v1/language-pairs |
Pairs used by pipelines of the API key’s department |
GET |
/internal/api/v1/tags |
Department + global tags |
Languages (+ system pairs)
curl -sS "https://krakenloc.com/internal/api/v1/languages" \
-H "Authorization: Bearer dk_your_department_api_key"
Response:
{
"languages": [
{
"code": "en",
"name": "English",
"nativeName": "English",
"flagEmoji": "🇬🇧",
"isSource": true,
"isTarget": true
},
{
"code": "ru",
"name": "Russian",
"nativeName": "Русский",
"flagEmoji": "🇷🇺",
"isSource": false,
"isTarget": true
},
{
"code": "de",
"name": "German",
"nativeName": "Deutsch",
"flagEmoji": "🇩🇪",
"isSource": false,
"isTarget": true
},
{
"code": "ja",
"name": "Japanese",
"nativeName": "日本語",
"flagEmoji": "🇯🇵",
"isSource": false,
"isTarget": true
}
],
"languagePairs": [
{
"id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
"sourceCode": "en",
"targetCode": "ru",
"sourceName": "English",
"targetName": "Russian"
},
{
"id": "4a3615f1-5f9a-42e4-ab1d-1416f93d4412",
"sourceCode": "en",
"targetCode": "de",
"sourceName": "English",
"targetName": "German"
}
]
}
Language pairs (department pipelines)
curl -sS "https://krakenloc.com/internal/api/v1/language-pairs" \
-H "Authorization: Bearer dk_your_department_api_key"
Response:
{
"languagePairs": [
{
"id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
"sourceCode": "en",
"targetCode": "ru",
"sourceName": "English",
"targetName": "Russian"
},
{
"id": "5b4726g2-6g0b-43f5-bc2e-2527g04e5523",
"sourceCode": "en",
"targetCode": "fr",
"sourceName": "English",
"targetName": "French"
}
]
}
Use this list to know which targetLangs your department can actually process via configured pipelines.
Tags
curl -sS "https://krakenloc.com/internal/api/v1/tags" \
-H "Authorization: Bearer dk_your_department_api_key"
Response:
{
"tags": [
{
"id": "t_01JGLOBAL000000000000001",
"name": "marketing",
"description": "Marketing / growth copy",
"color": "#9B7045",
"isGlobal": true
},
{
"id": "t_01JDEPT00000000000000002",
"name": "checkout",
"description": "Checkout funnel UI",
"color": "#467A89",
"isGlobal": false
},
{
"id": "t_01JDEPT00000000000000003",
"name": "release-2.4",
"description": "",
"color": "#5E7F57",
"isGlobal": false
}
]
}
Pass tag names (or ids) in context.tags on translate. Tags take part in pipeline
matching, so a tag that does not exist here is not ignored — it makes the task fail.
How tags select a pipeline
A pipeline is matched on department, language pair, content type, quality level and tags. For tags the rule is every tag configured on the pipeline must be present in the request:
| Pipeline tags | context.tags on the request |
Match |
|---|---|---|
| none | anything, or omitted | ✅ untagged pipeline is a wildcard |
["web"] |
["web"] or ["web", "promo"] |
✅ pipeline tag present |
["web"] |
omitted / [] |
❌ a request without tags only reaches untagged pipelines |
["web"] |
["cms-content"] |
❌ pipeline tag missing from the request |
["web", "promo"] |
["web"] |
❌ all pipeline tags must be present |
Names are resolved to tag ids before matching. A name that is not in the list above
resolves to nothing, no pipeline matches, and the task ends as failed:
{
"status": "failed",
"errorMessage": "no pipelines found for any target language: [en->es] (unknown tags: [cms-content])"
}
This happens after the 202 response, so poll GET /internal/api/v1/tasks/{id}
(or wait for the webhook) instead of treating 202 as success. Fix it by sending a tag
name from this endpoint, or ask your localization admin to create the tag and attach it
to the pipeline that should handle the content.
SDK: ListLanguages, ListLanguagePairs, ListTags.
Webhooks
Pass webhookUrl on translate (and related flows) to receive push notifications as translations are published, when work completes, or when context is required.
Delivery headers
Every signed delivery includes:
| Header | Description |
|---|---|
webhook-id |
Unique message id (idempotency key across retries) |
webhook-timestamp |
Unix seconds (string) |
webhook-signature |
Space-separated v1,<base64> signatures |
Example:
POST /localization/kraken HTTP/1.1
Host: hooks.acme-games.com
Content-Type: application/json
webhook-id: msg_0d9f2a6c4b7e4c1a9f3e5d8b2a7c6e41
webhook-timestamp: 1744276328
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
Verify against the raw body bytes. See Webhook signatures.
Types
type |
When |
|---|---|
translation |
A publication portion is ready, or the task reaches its final deliverable status |
context_request |
System needs more context; answer via context-response |
task.status_changed |
Task moves to a new status (incl. creation) |
task.tms.assigned |
Task sent to TMS |
task.tms.step_completed |
A linguist completes a workflow step |
task.tms.language_approved |
A target language is approved |
task.tms.language_reopened |
A target language is reopened |
task.tms.withdrawn |
A TMS assignment is withdrawn |
review.completed |
A human review finishes (task-level or LQA) |
There is no separate task.completed / event field — everything is carried in type. The task
lifecycle events (task.status_changed, task.tms.*, review.completed) are documented in full,
with per-type data shapes, in Task lifecycle events; they are emitted on the same
signed channel described on this page and default to ON per department (opt-out list).
Translation webhook — plain (single lang)
Wire envelope is camelCase; nested result uses languageResults.
{
"type": "translation",
"taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"status": "done",
"resultVersion": 1,
"final": true,
"department": "Product",
"sourceLang": "en",
"targetLang": "ru",
"targetLangs": ["ru"],
"completedAt": "2026-04-10T09:12:08Z",
"test": false,
"metadata": {
"ticket": "LOC-1842",
"clientRequestId": "req-checkout-2.4"
},
"publication": {
"mode": "per_task",
"portion": 0,
"languages": {
"ru": { "state": "complete", "published": 1, "pendingReview": 0, "reviewed": 0 }
},
"pendingSegments": [],
"decisionsUrl": "/internal/api/v1/tasks/7c9e6679-7425-40de-944b-e07fc1f90ae7/segments?route=review"
},
"result": {
"languageResults": {
"ru": {
"items": [
{
"type": "plain",
"content": "Завершите покупку"
}
],
"keySources": {},
"source": "translated",
"hasError": false,
"error": "",
"metadata": {
"sourceLang": "en",
"targetLang": "ru",
"itemsCount": 1,
"stringsCount": 1,
"processingTimeMs": 1250,
"processingInfo": {
"pipeline": "product-ui",
"engine": "openai",
"model": "gpt-4o"
}
}
}
},
"partialFailure": false,
"metadata": {}
}
}
Translation webhook — keyvalue multi-language
{
"type": "translation",
"taskId": "a0b1c2d3-e4f5-4678-89ab-cdef01234567",
"status": "ready",
"resultVersion": 1,
"final": true,
"department": "CMS - Configs",
"sourceLang": "en",
"targetLangs": ["de", "es", "fr"],
"completedAt": "2026-04-10T12:00:00Z",
"test": false,
"metadata": {
"configId": "3398",
"type": "config",
"stringIds": "[819793,819794,819795,819796]"
},
"publication": {
"mode": "per_task",
"portion": 0,
"languages": {
"de": { "state": "complete", "published": 4, "pendingReview": 0, "reviewed": 0 },
"es": { "state": "complete", "published": 4, "pendingReview": 0, "reviewed": 0 },
"fr": { "state": "complete", "published": 4, "pendingReview": 0, "reviewed": 0 }
},
"pendingSegments": [],
"decisionsUrl": "/internal/api/v1/tasks/a0b1c2d3-e4f5-4678-89ab-cdef01234567/segments?route=review"
},
"result": {
"languageResults": {
"de": {
"items": [
{
"type": "keyvalue",
"content": {
"welcome.title": "Willkommen",
"welcome.body": "Schön, dass du da bist",
"welcome.cta": "Loslegen",
"permissions.camera": "Kamerazugriff erlauben"
}
}
],
"keySources": {
"welcome.title": "translated",
"welcome.body": "translated",
"welcome.cta": "translated",
"permissions.camera": "translated"
},
"source": "translated",
"hasError": false,
"error": "",
"metadata": {
"sourceLang": "en",
"targetLang": "de",
"itemsCount": 1,
"stringsCount": 4,
"processingTimeMs": 416
}
},
"es": {
"items": [
{
"type": "keyvalue",
"content": {
"welcome.title": "Bienvenido",
"welcome.body": "Nos alegra que estés aquí",
"welcome.cta": "Empezar",
"permissions.camera": "Permitir acceso a la cámara"
}
}
],
"source": "translated",
"hasError": false,
"metadata": {
"sourceLang": "en",
"targetLang": "es",
"processingTimeMs": 421
}
},
"fr": {
"items": [
{
"type": "keyvalue",
"content": {
"welcome.title": "Bienvenue",
"welcome.body": "Ravi de vous voir ici",
"welcome.cta": "Commencer",
"permissions.camera": "Autoriser l'accès à la caméra"
}
}
],
"source": "translated",
"hasError": false,
"metadata": {
"sourceLang": "en",
"targetLang": "fr",
"processingTimeMs": 420
}
}
},
"partialFailure": false,
"metadata": {
"processingInfo": {
"pipeline": "cms-configs",
"engine": "openai",
"model": "gpt-4o"
},
"itemsCount": 3,
"stringsCount": 12,
"processingTimeMs": 535
}
}
}
Publication snapshots
In department settings, Publish results selects when translations are released:
| UI option | publication.mode |
Behavior |
|---|---|---|
| Whole task | per_task (default) |
Release the task result together; the translation webhook has final: true |
| Per language | per_language |
Release a language when its segments no longer wait for review |
| Per segment | per_segment |
Release cleared segments while the remaining segments wait for review |
The mode is copied onto a task at creation. Per segment uses the quality profile's
Only segments needing review (narrow) or Lock passed (lock) review scope.
Every translation webhook contains top-level resultVersion, final, and publication.
Each portion is a full snapshot of everything publishable now, including translations
released in earlier portions; it is never a delta.
| Field | Meaning |
|---|---|
resultVersion |
Task result revision; use this to decide whether to apply the snapshot |
final |
false while the publication cycle continues; true on its last snapshot |
publication.mode |
per_task, per_language, or per_segment |
publication.portion |
Publication sequence number |
publication.languages |
Map keyed by target language; each value has state (pending, partial, or complete), published, pendingReview, and reviewed counts |
publication.pendingSegments |
Authoritative list of cells to keep unpublished or to unpublish |
publication.decisionsUrl |
API path for the task's Review segments; call it with a key that has the tasks scope |
reviewed counts published cells cleared by human review. Each pending segment is addressed
by lang, zero-based itemIndex, and key. Its reasons array contains
layer, check, outcome, and optional severity; an empty array still means the cell
must be unpublished. final is top-level, outside publication.
Example: one German cell is released and another remains in Review.
{
"type": "translation",
"taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"status": "human_review",
"department": "Product",
"sourceLang": "en",
"targetLang": "de",
"targetLangs": ["de"],
"completedAt": "2026-09-30T09:12:08Z",
"resultVersion": 14,
"final": false,
"result": {
"languageResults": {
"de": {
"items": [
{ "type": "keyvalue", "content": { "checkout.title": "Zur Kasse" } }
],
"hasError": false
}
}
},
"publication": {
"mode": "per_segment",
"portion": 3,
"languages": {
"de": { "state": "partial", "published": 1, "pendingReview": 1, "reviewed": 0 }
},
"pendingSegments": [
{
"lang": "de",
"itemIndex": 0,
"key": "dose.warning",
"reasons": [
{ "layer": "l1", "check": "risk.medical_accuracy", "outcome": "veto", "severity": "critical" }
]
}
],
"decisionsUrl": "/internal/api/v1/tasks/7c9e6679-7425-40de-944b-e07fc1f90ae7/segments?route=review"
},
"test": false
}
Delivery is at least once. Dedupe by webhook-id and apply only a resultVersion
greater than the last applied version for that task. Delivered versions never decrease,
but intermediate versions may be skipped: do not wait for consecutive version or portion numbers.
Apply the full snapshot and unpublish every cell listed in pendingSegments, even if
an earlier snapshot published it. final: true closes this cycle; a later edit starts a
new cycle with a higher resultVersion.
Field name trap
| Surface | Multi-lang map field |
|---|---|
HTTP POST /translate response |
results |
Webhook nested result |
languageResults |
Content keys inside keyvalue maps are never renamed.
Handle both result shapes
result arrives in one of two shapes:
| Task | Shape |
|---|---|
Several targetLangs |
per-language map under result.languageResults, keyed by language code; the flat result.items is present but null |
| One target language | may use the shortcut shape with that language's items directly under result.items |
A receiver built for only one shape works until the first task of the other kind arrives —
typically surfacing as a schema error on your side such as
result: expected array, received null when a multi-language delivery has no items.
Read result.languageResults when present and fall back to result.items otherwise;
both carry the same per-item structure.
Context request webhook
{
"type": "context_request",
"taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"contextRequestId": "c0ffee00-1111-4222-8333-444444444444",
"message": "What does the string \"Season Pass\" refer to — a battle pass or a sports season ticket?",
"category": "terminology",
"targetKeys": ["shop.season_pass", "shop.season_pass.desc"],
"requestedBy": {
"name": "Anna Petrova",
"email": "[email protected]"
},
"taskInfo": {
"sourceLang": "en",
"targetLangs": ["ru", "de"],
"department": "Product"
},
"callbackUrl": "/api/v1/tasks/7c9e6679-7425-40de-944b-e07fc1f90ae7/context-response",
"test": false
}
Reply: Context response.
Test deliveries
When "test": true, the payload was sent from Webhook Testing (/developer/webhook-testing) with a department signing secret. Treat like production for signature verification; ignore for business side-effects if you filter on test.
Secret
Signing secret is per department: whsec_… from department settings. See Webhook signatures.
Receiver checklist
- Return 2xx quickly (process async if needed)
- Verify signature on raw body
- Dedupe on
webhook-id - Branch on
type - For
translation, apply only a greaterresultVersion; decodelanguageResults(or the single-languageitemsshape) as a full snapshot - Unpublish every cell in
publication.pendingSegments, including cells published earlier - Treat
final: trueas the last snapshot of this cycle; accept later edits with higher versions
Webhook signatures
Outbound webhooks follow the Standard Webhooks specification (HMAC-SHA256, v1 signatures).
Algorithm
- Require non-empty
webhook-id,webhook-timestamp,webhook-signature - Reject if
|now − timestamp| > 5 minutes(replay protection) - Secret must start with
whsec_; HMAC key = Base64-decode of the part afterwhsec_ signed_content = id + "." + timestamp + "." + raw_bodyexpected = Base64(HMAC-SHA256(key, signed_content))webhook-signatureis space-separatedv1,<base64>entries; any match accepts
Use the exact raw body bytes that were signed — do not re-serialize JSON before verifying.
Go (official SDK)
err := sdk.VerifyWebhookSignature(
secret, // whsec_...
r.Header.Get("webhook-id"),
r.Header.Get("webhook-timestamp"),
r.Header.Get("webhook-signature"),
rawBody,
)
if err != nil {
// reject
}
typ, payload, err := sdk.ParseWebhook(rawBody)
Also available: ParseWebhook, DecodeTranslationResult.
Node.js (minimal)
const crypto = require("crypto");
function verify(secret, id, timestamp, signatureHeader, rawBody) {
if (!secret.startsWith("whsec_")) throw new Error("bad secret");
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const signed = `${id}.${timestamp}.${rawBody}`;
const expected = crypto.createHmac("sha256", key).update(signed).digest("base64");
const ok = signatureHeader.split(/\s+/).some((part) => {
const [ver, sig] = part.split(",", 2);
return ver === "v1" && sig && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
});
if (!ok) throw new Error("invalid signature");
// also enforce |now - timestamp| <= 300s
}
Python (minimal)
import base64, hashlib, hmac, time
def verify(secret: str, id: str, timestamp: str, signature_header: str, raw_body: bytes) -> None:
assert secret.startswith("whsec_")
key = base64.b64decode(secret[len("whsec_"):])
if abs(time.time() - int(timestamp)) > 300:
raise ValueError("timestamp skew")
signed = f"{id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for part in signature_header.split():
ver, _, sig = part.partition(",")
if ver == "v1" and sig and hmac.compare_digest(expected, sig):
return
raise ValueError("invalid signature")
Fake example secret used in docs: whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw.
Go SDK
Install
go get github.com/simple-life-app/localizetion-service/sdk@latest
Module path: github.com/simple-life-app/localizetion-service/sdk
Quick start
import (
"context"
"log"
"github.com/simple-life-app/commonlib/v2/logger"
"github.com/simple-life-app/localizetion-service/sdk"
)
// NewClient(baseURL string, l logger.ILogger, opts ...ClientOption)
// Second argument is logger.ILogger from commonlib.
var l logger.ILogger // your application logger
client, err := sdk.NewClient(
"https://krakenloc.com", // host only
l,
sdk.WithAPIKey("dk_your_department_api_key"),
)
if err != nil { log.Fatal(err) }
if err := client.Ping(ctx); err != nil { log.Fatal(err) }
resp, err := client.Translate(ctx, &sdk.TranslateRequest{
Department: "Product",
SourceLang: "en",
TargetLang: "ru",
Items: []sdk.TranslationItem{
{Type: sdk.ItemTypePlain, Content: "Hello, world!"},
},
})
Methods
| Method | API |
|---|---|
Ping |
GET /_health/live |
UploadFile / UploadFileFromReader |
POST /internal/api/v1/files/upload |
Translate |
POST /internal/api/v1/translate |
GetTask / WaitTask |
GET /internal/api/v1/tasks/{id} |
ProvideContext |
POST /internal/api/v1/tasks/{id}/context-response |
GetTaskSegments |
GET /internal/api/v1/tasks/{id}/segments |
GetSegmentVersions |
GET /internal/api/v1/tasks/{id}/segments/{key}/versions |
ListLanguages |
GET /internal/api/v1/languages |
ListLanguagePairs |
GET /internal/api/v1/language-pairs |
ListTags |
GET /internal/api/v1/tags |
UpsertCMSConfig |
POST /internal/api/v1/cms/configs/upsert |
| Staticloc helpers | /internal/api/v1/staticloc/* |
ListAIModules |
GET /internal/api/v1/ai-modules |
InvokeAIModule |
POST /internal/api/v1/ai-modules/{key}/invoke |
VerifyWebhookSignature / ParseWebhook / DecodeTranslationResult |
webhook helpers |
See Segments and edit history for GetTaskSegments / GetSegmentVersions usage
and error handling (both return sdk.ErrInvalidArgument for empty required arguments).
See AI modules for ListAIModules / InvokeAIModule.
Example files (in the module)
example_translate.go, example_async_poll.go, example_webhook.go, example_files.go, example_cms.go, example_staticloc.go, example_discovery.go, example_additional_info.go, example_existing_translations.go, example_ai_modules.go
Errors
Failed HTTP responses decode into *sdk.APIError with error (or AI-module code) and optional message fields; on 429 and 503, RetryAfter (seconds) may be set.
Errors and limits
HTTP status codes
| Code | Typical cause |
|---|---|
| 200 / 201 / 202 | Success (202 common for async staticloc / queued work) |
| 400 | Malformed JSON or bad parameters |
| 401 | Missing/invalid Bearer key or wrong format |
| 403 | Suspended org or insufficient scope |
| 404 | Unknown task or resource |
| 422 | Validation error (business rules) |
| 429 | Rate limit exceeded |
| 500 | Server error |
Error body shapes
Simple middleware / rate-limit style:
{
"error": "rate limit exceeded"
}
{
"error": "invalid authorization format, expected: Bearer <api_key>"
}
Handler style (many external endpoints):
{
"error": "invalid_request",
"message": "Message is required"
}
{
"error": "internal_error",
"message": "failed to list languages"
}
Go SDK maps failed HTTP bodies into *sdk.APIError:
| Field | Wire / source | Notes |
|---|---|---|
error |
JSON error |
Primary error string (APIError.Err) |
message |
JSON message |
Optional detail (APIError.Message) |
RetryAfter |
Retry-After header on HTTP 429 |
Seconds; not a JSON body field (json:"-"); 0 if absent |
HTTPStatus |
HTTP status code | Not a JSON body field (json:"-") |
Error() returns error, or error: message when message is non-empty.
Validation example (translate)
Duplicate language in existingTranslations:
{
"error": "existingTranslations: duplicate language: ru"
}
Rate limits
Limits are per API key (RateLimitPerMinute configured on the key in admin).
Response headers on limited endpoints:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Configured per-minute limit |
X-RateLimit-Remaining |
Remaining in the current window |
On exceed:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
Content-Type: application/json
{"error":"rate limit exceeded"}
Backoff with jitter; do not spin-poll GET /tasks/:id more aggressively than needed.
Practical limits
- Prefer webhooks over tight polling loops
- Large
keyvaluebatches may be split server-side - Upload size limits follow reverse-proxy / server configuration for your environment
- Text size guidance for plain items is on the order of tens of thousands of characters per item — split oversized documents into multiple items or use
file_url
Troubleshooting
401 — wrong auth format
- Must be
Authorization: Bearer dk_… - A custom header name for the key is not supported
- Extra whitespace or missing
Bearerprefix fails validation
401 — invalid key
- Key revoked, mistyped, or belongs to another environment
- Confirm the key in admin API Keys (
/api-keys)
429 — rate limit
- Back off and honor
X-RateLimit-*headers - Raise
RateLimitPerMinuteon the key if legitimate traffic needs it
Wrong base path
- Base URL is host only
- Paths start with
/internal/api/v1/… - Health is
/_health/live(not under/internal/api/v1)
Webhook signature failures
- Verify against the raw request body
- Secret must include the
whsec_prefix - Check clock skew (5-minute window)
- Header names are exact:
webhook-id,webhook-timestamp,webhook-signature
Receiver returns 400 on a multi-language task
Symptom: single-language tasks deliver fine, then a task with several targetLangs
fails with the receiver's own validation error (e.g. result: expected array, received null),
retried a few times and then given up on.
- In a multi-language body
result.itemsisnull— hence a schema error likeexpected array, received null. Every language lives underresult.languageResults. See Webhooks → Handle bothresultshapes. - Attempts are recorded on the task (
GET /internal/api/v1/tasks/{id}) with the HTTP status of each try, so you can confirm the receiver — not the network — rejected it. - After fixing the receiver, ask your localization admin to re-send the webhook for the affected task; the payload is rebuilt from the stored result.
Task fails right after 202 with pipeline_not_found
errorMessage reads no pipelines found for any target language: [en->es], optionally
followed by (unknown tags: [...]).
- Unknown tag names listed — the names in
context.tagsdo not exist for your department. Send names fromGET /internal/api/v1/tags, or have the tag created and attached to the pipeline. See Discovery → Tags. - No unknown tags — the tags resolve, but no pipeline covers this combination of department, language pair, content type and tags. A request without tags only matches pipelines that have no tags at all, and a tagged pipeline needs all of its tags present in the request.
- Check the pair is offered at all:
GET /internal/api/v1/language-pairslists what your department's pipelines cover. - The failure is terminal — the task is not retried. Only successful terminal statuses
(
ready/done) emit a translation webhook, so a failed task is visible throughGET /internal/api/v1/tasks/{id}, not through a webhook.
results vs languageResults
- HTTP multi-lang translate →
results - Webhook nested translation payload →
languageResults - Go SDK:
DecodeTranslationResultnormalizes structural keys
camelCase responses
- Wire field names are camelCase (
taskId,sourceLang, …) - Enum values stay snake where multi-word (
in_progress,context_request) - During a limited migration window the server may still accept snake_case multi-word keys on requests; responses are camel only
Task lifecycle events
Beyond translation and context_request (Webhooks), the service emits a family of
task lifecycle events on the same signed channel: same webhookUrl, same Standard Webhooks
signature, same attempt log, same retry policy. A task that moves on the kanban, is sent to TMS,
has a step completed by a linguist, or is approved after human review now produces a webhook.
Event registry
type |
Fired when |
|---|---|
task.status_changed |
Every status transition, including creation (from: null) |
task.tms.assigned |
A TMS assignment is created (task sent to TMS) |
task.tms.step_completed |
A linguist completes a workflow step |
task.tms.language_approved |
A target language is approved in the TMS workflow |
task.tms.language_reopened |
A previously approved target language is reopened |
task.tms.withdrawn |
A TMS assignment is withdrawn |
review.completed |
A human review (task-level or LQA) finishes, with the result attached |
A failed task does not get a dedicated task.failed type — it surfaces as
task.status_changed with data.to: "failed".
Envelope
Every lifecycle event uses the same camelCase envelope as translation/context_request, with an
event-specific data object:
{
"type": "task.status_changed",
"eventId": "9f8c7b6a-1111-4222-8333-444444444444",
"occurredAt": "2026-09-09T10:00:00.123456Z",
"taskId": "550e8400-e29b-41d4-a716-446655440000",
"department": "marketing",
"sourceLang": "en",
"targetLangs": ["de", "fr"],
"status": "ready",
"test": false,
"data": {
"from": "human_review",
"to": "ready",
"changedBy": "550e8400-e29b-41d4-a716-446655440099",
"note": null
}
}
| Field | Notes |
|---|---|
eventId |
UUID, unique per event; use for idempotency/dedupe on top of webhook-id |
occurredAt |
UTC, RFC3339 with nanoseconds |
status |
Task status at delivery time (snake enum) |
targetLang / targetLangs |
Single-lang shortcut / full list, same rule as translation |
data |
Always an object (never omitted); shape depends on type — see below |
data by event type
task.status_changed
| Field | Type | Notes |
|---|---|---|
from |
string | null | Previous status; null on task creation |
to |
string | New status |
changedBy |
string (uuid) | null | User who made the change; null for automatic transitions |
note |
string | null | Optional transition note |
task.tms.assigned, task.tms.step_completed, task.tms.language_approved, task.tms.language_reopened, task.tms.withdrawn
| Field | Type | Notes |
|---|---|---|
assignmentId |
string | TMS assignment id |
targetLang |
string | Language the assignment covers |
step |
string | Workflow step |
wordCount |
number | Omitted when unknown |
Commercial/vendor fields (assignee identity, rates, quotes) are never included on this channel.
review.completed
{
"type": "review.completed",
"eventId": "bbbbbbbb-1111-4222-8333-444444444444",
"occurredAt": "2026-09-09T11:00:00Z",
"taskId": "550e8400-e29b-41d4-a716-446655440000",
"department": "marketing",
"sourceLang": "en",
"targetLangs": ["ru"],
"status": "ready",
"test": false,
"data": {
"reviewKind": "task",
"approvedAt": "2026-09-09T10:59:00Z",
"approvedBy": "550e8400-e29b-41d4-a716-446655440099",
"editedSegments": {"ru": 3}
},
"result": {
"languageResults": {
"ru": {
"items": [{"type": "plain", "content": "Привет"}],
"hasError": false
}
},
"partialFailure": false
},
"metadata": {}
}
| Field | Type | Notes |
|---|---|---|
data.reviewKind |
string | "task" or "lqa" |
data.approvedAt |
string | RFC3339 |
data.approvedBy |
string (uuid) | Reviewer id |
data.editedSegments |
object | Per-language count of segments edited during review |
result |
object | null | Same shape as the translation webhook result (languageResults); null when unavailable — never omitted |
metadata |
object | Omitted when empty |
Manual kanban moves to ready (without going through a review action) emit only
task.status_changed, not review.completed.
Subscription — default ALL, opt out per department
By default every department receives every subscribable event type (translation,
task.status_changed, task.tms.*, review.completed; context_request is always sent and is
not part of the opt-out list). Configure an explicit allow-list in department settings to receive
only a subset. If your receiver hard-fails on an unrecognized type, either update it to ignore
unknown types gracefully or opt out of the event families you do not handle yet.
Ordering and retries
- Delivery order across event types is not guaranteed — order by
occurredAtif your integration needs a strict sequence. - Same retry policy as
translation: up to 5 retries (6 attempts total) with backoff; non-2xx and transport errors both retry. task.tms.*events are relayed from an internal outbox on a periodic tick (a few seconds), so they may lag slightly behind the TMS action that produced them.
Receiver checklist (addendum)
- Branch on the full
typeset, not justtranslation/context_request - For
review.completed, handleresult: null(task without a stored result yet) - Dedupe on
eventId(per-event) in addition towebhook-id(per-delivery) - Treat unrecognized
typevalues as forward-compatible no-ops (ack, don't fail)
Segments and edit history
Read-only access to a task's individual translatable cells ("segments") and their full
human-edit history: source text, initial machine output, current (approved-edit-overlaid)
value, gate decisions, publication state, best-effort TM/LQA provenance, and every version a segment went through. Both endpoints
are under the same tasks scope as GET /internal/api/v1/tasks/:id.
| Method | Path | Description |
|---|---|---|
GET |
/internal/api/v1/tasks/:id/segments |
List every segment of a task |
GET |
/internal/api/v1/tasks/:id/segments/:key/versions |
Get one segment's version chain |
Segment identity
A segment is addressed by the triple (lang, itemIndex, key):
lang— target language codeitemIndex— index of the request item the key belongs to (0-based). Needed because akeyvaluetask can send multiple items to the same language, and the same key (e.g.hero.title) can legitimately repeat across items —itemIndexdisambiguates them.key— the cell key (forkeyvalue/jsonitems) or a synthetic key forplainitems
key may contain |, /, %, or Unicode characters and must be URL-escaped as a path
segment when calling the versions endpoint directly (the Go SDK does this for you). Every
segment's versionsUrl field is already a ready-to-call, escaped link.
List segments
GET /internal/api/v1/tasks/:id/segments
| Query param | Required | Notes |
|---|---|---|
lang |
no | Filter to one target language |
route |
no | publish or review (includes reviewed decisions) |
publication |
no | published, pending_review, reviewed, or unpublished |
layer |
no | Stopping layer: l0, l1, l2, or l3 |
include |
no | checks to include individual check results |
cursor |
no | Opaque cursor from a previous page's nextCursor |
limit |
no | Page size; default 200, max 500 |
curl -sS "https://krakenloc.com/internal/api/v1/tasks/7c9e6679-7425-40de-944b-e07fc1f90ae7/segments?lang=de&limit=100" \
-H "Authorization: Bearer dk_your_department_api_key"
{
"taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"items": [
{
"lang": "de",
"itemIndex": 0,
"key": "checkout.title",
"itemType": "keyvalue",
"source": "Checkout",
"initialMt": "Kasse",
"current": "Zur Kasse",
"keySource": "content",
"decision": {
"route": "review",
"status": "reviewed",
"decidedAt": "2026-04-10T10:05:00Z",
"reasons": [
{ "layer": "", "check": "", "outcome": "human_review", "veto": false }
],
"overridden": false
},
"publication": {
"state": "reviewed",
"resultVersion": 14,
"publishedAt": "2026-04-10T10:05:00Z",
"deliveredAt": "2026-04-10T10:05:01Z",
"value": "Zur Kasse"
},
"versionsUrl": "/internal/api/v1/tasks/7c9e6679-7425-40de-944b-e07fc1f90ae7/segments/checkout.title/versions?lang=de&itemIndex=0",
"tm": {
"tier": "fuzzy",
"score": 1,
"matchPercent": 87,
"memoryId": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"locked": false
},
"lqa": [
{
"dimension": "accuracy",
"category": "mistranslation",
"severity": "minor",
"status": "open",
"explanation": "Slightly literal phrasing.",
"suggestedFix": "Zur Kasse gehen"
}
]
}
],
"nextCursor": "eyJsYW5nIjoiZGUiLCJpdGVtSW5kZXgiOjAsImtleSI6ImNoZWNrb3V0LnRpdGxlIn0",
"warnings": []
}
| Field | Notes |
|---|---|
source |
Source-language text for this cell (empty when the source item could not be matched) |
initialMt |
The cell's value right after the pipeline finished — before any human edit |
current |
Current value with the latest approved edit overlaid (falls back to raw MT output when no approved edit exists) |
keySource |
Where the key came from (e.g. content) when the task's stored key-sources map has it, else empty |
tm |
Best-effort translation-memory match info — omitted when unknown (see below) |
provenance |
Optional pass-through of pipeline node provenance (K-07); omitted when absent |
lqa |
LQA findings for this cell from the newest completed full-LQA assessment for the language (empty array, never omitted) |
decision |
Pipeline gate decision; omitted when none exists (see below) |
publication |
Release state and optional published value/timestamps; always present |
versionsUrl |
Ready-to-call, escaped link to this segment's version chain |
warnings |
See Warnings below |
A task without a stored result returns items: []. A lang filter that matches no cells also
returns items: [] (not an error).
Filters combine and apply before pagination. Invalid route, publication, layer,
or include returns HTTP 400 invalid_filter.
curl -sS "https://krakenloc.com/internal/api/v1/tasks/7c9e6679-7425-40de-944b-e07fc1f90ae7/segments?lang=de&route=review&publication=pending_review&layer=l1&include=checks" \
-H "Authorization: Bearer dk_your_department_api_key"
Gate decisions and publication
Each cell has a publication object. Its decision is the pipeline gate decision,
omitted when the pipeline has not produced one for that cell. Manual quality runs are
advisory and do not change the route or publish translations.
decision field |
Meaning |
|---|---|
route |
publish for passed or locked; review for needs_review or reviewed |
status |
passed, needs_review, locked, or reviewed |
layer |
Optional stopping layer: l0 (L0 Format), l1 (L1 Critical risks), l2 (L2 Critical errors), or l3 (L3 Quality score) |
profileVersionId |
Optional UUID of the published quality-profile version used |
decidedAt |
Decision timestamp (ISO 8601) |
score |
Optional segment score |
reasons |
Reason array; present even when empty |
checks |
Individual check results, present only with include=checks; an empty array is valid |
overridden |
Whether a manager overrode the decision |
Publish and Review group the gate decisions. route=review includes already
reviewed cells; to identify decisions still waiting for action, inspect
decision.status == needs_review. A Publish route describes the gate result;
publication.state describes whether the cell has been released.
decision.reasons[] field |
Meaning |
|---|---|
layer |
Layer associated with the reason |
check |
Check identifier |
outcome |
Reason kind, such as fail, veto, critical_finding, below_threshold, check_failed, missing, no_verdict, manual, human_review, or qe |
severity |
Optional neutral, minor, major, or critical |
findingId |
Optional finding UUID |
detail |
Optional explanation |
score |
Optional nonzero score |
veto |
true for reasons at l0, l1, or l2; false otherwise |
decision.checks[] field |
Meaning |
|---|---|
check |
Check identifier |
layer |
Check layer |
status |
ok, failed, not_applicable, or skipped |
verdict |
Optional pass or fail |
score |
Optional check score |
publication field |
Meaning |
|---|---|
state |
published (Published), pending_review (Pending review), reviewed (Reviewed and published), or unpublished |
resultVersion |
Optional first result revision in which this cell was published; different from the task's current revision |
publishedAt |
Optional first-publication timestamp |
deliveredAt |
Successful webhook-delivery timestamp; omitted until acknowledged by a 2xx response |
revisedAt |
Optional timestamp of a revision to the published value |
value |
Optional value from the publication record; import it only when the state is published or reviewed |
current includes approved edits and can differ from publication.value. Import only cells
whose publication state is published or reviewed.
Publication timestamps use ISO 8601. published describes release eligibility and recorded
publication; deliveredAt confirms webhook receipt.
TM best-effort note (D3)
tm reflects the task-level processingInfo.tmApplied at the moment the pipeline ran. For a
keyvalue task with multiple items, this data uses last-writer semantics — if two items
touched the same key, only the last one's TM info survives at the task level, so tm may be
missing or belong to a different item than the one you queried. Treat tm as informational, not
authoritative. tm is omitted entirely when nothing is known.
Warnings
warnings is a deduplicated array in a fixed order:
| Warning | Meaning |
|---|---|
history_unavailable |
Edit history could not be loaded; current/initialMt fall back to the raw stored result |
lqa_unavailable |
LQA findings could not be loaded for at least one language; lqa is empty for those cells |
publication_unavailable |
Publication data could not be loaded |
Get a segment's version chain
GET /internal/api/v1/tasks/:id/segments/:key/versions
| Query param | Required | Notes |
|---|---|---|
lang |
yes | Target language |
itemIndex |
no | Default 0 — see Segment identity above |
cursor |
no | Opaque cursor from a previous page's nextCursor |
limit |
no | Page size; default 200, max 500. Counts the synthetic mt version on the first page |
curl -sS "https://krakenloc.com/internal/api/v1/tasks/7c9e6679-7425-40de-944b-e07fc1f90ae7/segments/checkout.title/versions?lang=de&itemIndex=0" \
-H "Authorization: Bearer dk_your_department_api_key"
{
"taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"lang": "de",
"itemIndex": 0,
"key": "checkout.title",
"versions": [
{
"n": 1,
"kind": "mt",
"value": "Kasse",
"at": "2026-04-10T09:12:08Z",
"author": { "kind": "system", "label": "pipeline" },
"note": null
},
{
"n": 2,
"kind": "edit",
"value": "Zur Kasse",
"at": "2026-04-10T10:02:14Z",
"author": { "kind": "user", "label": "[email protected]" },
"note": null
},
{
"n": 3,
"kind": "approve",
"value": "Zur Kasse",
"at": "2026-04-10T10:05:00Z",
"author": { "kind": "user", "label": "[email protected]" },
"note": null
}
],
"nextCursor": "",
"warnings": []
}
Version kinds
| Kind | Fired by (internal history action) | value |
author |
|---|---|---|---|
mt |
Synthetic — always n: 1, first page only |
Value right after the pipeline ran | {kind: "system", label: "pipeline"} |
edit |
created, value_changed |
The new value after the edit | The editor (or import, see below) |
approve |
review_approved |
The value before the review action (unchanged by approval) | The reviewer |
reject |
review_rejected |
The value before rejection | The reviewer |
reset |
review_reset |
The value before the reset | The reviewer |
apply |
applied |
The value before it was applied | {kind: "system", label: "applied"} |
revert |
reverted, reverted_to_version |
The restored value (falls back to the edit's original value on legacy rows) | The user who reverted |
Author kinds
| Kind | Meaning |
|---|---|
user |
A named human editor or reviewer — label is their email/identifier |
agency |
An agency-affiliated editor — label is always the literal "agency", the real identity is never exposed on this channel |
import |
The edit was created by the bulk-import path (origin:import) — label is the importing user, kind marks it as import-sourced |
system |
An automated action (the initial MT output, or an "applied" step) — no human actor |
Paging
Both endpoints use the same opaque-cursor rule: request limit (default 200, max 500), read
limit + 1 rows internally, return limit and set nextCursor when more remain. A cursor from
one segment/list cannot be reused for a different one — the server returns 400 invalid_cursor
if it does not match.
On the versions endpoint specifically: the synthetic mt version is only ever prepended to
the first page and counts toward limit — e.g. limit=50 on the first page returns mt
plus up to 49 real history entries. Version numbers (n) are stable ordinals that continue
across pages (they never restart at n: 1 on a later page).
Errors
| Endpoint | Status | error |
Cause |
|---|---|---|---|
| List | 400 | invalid_id |
Task ID is not a UUID |
| List | 400 | invalid_cursor |
Cursor malformed or from a different coordinate |
| List | 400 | invalid_limit |
limit is not an integer, <= 0, or > 500 |
| List | 400 | invalid_filter |
Unknown route, publication, layer, or include value |
| List | 404 | task_not_found |
Unknown task, or task belongs to another org/product |
| List | 500 | invalid_task_result |
Stored task result could not be decoded |
| List | 500 | decision_unavailable |
Pipeline gate decisions could not be loaded |
| Versions | 400 | invalid_id | missing_lang | invalid_item_index | invalid_cursor | invalid_limit |
See above, plus a required lang or a non-integer itemIndex |
| Versions | 404 | task_not_found | segment_not_found |
Unknown/foreign task, or no cell matches (lang, itemIndex, key) |
| Versions | 500 | invalid_task_result |
Stored task result could not be decoded |
Go SDK
// List every German segment.
page, err := client.GetTaskSegments(ctx, taskID, sdk.SegmentsQuery{Lang: "de"})
for _, seg := range page.Items {
fmt.Println(seg.Key, seg.Current)
}
// Inspect German L1 cells waiting for review, including each check result.
review, err := client.GetTaskSegments(ctx, taskID, sdk.SegmentsQuery{
Lang: "de", Route: "review", Publication: "pending_review", Layer: "l1", IncludeChecks: true,
})
_ = review
// Page through the rest with the cursor.
if page.NextCursor != "" {
next, err := client.GetTaskSegments(ctx, taskID, sdk.SegmentsQuery{Lang: "de", Cursor: page.NextCursor})
_ = next
}
// Get one segment's full version chain.
versions, err := client.GetSegmentVersions(ctx, taskID, "de", 0, "checkout.title", "", 0)
for _, v := range versions.Versions {
fmt.Println(v.N, v.Kind, v.Author.Kind, v.Value)
}
GetTaskSegments/GetSegmentVersions return sdk.ErrInvalidArgument (via errors.Is) when a
required argument (task ID, lang, key) is empty — no request is made in that case. key is
URL-escaped by the client; limit == 0 omits the query parameter (server default applies).
See Go SDK for the full method table and Context response for
contextRequest on the task read.
Prompt & model provenance (K-07)
For every text-LLM pipeline node (mt, ai_agent, lqa) the orchestrator records what
actually ran: which prompt version, which model, and what happened. This is historical truth
of the execution, not a reconstruction from the current pipeline config — the pipeline can
change after a task ran, and provenance still reflects what that task actually used.
Where this data lives
- Task result —
resultData.metadata.processingInfo.provenanceon adone(or partially done) task, accumulated across every language and item the task ran (see Tasks). - Segments —
provenanceon each row ofGET /internal/api/v1/tasks/:id/segmentsis a pass-through of the same data, scoped to that segment's(lang, itemIndex)(see Segments); omitted when absent, same astm. - Analytics tables (internal; expand-only columns, additive — nothing existing was
renamed or removed) —
provider_usage_logs.prompt_version_idandpost_edit_analytics.translation_prompt_version_idcarry the samepromptVersionId, so LLM spend and post-edit quality metrics can be joined back to the exact prompt version that produced a translation.
Shape
{
"provenance": {
"nodes": [
{
"executionId": "b7c1a9f0-2e34-4b3f-9b3d-2b7f6a9f0c11",
"targetLang": "de",
"itemIndex": 0,
"nodeId": "translate",
"nodeType": "ai_agent",
"stepName": "Translate",
"status": "ok",
"promptId": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"promptVersionId": "c3f1e9a0-6b2d-4e1a-9c3e-7a1b2c3d4e5f",
"promptVersion": 4,
"promptName": "Checkout translator",
"promptKey": "checkout_translate",
"provider": "openai",
"model": "gpt-4o",
"modelSource": "applied",
"temperature": 0.3,
"reasoningEffort": null
}
],
"primary": {
"executionId": "b7c1a9f0-2e34-4b3f-9b3d-2b7f6a9f0c11",
"targetLang": "de",
"itemIndex": 0,
"nodeId": "translate",
"nodeType": "ai_agent",
"status": "ok",
"promptVersionId": "c3f1e9a0-6b2d-4e1a-9c3e-7a1b2c3d4e5f"
}
}
}
| Field | Notes |
|---|---|
nodes |
One entry per node execution — every mt/ai_agent/lqa node that ran, including nodes that errored or were skipped. Never omitted; [] when nothing ran yet. |
nodes[].status |
ok | error | skipped_no_version (no prompt version was configured to run) | skipped_unavailable (the node's producer wasn't wired for this task) |
nodes[].promptId / promptVersionId |
The exact prompt and version used. promptVersionId is null when the node never resolved one (e.g. skipped_no_version) — it does not fall back to "whatever the prompt's current version is now". |
nodes[].model / modelSource |
See Applied vs configured below |
nodes[].itemIndex |
null for a language-wide record (not per-item); a keyvalue/json task with multiple items sets it per item |
primary |
The node a consumer should treat as the translation step for that (targetLang, itemIndex) — last ai_agent node, or the last mt node when there was no ai_agent step. null when nothing ran. |
Applied vs configured
model is what actually produced the response whenever the orchestrator can report it
(modelSource: "applied"). Some paths error before a model responds, or a provider doesn't
echo back which model served the request — in that case model falls back to what was
configured on the node/engine (modelSource: "configured"), which may not be exactly what
ran (e.g. a provider-side model alias resolved to a different underlying model). Treat
modelSource: "configured" as best-effort, not a hard guarantee.
promptVersionId as a stable join key
The same UUID appears in three places for one translation event:
resultData...provenance.nodes[].promptVersionId(or the segment's pass-through)provider_usage_logs.prompt_version_id— the LLM call that consumed tokenspost_edit_analytics.translation_prompt_version_id— if that segment was later edited by a human, the row scoring the edit against(targetLang, itemIndex)'s recorded primary node
post_edit_analytics.prompt_version_id (no translation_ prefix) is a different id — the
classifier prompt that labeled the edit type, not the translation prompt. Don't confuse the two
when joining.
A promptVersionId can go stale: if the prompt or that specific version is later deleted or
reverted, historical provenance still shows the id it recorded (this is what actually ran), but
looking it up now returns not-found. The admin prompt-chain view (GET /internal/admin/v1/tasks/:id/prompt-chain) surfaces this explicitly as "missing": true on the
affected step rather than silently dropping it or resolving to a different version.
Managing prompt versions
Prompt content and versioning are managed through the admin console, not this API — these routes require an authenticated admin session (cookie), not a department API key:
| Method | Path | Permission | Purpose |
|---|---|---|---|
GET |
/internal/admin/v1/prompts/:id/versions |
prompts.view |
List a prompt's versions |
POST |
/internal/admin/v1/prompts/:id/versions |
prompts.create |
Push a new version |
POST |
/internal/admin/v1/prompts/:id/versions/:version/revert |
prompts.edit |
Roll back to an earlier version |
GET |
/internal/admin/v1/tasks/:id/prompt-chain |
prompt_proposals.propose |
The resolved prompt chain for one task, grouped by execution — recorded provenance when available, a reconstruction from the current pipeline otherwise |
Pushing a new version does not retroactively change provenance already recorded on past tasks — those keep pointing at the version that actually ran them.
AI modules
Published AI pre-edit modules the calling department API key may invoke. The key needs the ai_modules:invoke scope, the module must be on the key's allowlist, and API exposure must be approved. A module the key may not call is simply absent from discovery.
| Method | Path | Scope |
|---|---|---|
GET |
/internal/api/v1/ai-modules |
ai_modules:invoke — list only what this key may invoke |
POST |
/internal/api/v1/ai-modules/{key}/invoke |
ai_modules:invoke — run the published snapshot |
The server never reads surface or run.* from the body: it sets them from the route. Only a published immutable snapshot runs.
SDK: ListAIModules, InvokeAIModule.
Three anchors
The invoke body names the anchor. The server never changes it silently.
| Body | Anchor | Required besides targetLang |
|---|---|---|
taskId set |
task |
rowKey — the task row |
cmsConfigId set |
cms |
rowKey — the CMS row |
| neither | text |
text — the source string |
both taskId and cmsConfigId |
— | 400 ambiguous_anchor |
A missing task or CMS config and one that belongs to another tenant answer the same 404 (task_not_found / cms_config_not_found).
Discovery
curl -sS "https://krakenloc.com/internal/api/v1/ai-modules" \
-H "Authorization: Bearer dk_your_department_api_key"
Response:
{
"items": [
{
"key": "shorten",
"name": "Shorten",
"version": 3,
"interfaceVersion": 2,
"maxVariants": 3,
"acceptsInline": ["description", "maxLength"],
"params": [
{
"name": "level",
"type": "enum",
"exposure": "cell_toolbar",
"default": "b1",
"options": [{"value": "b1", "label": "B1"}]
}
],
"modes": [
{
"mode": "api_task",
"anchor": "task",
"state": "full",
"losses": [],
"requires": []
},
{
"mode": "api_text",
"anchor": "text",
"state": "requires",
"losses": [{"source": "tm", "reason": "no_anchor", "impact": "skipped"}],
"requires": ["description", "maxLength"]
}
]
}
]
}
items is [] (HTTP 200) when the API surface is off for the organization, the key has no exposed published modules, or the caller is unidentified. Discovery never lists a module that is pending approval, disabled, unpublished, or closed to the key.
Each modes[] entry is one API mode the exposure opens (api_task, api_cms, api_text). state is full, degraded, unavailable, or requires. losses name sources that do not reach the prompt; requires / acceptsInline name inline fields the plan can take. Parameter names are the module author's and travel verbatim. params lists only the parameters a call may set (exposure is cell_toolbar or run_dialog); parameters the author fixed are not listed.
Invoke — task + row
curl -sS -X POST "https://krakenloc.com/internal/api/v1/ai-modules/shorten/invoke" \
-H "Authorization: Bearer dk_your_department_api_key" \
-H "Content-Type: application/json" \
-d '{
"taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"rowKey": "checkout.cta",
"sourceLang": "en",
"targetLang": "ru-RU",
"currentText": "Перейти к оформлению",
"maxVariants": 2
}'
Invoke — CMS config + row
curl -sS -X POST "https://krakenloc.com/internal/api/v1/ai-modules/shorten/invoke" \
-H "Authorization: Bearer dk_your_department_api_key" \
-H "Content-Type: application/json" \
-d '{
"cmsConfigId": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
"rowKey": "checkout.cta",
"sourceLang": "en",
"targetLang": "ru-RU"
}'
Invoke — plain text
targetLang is required on every call. text is required on the text anchor. Optional context is untrusted inline input (arrays, never maps, for row-keyed data).
curl -sS -X POST "https://krakenloc.com/internal/api/v1/ai-modules/shorten/invoke" \
-H "Authorization: Bearer dk_your_department_api_key" \
-H "Content-Type: application/json" \
-d '{
"text": "Proceed to checkout",
"currentText": "Перейти к оформлению заказа",
"sourceLang": "en",
"targetLang": "ru-RU",
"params": {"level": "b1"},
"maxVariants": 2,
"expectInterfaceVersion": 2,
"strictContext": false,
"context": {
"description": "Primary checkout button",
"maxLength": 24,
"uiType": "button",
"styleNotes": "Keep the verb.",
"metadata": {"screen": "checkout"}
}
}'
Response (result fields only — source contents and trace are never returned):
{
"invocationId": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"module": {"key": "shorten", "version": 3, "interfaceVersion": 2},
"status": "changed",
"text": "Оформить заказ",
"requestId": "fcb18b18-a335-4d8b-8fc4-42e85f211e49",
"anchor": "text",
"quality": "normal",
"edits": [
{
"from": "Перейти к оформлению заказа",
"to": "Оформить заказ",
"category": "length"
}
],
"alternatives": ["К оформлению"],
"diff": [
{"op": "delete", "text": "Перейти к "},
{"op": "equal", "text": "оформлению"},
{"op": "insert", "text": "Оформить заказ"}
],
"usedSources": [{"id": "key_metadata", "origin": "inline"}],
"degraded": [{"source": "tm", "reason": "no_anchor", "retryable": false}],
"usage": {
"model": "gpt-4.1-mini",
"inputTokens": 420,
"outputTokens": 48,
"latencyMs": 890
}
}
usedSources and degraded carry identifiers and reasons only. notes and edits[].reason are omitted unless the module's API exposure turns explanations on (explain=false by default). invocationId is the journal row; null is not used on this public path.
status is changed, no_change, or cannot_comply. quality is normal or low_context. diff is a word-level diff of the current value and text; whitespace belongs to the piece.
Missing data: degraded and insufficient_context
A call can still succeed when optional sources do not arrive. Those sources appear in degraded (retryable says whether a later retry might fill them). Discovery already advertises the same loss on the mode (state: degraded).
A hard quality floor fails before the model is called and is not billed:
{
"code": "insufficient_context",
"message": "not enough context to run the module",
"details": {
"missing": ["tm"],
"unlockWith": ["taskId", "rowKey"],
"retryable": true
}
}
strictContext: true turns the soft floor into this 422.
A required source with no inline fallback is 422 required_source_unavailable (details.source, details.reason, and details.missing = the plan's requires fields). A module with no plan for the named anchor is 422 mode_not_supported (details.supportedAnchors).
Body limits
| Limit | Cap |
|---|---|
| Request body | 1 MiB (over → 413 invalid_request) |
context.neighbors |
50 |
context.siblings |
10 |
context.glossaryHints |
50 |
context.styleNotes |
500 characters |
context.metadata |
1500 characters (keys + values) |
context.description |
1000 characters |
context.uiType |
64 characters |
String value in params |
2000 characters (over → 400 invalid_params) |
maxVariants |
1–5 |
context.screenshotUrl |
absolute https URL only |
A context field over its cap is 400 invalid_request.
params are checked against the parameter schema of the version the call runs — the latest published snapshot, or the one pinned with moduleVersion — before any source is read or the model is called. A parameter the call leaves out takes the module's default. A parameter whose default is null is required: send a non-empty value (the custom module requires params.custom), otherwise the call answers 400 invalid_params with reason required. Any mismatch is 400 invalid_params, and details.issues lists every problem, sorted by parameter name (at most 20):
reason |
When |
|---|---|
unknown |
The module declares no parameter with this name |
fixed |
The author fixed the parameter; a call may not set it |
null |
The value is null |
wrong_type |
The value does not match the type: enum and string take a JSON string, int a whole number, bool true or false |
not_an_option |
The enum value is not one of its options[].value |
too_long |
The string is longer than 2000 characters. Before counting, control characters (line breaks included), bidirectional embeddings, overrides and isolates, and Unicode tag characters are removed |
required |
The parameter has no default, and the call does not send it or sends a string without a single visible character: empty, or only spaces, control characters or invisible formatting characters (such as zero-width spaces) |
For example, "params": {"level": "z9", "tone": "casual"} against the module in the discovery example answers:
{
"code": "invalid_params",
"message": "parameter \"level\": the value is not one of its options (and 1 more)",
"details": {
"issues": [
{"param": "level", "reason": "not_an_option"},
{"param": "tone", "reason": "unknown"}
]
}
}
message never repeats the values you sent.
targetLang, and sourceLang when sent, must be known language codes. Region, script, and variant subtags are fine (pt-BR, es-419, zh-Hant-TW, de-CH-1996); a code with an extension or a private-use part (en-US-u-ca-gregory, de-t-en, en-x-test) is 400 invalid_request.
expectInterfaceVersion mismatch is 409 module_interface_changed. Pin an older snapshot with moduleVersion (must be ≥ 1).
Error codes
Error bodies are { "code": "…", "message": "…", "details": {…} }. Frequency refusals also set Retry-After (seconds).
| HTTP | code |
When |
|---|---|---|
| 400 | invalid_request |
Body not parsed, missing rowKey/text/targetLang, unknown language code or one with an extension, context field over its cap, bad UUID, blocked screenshot URL |
| 400 | ambiguous_anchor |
Both taskId and cmsConfigId |
| 400 | invalid_params |
params do not match the parameter schema of the version the call runs: unknown name, fixed parameter, null, wrong type, value outside the options, string over 2000 characters, required parameter missing (details.issues) |
| 401 | — | Missing or invalid Bearer key (middleware) |
| 403 | module_not_exposed |
Module not open to this key |
| 403 | engine_not_granted |
Engine not granted to the organization |
| 403 | budget_exceeded |
Daily USD budget exhausted (fail-closed) |
| 404 | module_not_found |
Unknown module key |
| 404 | task_not_found |
Unknown or foreign task |
| 404 | cms_config_not_found |
Unknown or foreign CMS config |
| 409 | module_interface_changed |
expectInterfaceVersion did not match |
| 413 | invalid_request |
Body larger than 1 MiB |
| 422 | mode_not_supported |
No plan for this anchor (details.supportedAnchors) |
| 422 | required_source_unavailable |
Required source missing (details.source, details.reason) |
| 422 | insufficient_context |
Hard quality floor; not billed |
| 429 | rate_limited |
Key frequency limit, module×key limit, or in-flight cap; Retry-After |
| 500 | internal_error |
Unexpected failure |
| 502 | llm_failed |
Model call failed |
| 503 | modules_disabled |
API surface off for the organization (invoke only; discovery returns []) |
| 503 | limiter_unavailable |
Frequency check could not run (fail-closed); Retry-After |
| 504 | llm_timeout |
Model call timed out |
Checks run in order: exposure → key frequency → module×key limit → daily budget → anchor → quality floor. Every 422 is answered before the model is called. A limiter that cannot answer refuses the call (503) instead of letting it through.
Go SDK
listed, err := client.ListAIModules(ctx)
for _, m := range listed.Items {
fmt.Println(m.Key, m.Version, m.MaxVariants)
}
result, err := client.InvokeAIModule(ctx, "shorten", &sdk.AIModuleInvokeRequest{
Text: "Proceed to checkout",
SourceLang: "en",
TargetLang: "ru-RU",
})
fmt.Println(result.Text, result.Status, result.Anchor)
Failed module responses decode into *sdk.APIError: Err is the wire code, Message is message. On 429 and 503, RetryAfter is taken from Retry-After.