Довідник агентського API /api/v1/
Машинний API /api/v1/ для скриптів і ШІ-агентів: ключ, вибірка рядків, контекст, глосарій і запис.
Ця сторінка описує новий машинний API для скриптів і ШІ-агентів. Старий API
для ботів (/api/translations-api.php) лишається окремим контрактом зі своєю
формою відповіді — його цей документ не описує й не замінює.
Базовий шлях
Усі адреси починаються з /api/v1/. Версія стоїть у шляху, а не в заголовку:
чужому скриптові простіше зафіксувати URL, ніж домовлятися про заголовок.
Це stateless-гілка: сесії, CSRF і Set-Cookie тут немає. Відповідь персональна,
тому її не можна кешувати спільним кешем.
Автентифікація
Ключ передається заголовком X-API-Key. Формат значення — wwm_ і 60
hex-символів; рядок іншого вигляду навіть не доходить до бази.
- без заголовка —
401 missing_key; - рядок не схожий на ключ —
401 invalid_key; - ключ не знайдено або відкликано —
401 invalid_key; - ключ деактивовано —
403 key_inactive; - власника заблоковано, деактивовано або видалено —
403 account_inactive; - API вимкнено адміністратором (
API_V1_ENABLED=false) —403 api_disabled.
Здатності й права
Ключ — це дзеркало ролі власника плюс ручні корективи (рішення D-102):
чинні здатності = (права поточної ролі власника ∪ ability_grants) − ability_denies
Гранти (ability_grants) — те, що ключу додано понад роль; денаї
(ability_denies) — те, що з ключа знято попри роль. Обидва обчислюються
наживо, тому підвищення ролі власника розширює ключ, а пониження звужує без
перевидачі ключа. Персональна заборона права сильніша за грант, а неактивний,
заблокований чи деактивований акаунт не має жодної здатності. Збережений у ключі
перелік abilities — лише знімок для історії, на доступ він не впливає.
| Здатність | Потрібне право ролі | Межа гранту |
|---|---|---|
rows:read |
view |
— |
translations:validate |
view |
— |
translations:propose |
propose_translation |
— |
translations:write-machine |
import_localized |
лише admin і вище |
translations:write-manual |
review_translation |
лише moderator і вище |
translations:write-machine — машинний шар, який іде в гру без модерації; грант
не видає його нікому нижче admin. translations:write-manual — прямий ручний
запис, на сайті його ухвалює moderator і вище; грант нижче moderator його не
відкриває.
Коди відмови: 403 forbidden_ability — ключ цю дію зняв (ability_denies);
403 forbidden_permission — роль не дає здатності. У details видно, чого
саме бракує: required_roles (для машинного й ручного запису) або
required_permission (для решти). Керування поправками ключа («додати» /
«зняти») з'явиться в адмінці.
Ліміти й заголовки
Хвилинне вікно рахується за користувачем, а не за IP (за одним IP може
працювати кілька агентів). Типові значення за роллю: super_admin/admin — 120
запитів/хв, moderator/translator — 60, user — 30. Колонка ключа
requests_per_minute перекриває дефолт.
На кожній відповіді:
X-RateLimit-Limit— стеля хвилини;X-RateLimit-Remaining— скільки лишилось;Cache-Control: no-store, private,X-Robots-Tag: noindex, nofollow,X-Content-Type-Options: nosniff.
Перевищення дає 429 rate_limited із заголовком Retry-After (у секундах).
Форма відповіді
Успіх:
{
"success": true,
"data": { }
}
Відмова:
{
"success": false,
"error": "invalid_key",
"message": "Ключ не знайдено або відкликано. Візьміть новий у кабінеті.",
"hint": "…",
"details": { }
}
Коди помилок
| Код | HTTP | Що означає |
|---|---|---|
missing_key |
401 | Немає заголовка X-API-Key |
invalid_key |
401 | Ключ не знайдено, відкликано або має чужий формат |
key_inactive |
403 | Ключ деактивовано |
account_inactive |
403 | Власника заблоковано, деактивовано або видалено |
forbidden_ability |
403 | Ключ зняв цю здатність (ability_denies) попри роль |
forbidden_permission |
403 | Роль не дає здатності; деталі — required_roles або required_permission |
api_disabled |
403 | API вимкнено адміністратором |
rate_limited |
429 | Перевищено хвилинний ліміт |
daily_row_quota_exceeded |
429 | Вичерпано добову квоту записаних рядків |
limiter_unavailable |
503 | Лічильник лімітів недоступний |
invalid_request |
400 | Некоректні параметри; допустимі значення — у details |
batch_too_large |
400 | Забагато елементів у пачці |
not_found |
404 | Такої адреси чи методу в API v1 немає — виправте шлях |
internal_error |
500 | Збій сервера, не пов'язаний із текстом; повторіть пізніше |
entry_not_found |
404 | Рядка з таким identity_hash немає |
stale_source |
409 | Оригінал змінився після того, як його прочитали |
layer_busy |
409 | Шар перекладів зайнятий перебудовою |
active_proposal_exists |
409 | У автора вже є активна пропозиція для рядка |
duplicate_idempotency_key |
409 | Idempotency-Key уже використано з іншим тілом |
non_translatable |
422 | Рядок не потребує перекладу |
empty_text |
422 | Порожній текст перекладу |
source_equivalent |
422 | Подано англійський оригінал замість перекладу |
markup_functional_breakage |
422 | Зламано підстановки рушія |
markup_cosmetic_breakage |
422 | Не збігаються теги оформлення |
markup_caption_breakage |
422 | Змінено вміст ігрових тегів у кутових дужках |
glossary_violation |
422 | Текст суперечить глосарію |
length_too_short |
422 | Переклад надто короткий проти оригіналу |
length_too_long |
422 | Переклад надто довгий проти оригіналу |
unchanged |
422 | Такий самий текст у цьому шарі вже збережено |
save_failed |
422 | Збій збереження на сервері |
Кожен код несе ще й машинну підказку agent_action (retranslate, refetch,
skip, wait, stop_until_quota_reset, abort) — щоб скрипт не вигадував
стратегію з тексту повідомлення.
Маршрути
GET /api/v1/me
Хто я і який у мене бюджет. Здатностей не потребує — агент читає свої права ще до першого запиту даних.
curl -H "X-API-Key: wwm_…" https://example/api/v1/me
{
"success": true,
"data": {
"user": { "id": 42, "name": "Перекладач", "role": "moderator" },
"key": {
"prefix": "wwm_ab12cd34",
"name": "Бот перекладу",
"abilities": ["rows:read", "translations:validate", "translations:propose", "translations:write-manual"],
"ability_grants": [],
"ability_denies": [],
"last_used_at": "2026-09-29T05:44:00+00:00"
},
"effective_abilities": ["rows:read", "translations:validate", "translations:propose", "translations:write-manual"],
"limits": { "requests_per_minute": 60 }
}
}
key.abilities і effective_abilities — обидва чинний список (роль ∪ гранти −
денаї): клієнт не мусить множити його сам. key.ability_grants і
key.ability_denies показують саме поправки ключа окремо від ролі. Пошти у
відповіді немає навмисно.
GET /api/v1/rows
Список рядків перекладу в порядку ordinal, id. Потребує здатності
rows:read. Клієнт іде сторінками через курсор і не мусить рахувати OFFSET:
на великому каталозі це було б усе повільніше з кожною сторінкою.
Параметри (кожне невідоме значення — 400 invalid_request із переліком у
details):
| Параметр | Допустимі значення | Типово |
|---|---|---|
limit |
ціле 1..100 | 50 |
cursor |
непрозорий рядок із meta.next_cursor попередньої сторінки |
— |
state |
all, none, machine, manual, stale |
all |
source |
all, main, diff, diff_only |
all |
updated_since |
дата ISO 8601 | — |
include_total |
1 |
— |
fields |
CSV груп: core, sources, reference, layers, tokens |
core |
state: none — ні ручного, ні машинного шару (службові diff-рядки сюди не
входять), machine — лише машинний, manual — є ручний, stale — хоч один шар
застарів. source: diff — чинний оригінал із diff-файлу, diff_only — рядок
є лише в diff-файлі.
cursor непрозорий — його не можна складати чи тлумачити самому, лише
передавати назад незміненим. На останній сторінці next_cursor немає.
fields керує обсягом картки. core додається завжди, навіть якщо її не
вказали, і містить row_id, ordinal, source_text, source_kind,
source_hash, is_service_row. Інші групи — окремий ключ із назвою групи:
| Група | Що додає |
|---|---|
core |
Ключ, оригінал і його хеш; присутня завжди |
sources |
Тексти обох англійських файлів: `{"main": {…} |
reference |
Китайська довідка {"text", "kind"} або null, коли її немає |
layers |
Шари перекладу `{"manual": {…} |
tokens |
Технічні токени оригіналу {"must_preserve", "cosmetic", "note"} |
Групи, яких клієнт не просив, у відповіді відсутні зовсім — не null і не
порожній об'єкт. Довідковий текст у гру не потрапляє ніколи — це лише текст для
перекладача.
sources.main і sources.diff — текст із відповідного файлу гри (text,
text_hash); null, якщо в цьому файлі рядка немає. layers.manual і
layers.machine містять text, status (робочий статус), freshness, origin,
provider, model, updated_at (ISO 8601); шар без голови — null.
tokens — мультимножини «токен → скільки разів», не просто перелік: втрата
одного з двох однакових токенів — окрема поломка. must_preserve мусить
зустрітися в перекладі дослівно й у тій самій кількості, cosmetic (кольорові
теги, літерні переноси й табуляції) бажано зберегти. Обидва блоки — завжди
JSON-об'єкт «токен → кількість»; порожній — {}, а не []. Поле note містить
це правило словами.
{
"success": true,
"data": {
"rows": [
{
"row_id": "aa11bb22cc33dd44",
"ordinal": 1,
"source_text": "Hello {0}",
"source_kind": "main",
"source_hash": "1f0c…",
"is_service_row": false,
"sources": {
"main": { "text": "Hello {0}", "text_hash": "1f0c…" },
"diff": { "text": "Hello again {0}", "text_hash": "9b31…" }
},
"reference": { "text": "你好 {0}", "kind": "main" },
"layers": {
"manual": {
"text": "Привіт {0}",
"status": "approved",
"freshness": "fresh",
"origin": "human",
"provider": null,
"model": null,
"updated_at": "2026-09-29T05:44:00+00:00"
},
"machine": null
},
"tokens": {
"must_preserve": { "{0}": 1 },
"cosmetic": {},
"note": "must_preserve скопіюйте в переклад дослівно й у тій самій кількості; cosmetic (кольорові теги, літерні переноси й табуляції) бажано зберегти."
}
}
]
}
}
curl -H "X-API-Key: <ключ>" \
"https://example/api/v1/rows?limit=2&state=none&include_total=1"
{
"success": true,
"data": {
"rows": [
{
"row_id": "aa11bb22cc33dd44",
"ordinal": 1,
"source_text": "Alpha EN",
"source_kind": "main",
"source_hash": "1f0c…",
"is_service_row": false
}
]
},
"meta": {
"count": 1,
"total_matching": 128,
"has_more": true,
"next_cursor": "MToy",
"fields": ["core"]
}
}
meta: count — рядків на цій сторінці; has_more — чи є продовження;
next_cursor — курсор наступної сторінки (немає на останній);
total_matching — усього рядків під фільтр, лише з include_total=1; fields
— групи, які реально зібрано.
GET /api/v1/rows/{row_id}
Повна картка одного рядка. Потребує здатності rows:read. {row_id} — це
16 hex-символів row_id зі списку. Без параметра fields віддаються усі
групи (це повний рядок); fields звужує набір так само, як у списку, а core
присутня завжди.
Невідомий ID правильного формату — 404 entry_not_found; рядок іншого вигляду
не потрапляє в маршрут і дістає загальний 404 not_found.
curl -H "X-API-Key: <ключ>" \
"https://example/api/v1/rows/aa11bb22cc33dd44?fields=core,sources,tokens"
{
"success": true,
"data": {
"row": {
"row_id": "aa11bb22cc33dd44",
"ordinal": 1,
"source_text": "Hello {0}",
"source_kind": "main",
"source_hash": "1f0c…",
"is_service_row": false,
"sources": {
"main": { "text": "Hello {0}", "text_hash": "1f0c…" },
"diff": null
},
"tokens": {
"must_preserve": { "{0}": 1 },
"cosmetic": {},
"note": "must_preserve скопіюйте в переклад дослівно й у тій самій кількості; cosmetic (кольорові теги, літерні переноси й табуляції) бажано зберегти."
}
}
},
"meta": { "fields": ["core", "sources", "tokens"] }
}
GET /api/v1/taxonomy
Самоопис API: усі допустимі значення машинно, щоб скрипт чи модель не
хардкодили рядки. Потребує здатності rows:read. Значення беруться з тих самих
enum-ів, які перевіряють запит, тому зміна переліку видима клієнтові без правки
його коду.
Поля відповіді:
| Поле | Що містить |
|---|---|
row_states |
значення фільтра state |
sources |
значення фільтра source |
field_groups |
назви груп fields |
layers |
шари перекладу (manual, machine) |
freshness |
стани свіжості шару |
translation_statuses |
робочі статуси перекладу |
origins |
походження ревізії |
limits |
межі limit для списку |
abilities |
{value, label, required_permission} для кожної здатності |
error_codes |
повний довідник кодів помилок із agent_action |
curl -H "X-API-Key: <ключ>" https://example/api/v1/taxonomy
{
"success": true,
"data": {
"row_states": ["all", "none", "machine", "manual", "stale"],
"sources": ["all", "main", "diff", "diff_only"],
"field_groups": ["core", "sources", "reference", "layers", "tokens"],
"layers": ["manual", "machine"],
"freshness": ["fresh", "stale", "unverified"],
"translation_statuses": ["draft", "reviewed", "approved", "rejected", "invalid"],
"origins": ["human", "ai_api", "localized_file_import", "legacy_unknown"],
"limits": { "rows_limit_default": 50, "rows_limit_max": 100 },
"abilities": [
{ "value": "rows:read", "label": "Читати рядки й переклади", "required_permission": "view" }
],
"error_codes": [
{
"code": "entry_not_found",
"http_status": 404,
"hint": "Рядка з таким identity_hash немає.",
"agent_action": "abort",
"retriable": false
}
]
}
}
Довідки /guide у v1 поки немає.
Перевірка перекладів без запису
POST /api/v1/translations/validate
Спитати, чи прийняв би сайт ці переклади, не зберігаючи нічого. Потребує
здатності translations:validate. Це той самий шлюз тексту, що стоїть на сайті
перед поданням і перед затвердженням машинного перекладу, тож відповідь
ok: true означає саме те, що сайт збереже рядок, а не наближену схожість.
Перевірка не пише в базу й не витрачає добову квоту записаних рядків — жодного запису, жодного списання.
Тіло запиту:
| Поле | Тип | Обов'язкове | Що означає |
|---|---|---|---|
items |
масив 1..50 | так | рядки пачки, порядок зберігається |
items[].row_id |
16 hex | так | row_id зі списку чи картки |
items[].text |
рядок | так | переклад; порожній рядок — теж значення |
items[].layer |
machine, manual |
ні | типово manual: manual — перевірка подання, machine — перевірка затвердження машинного тексту |
items[].source_hash |
64 hex | ні | якщо передано й не дорівнює чинному source_hash рядка — stale_source |
Помилки пачки (відмова всієї відповіді): 400 invalid_request (зіпсоване або
невідоме поле), 400 batch_too_large (понад 50 елементів, у details.max —
стеля).
Помилки пункту (відповідь залишається 200): entry_not_found (рядка
немає), stale_source (застарілий source_hash, у details — чинний).
Відмова рядка — не відмова запиту: HTTP лишається 200, а причина лежить у
errors цього рядка.
Рядок результату: row_id, ok, errors, warnings. errors порожній —
рядок пройшов. Кожен запис помилки: code (код API), rule_code (номер
правила сайту, якщо є) і message; для глосарія — details.missing_phrases з
назвами, яких бракує перекладу.
Попередження (warnings) не блокують запис, але їх варто показати людині.
| Тип порушення сайту | Код API |
|---|---|
Теги, службові вставки (tags, service_elements) |
markup_functional_breakage |
Оформлення, керуючі символи (formatting) |
markup_cosmetic_breakage |
Правило LENGTH_01 |
length_too_short |
Правило LENGTH_02 |
length_too_long |
| Попередження глосарія | glossary_violation |
| Невідомий тип правила | markup_functional_breakage (у rule_code — початковий код) |
curl -X POST -H "X-API-Key: <ключ>" -H "Content-Type: application/json" \
-d '{"items":[
{"row_id":"aa11bb22cc33dd44","text":"Привіт {0}","layer":"manual"},
{"row_id":"bb22cc33dd44ee55","text":"Hello"}
]}' \
https://example/api/v1/translations/validate
{
"success": true,
"data": {
"items": [
{ "row_id": "aa11bb22cc33dd44", "ok": true, "errors": [], "warnings": [] },
{
"row_id": "bb22cc33dd44ee55",
"ok": false,
"errors": [
{
"code": "markup_functional_breakage",
"rule_code": "TAG_MATCH_01",
"message": "Кількість тегів не відповідає оригіналу. Оригінал: 2 тегів (#R, #E), переклад: 0 тегів ()"
}
],
"warnings": []
}
]
},
"meta": { "total": 2, "ok": 1, "failed": 1 }
}
Запис перекладів
POST /api/v1/translations
Записати пачку перекладів. Здатність визначається кожним пунктом: machine
потребує translations:write-machine, ручний proposal — translations:propose,
ручний direct — translations:write-manual. Кожен пункт перевіряється тим
самим дзеркалом ролі з поправками (див. «Здатності й права»): forbidden_ability
означає «ключ зняв цю здатність», forbidden_permission — «роль не дає».
Якщо власник ключа не має права на жоден запис, уся пачка дістає
403 forbidden_ability одразу; якщо здатності бракує лише частині пунктів — ці
пункти відхиляються зі своїм кодом, решта пишеться.
Заголовок Idempotency-Key обовʼязковий (1..128 символів). Без нього —
400 invalid_request із details.header. Той самий ключ із тим самим тілом
повертає збережену відповідь із заголовком Idempotent-Replayed: true (запис
удруге не робиться); той самий ключ з іншим тілом — 409 duplicate_idempotency_key.
Відповіді зберігаються 24 години. Обробка одного ключа тримається під
блокуванням; якщо попередній запит із цим ключем ще триває понад 5 секунд —
409 layer_busy із Retry-After: 5.
Квота. Добовий ліміт записаних рядків спільний на людину (усі її ключі). Якщо
в пачці більше пунктів, ніж лишилось, — уся пачка відхиляється 429 daily_row_quota_exceeded (details: limit, used, remaining, resets_at),
нічого не пишеться. У квоту входять лише пункти, що отримали нову ревізію чи
пропозицію; відхилені рахуються окремо й ліміт не витрачають.
Тіло запиту:
| Поле | Тип | Обов'язкове | Що означає |
|---|---|---|---|
items |
масив 1..50 | так | пункти пачки, порядок зберігається |
items[].row_id |
16 hex | так | row_id зі списку чи картки |
items[].text |
рядок | так | переклад; порожній рядок — теж значення |
items[].layer |
machine, manual |
ні | типово manual |
items[].source_hash |
64 hex | так | чинний source_hash оригіналу; розбіжність — відмова пункту stale_source |
items[].mode |
direct, proposal |
ні | лише для manual, типово proposal |
items[].overwrite |
bool | ні | лише для machine, типово false: перезаписати наявний машинний текст |
items[].provider |
рядок 1..128 | для machine — так |
хто зробив переклад: провайдер, напр. openai, deepseek, opencode |
items[].model |
рядок 1..128 | для machine — так |
назва моделі, якою зроблено переклад |
Для layer = machine поля items[].provider і items[].model обовʼязкові: без
них запит відхиляється цілком як 400 invalid_request із помилкою поля в
details.errors. Для layer = manual вони ігноруються (людина перекладає без
моделі; навіть передані, у ревізію вони не потрапляють).
Як заповнювати provider і model
Обидва поля — вільний рядок, але пишіть їх за єдиним правилом: тоді статистика «який ШІ і з якою якістю переклав» лишається точною. Різні написання однієї моделі вона рахує як різні моделі.
provider— хто віддає модель (компанія або сервіс, до якого йде запит): лише малі латинські літери, цифри й дефіс, без пробілів і версій. Приклади:openai,anthropic,google,deepseek,mistral,openrouter,opencode,ollama(локальна модель).model— точний ідентифікатор моделі так, як його приймає API провайдера (без перекладу, скорочень і прикрас), разом із версією чи датою, якщо вона є в ідентифікаторі. Приклади:gpt-4.1-mini,claude-sonnet-4-5,deepseek-chat,gemini-2.5-flash. Не писати «GPT 4», «ChatGPT», «Claude», «default».- Через посередника (OpenRouter, OpenCode)
provider— сам посередник, аmodel— ідентифікатор, який приймає посередник (напр.deepseek/deepseek-chat). - Одна й та сама модель завжди пишеться однаково.
- Сервер лише обрізає пробіли на краях і не виправляє написання.
Статус пункту в data.items[]:
status |
ok |
Що сталося |
|---|---|---|
written |
true | створено нову ревізію (machine або manual + direct) |
proposed |
true | створено пропозицію pending; у пункті proposal_id і proposal_version. Для manual + direct так буває, коли чинний ручний переклад схвалила інша людина: модератор не перебиває чуже схвалення, і пункт лишається пропозицією з details.reason = foreign_approval |
unchanged |
true | нічого не змінено; причина в details.reason: same_text, already_filled, unchanged або diff_priority |
rejected |
false | пункт не записано; причина в errors (як у перевірці: code, rule_code, message) |
meta: total, written, proposed, unchanged, rejected і quota
(limit, used, remaining, resets_at — уже після цієї пачки).
curl -X POST -H "X-API-Key: <ключ>" -H "Content-Type: application/json" \
-H "Idempotency-Key: batch-2026-09-29-001" \
-d '{"items":[
{"row_id":"aa11bb22cc33dd44","layer":"machine","provider":"openai","model":"gpt-4o","source_hash":"<64 hex>","text":"Привіт, світе"},
{"row_id":"bb22cc33dd44ee55","layer":"manual","mode":"proposal","source_hash":"<64 hex>","text":"Ніч"}
]}' \
https://example/api/v1/translations
{
"success": true,
"data": {
"items": [
{ "row_id": "aa11bb22cc33dd44", "ok": true, "status": "written", "errors": [], "warnings": [] },
{
"row_id": "bb22cc33dd44ee55",
"ok": true,
"status": "proposed",
"errors": [],
"warnings": [],
"proposal_id": 321,
"proposal_version": 1
}
]
},
"meta": {
"total": 2,
"written": 1,
"proposed": 1,
"unchanged": 0,
"rejected": 0,
"quota": { "limit": 5000, "used": 2, "remaining": 4998, "resets_at": "2026-09-30T00:00:00+03:00" }
}
}