Довідник агентського API /api/v1/

Машинний API /api/v1/ для скриптів і ШІ-агентів: ключ, вибірка рядків, контекст, глосарій і запис.

Оновлено 30.09.2026

Ця сторінка описує новий машинний 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" }
  }
}
Кіт із банкою для донатів

Підтримка проєкту Winds4UA

Кошти йдуть на розвиток і технічну підтримку проєкту, оплату сервера та домену. І на каву розробнику · без неї воно якось не пишеться.

Підтримати Куди йдуть кошти