Попробовать API
</>
Публичный API BOTIX: OpenAPI, multi-tenant и Partner API
sdkРазбор
15 мин

Публичный API BOTIX: OpenAPI, multi-tenant и Partner API

Когда выпускали публичный API, стоял выбор: сделать «обычный» API на свой кабинет и потом дописывать партнёрские возможности — или сразу спроектировать так, чтобы один контракт работал и для прямого клиента, и для агентства с сотней ботов, и для white-label партнёра. Выбрали второй путь. Ниже — почему OpenAPI оказался единственным способом удержать паритет SDK на трёх языках, как устроена изоляция `project_id` и какой минимальный Partner API заложили в v1.0.

Коротко

  • OpenAPI 3.x — единственный источник правды. Ручные SDK на трёх языках расходятся тремя способами (documentation / type / coverage drift), все три неизбежны. Из одного openapi.yaml генерируются SDK, Redoc и Swagger UI — расхождение технически невозможно.
  • Multi-tenant — не настройка, а контракт. Каждый ключ привязан к одному project_id, каждый SQL содержит WHERE project_id = ?, Auth::assertProjectAccess() стоит в начале каждого эндпоинта. На стороне интегратора — один инстанс SDK на проект, ключи из vault, узкие scopes, ротация без даунтайма.
  • Partner API — отдельный префикс /partner/v1/*, а не флаги. Партнёрские возможности не навешиваются на обычные ключи параметрами: обычный ключ к ним получает 403. Минимальный набор в v1.0, white-label-домены и partner-scope OAuth — отложены.

Точка старта: почему не переименовали internal в public

К моменту, когда садились писать публичный API, у BOTIX уже было 11 нишевых ботов в продакшене и пара агентств в очереди с одинаковой формулировкой: «дайте API, чтобы мы делали своё под нашими клиентами». Внутренний API уже работал — кабинет на нём построен. Соблазн очевидный: переименовать /internal/v1/* в /public/v1/*, добавить bearer-токен в middleware, написать пару страниц документации в Notion. Неделя до релиза.

Не сделали. Внутренний API разрабатывался под кабинет — под один сценарий: один авторизованный пользователь, один активный проект. Для агентства этот контракт не работает: один технический аккаунт и сто проектов клиентов. У white-label партнёра — отдельный домен и набор sub-проектов, изолированных так, чтобы один клиент не видел данные другого, даже если оба принадлежат одному партнёру. Три модели нельзя удовлетворить одним набором эндпоинтов с разными правами — это разные контракты с разной семантикой project_id. Решение: публичный API строим с нуля, multi-tenant закладываем как основной сценарий, OpenAPI пишем первым, код — вторым.

Итоговый контракт получился строгим и предсказуемым: авторизация Authorization: Bearer XXXXXXXX…, обёртка ответа {success, data, meta} для успеха и {success:false, error:{code,message,details}} для ошибки, версия зафиксирована как /public/v1/*. Новые поля в ответы добавлять можно, удалять и менять типы — нельзя. Эта дисциплина совместимости важнее, чем кажется на старте: именно она позволяет генерировать SDK без страха сломать чужой прод.

Тезис 1. OpenAPI как единый источник правды, а не «Notion + руками написанные SDK»

Когда команда выпускает публичный API на нескольких языках, есть два пути. Первый: писать SDK руками — на PHP отдельной командой, на Python отдельной, на Node отдельной, а документацию держать в Notion. Второй: описать API в OpenAPI-спеке и генерировать SDK инструментом вроде openapi-generator-cli. На старте первый путь быстрее и кажется более контролируемым. Через год выясняется, что он дорогой, потому что ломается тремя способами — и все три неизбежны.

Documentation drift. API получает новый эндпоинт. Команда добавляет его на сервер и в документацию. Через две недели разработчик PHP-SDK находит время и пишет метод. Через месяц — Python. Через два — Node. В этот промежуток клиенты на разных языках видят разный набор возможностей: в доке метод описан, в библиотеке его ещё нет. Прилетает тикет «у вас в доках есть метод X, а в SDK его нет — баг?».

Type drift. Сервер возвращает created_at строкой в ISO 8601. В PHP-SDK это string, в Python — datetime, в Node — Date | string. Через полгода кто-то меняет формат на Unix timestamp. На сервере — один коммит. В трёх SDK — три pull request, три релиза, три обновления README. Вероятность, что все три пройдут синхронно, стремится к нулю.

Coverage drift. На сервере 92 эндпоинта. В PHP-SDK 92, в Python — 85, в Node — 71. У каждого свой приоритет, и партнёрский ключ, работающий у PHP-агентства, не работает у Node-агентства. Держать паритет ручным трудом можно только тогда, когда есть отдельный человек, единственная задача которого — следить за паритетом. Это редкий формат.

OpenAPI закрывает все три. Единственный источник правды о Public API — файл openapi.yaml в репозитории платформы: он описывает каждый эндпоинт, каждую модель, каждый код ошибки, каждое поле запроса и ответа. Из него генерируется всё остальное:

bash
# Цикл выпуска новой версии API
openapi-generator-cli generate -i openapi.yaml -g php -o sdk-php/
openapi-generator-cli generate -i openapi.yaml -g python -o sdk-python/
openapi-generator-cli generate -i openapi.yaml -g typescript-node -o sdk-node/
redoc-cli build openapi.yaml -o developers.botix.pro/index.html
swagger-ui dist openapi.yaml -o developers.botix.pro/try-it/

Шесть артефактов из одного файла: три SDK, публичная документация на Redoc и интерактивная песочница на Swagger UI. Любое изменение распространяется через генератор за минуту, без копипасты.

Отдельная история — примеры использования. Обычно их пишут вручную в Markdown, и синхронизация с API — отдельная боль: переименовали поле → вспомни, в каких примерах оно встречается, поправь везде. Всегда выполняется не до конца. В OpenAPI 3 есть расширение x-codeSamples — примеры запросов хранятся прямо внутри спеки, под каждым эндпоинтом:

yaml
paths:
  /contacts:
    post:
      x-codeSamples:
        - lang: curl
          source: |
            curl -X POST https://api.botix.pro/public/v1/contacts \
              -H "Authorization: Bearer $BOTIX_TOKEN" \
              -H "Content-Type: application/json" \
              -d '{"phone":"+79991234567","name":"Test"}'
        - lang: PHP
          source: |
            $contact = $client->contacts()->create([
              'phone' => '+79991234567',
              'name' => 'Test',
            ]);

У нас в openapi.yaml сейчас 92 таких блока — по три-четыре языка на каждый осмысленный эндпоинт. Меняется сигнатура метода — сэмпл правится в одном месте и через рендер Redoc автоматически попадает на страницу документации.

Что понимать заранее: качество спеки определяет качество SDK. Если тип поля описан как oneOf без discriminator — генератор не построит корректный union-тип в TypeScript. Если код ошибки указан в description, но не в responses — сгенерированный клиент не будет уметь его обрабатывать. Спека — контракт, а не отчёт. Любая неточность всплывёт во всех трёх SDK одновременно: плохо, когда переделываешь, и хорошо, когда отлавливаешь несоответствие до релиза.

Ещё два практических момента с генерацией. Первый — кастомизация через шаблоны, а не правки в сгенерированном коде: если стандартный PHP-шаблон отдаёт код без strict types или не вставляет timing-safe сравнение в verifyWebhook, это правится один раз в шаблоне и переживает регенерацию. Второй — разделение папок: сгенерированный код кладётся в src/Generated/, ручные надстройки под нестандартные паттерны (long-polling, batch-склейка, Laravel-фасады) — в src/Extension/. При регенерации src/Generated/ перезаписывается целиком, src/Extension/ остаётся нетронутым, конфликт становится явным при сборке. Как это раскладывается на весь цикл разработки SDK — в разборе Zero-dep SDK, генерация из OpenAPI и триал-sandbox.

Тезис 2. Multi-tenant: явный project_id в каждом запросе

Сценарий агентства: одна команда разработчиков, один репозиторий с инструментами, один деплой — и сотня клиентов, где каждый клиент это отдельный проект на стороне платформы со своей базой контактов, сценариями и лимитами по тарифу. Между этими двумя реальностями нужен мост, который не утечёт из одного клиента в другой, переживёт рост нагрузки и ротацию ключей без даунтайма. Большинство ошибок multi-tenant лежит не в коде, а в неявных допущениях: «project_id один на запрос», «токен можно положить в общий конфиг», «лимит у всех одинаковый». Каждое однажды оказывается неверным — и тогда клиент A видит данные клиента B либо вся интеграция упирается в лимит самого слабого тарифа из ста.

Главное требование multi-tenant — невозможность утечки данных между проектами. На уровне платформы это жёсткая дисциплина: каждый запрос приходит с токеном, токен резолвится в project_id, любой SQL содержит WHERE project_id = ? от текущего токена. Закреплено правилом №6 в CLAUDE.md и проверяется в каждом контроллере одной строкой:

php
public function createContact(): JsonResponse
{
    $input = json_decode(file_get_contents('php://input'), true);
    $projectId = (int) ($input['project_id'] ?? 0) ?: Auth::projectId();

    Auth::assertProjectAccess($projectId);  // 403 если ключ не от этого проекта

    // дальше — работа с данными, гарантированно изолированными
}

Auth::assertProjectAccess() — единственная защита от утечки данных между проектами. Мы прошли два аудита перед публичным релизом и поймали три места, где assertProjectAccess забыли. После этого ввели правило: новый эндпоинт без вызова assertProjectAccess не проходит code review.

На уровне интегратора нужна аналогичная дисциплина в клиентском коде. Если SDK инициализируется один раз глобально и используется во всех ботах — любая ошибка в параметрах приведёт к запросу на чужой проект. Правильный паттерн — один инстанс SDK на проект:

python
from botix import BotixClient

# Ключи берутся из vault на старте процесса
clients = {
    project_id: BotixClient(api_key=XXXXXXXX(f"botix/{project_id}"))
    for project_id in active_projects
}

def send_to_project(project_id: int, contact_id: int, text: str):
    client = clients[project_id]
    client.messages.create(contact_id=contact_id, text=text)

Инстансы кэшируются, ключи берутся из защищённого хранилища, в момент использования невозможно случайно перепутать project_id с api_key. Если ключ ротируется — пересоздаётся один инстанс, остальные продолжают работать.

Scopes — третий слой защиты. По умолчанию выпущенный ключ может всё, что доступно тарифу проекта. На практике это редко нужно: сервису отправки сообщений не нужны права на удаление контактов или чтение настроек проекта. BOTIX поддерживает scopes на уровне ключа через поле scopes (JSON) в btx_api_keys. При создании ключа на странице app.botix.pro/developers указывается список разрешённых scope (contacts:read, contacts:write, messages:read, messages:send), и middleware при каждом запросе проверяет, что эндпоинт входит в scope токена. Несоответствие — 403 INSUFFICIENT_SCOPE. На стороне интегратора это значит: на каждый изолированный кусок логики — отдельный ключ с минимальным scope. Сервис отправки — ключ с messages:send; аналитический воркер, читающий статистику раз в час, — отдельный ключ с contacts:read. Утекут оба в общий лог — ни один не даст компрометации полного контура. Антипаттерны хранения ключей разбираем отдельно — Онбординг разработчика: quickstart, sandbox и хранение ключей.

Ротация без даунтайма. Любой ключ когда-нибудь нужно ротировать: плановый ротейт раз в полгода — гигиена, внеплановый после утечки — обязательная процедура. Помогает разделение состояний ключа: active (можно использовать) и revoked (отказ в обслуживании). У BOTIX это поле status (active/revoked) и revoked_at в btx_api_keys. Выпускается новый ключ → инстанс SDK переключается на него → старый помечается revoked. Без grace-периодов с двумя ключами одновременно: новый работает до отзыва старого, три шага выполняются независимо.

Audit-логи. Финальный слой — журналирование всех вызовов: какой ключ, какой эндпоинт, какой HTTP-статус, какой project_id, какое время ответа. У BOTIX это таблица btx_api_log, retention 90 дней, фильтр по ключу и статусу — на app.botix.pro/developers. Журнал решает три задачи: отладку (где начались ошибки), расследование инцидентов (какие запросы шли с подозрительного ключа), оптимизацию (что вызывается чаще всего — может, пора на webhook). Полезно дублировать: журнал читается из платформы и пишется внутри интегратора параллельно.

Тезис 3. Partner API: что вошло в v1.0, что отложили

Один эндпоинт /contacts не делает API платформой. Платформа начинается там, где появляется второй уровень: партнёр, встраивающий API внутрь своего продукта и продающий дальше. У прямого клиента контур простой — один аккаунт, один проект, несколько ключей, один webhook. У партнёра многоуровневый: сам партнёр сверху, внутри десятки или сотни конечных клиентов, у каждого свой логический проект со своими данными, биллингом, webhook’ом. Обычный ключ работает в контексте одного проекта — партнёрский должен работать в контексте партнёрского аккаунта и переключаться между конечными. Скрыть это под одним эндпоинтом — путь к ошибкам.

Поэтому Partner API — отдельный набор эндпоинтов с префиксом /partner/v1/*. Обычный project-ключ к ним не имеет доступа: получает 403. Это сознательное решение — не навешивать партнёрские возможности на обычные эндпоинты флагами «если ты партнёр, можешь передать ещё этот параметр». Запутанная семантика опаснее дублирования префикса.

В v1.0 партнёрский набор минимальный:

  • POST /partner/v1/projects — создание нового sub-проекта от имени партнёра
  • GET /partner/v1/projects — перечисление всех sub-проектов с пагинацией
  • POST /partner/v1/projects/{id}/keys — выпуск project-ключа для конкретного sub-проекта
  • GET /partner/v1/usage — сырые данные usage по всем sub-проектам за период (для биллинга по партнёрской модели)

Изоляция данных здесь — критическая граница: внутри одного партнёрского аккаунта могут жить sub-проекты конкурирующих компаний, и возможность одного увидеть данные другого — катастрофа. Партнёрский уровень добавляет слой поверх обычного assertProjectAccess: проверяется не только доступ к sub-проекту, но и то, что sub-проект принадлежит партнёрскому аккаунту, выпустившему ключ. На уровне БД — WHERE project_id = ? в каждом запросе; на уровне ключей — нельзя получить ответ по одному sub-проекту, передав ключ другого.

Что отложили на v1.2: white-label-домены под партнёрский кабинет (нужна отдельная инфраструктура CNAME-маршрутизации, не успели) и OAuth installations с partner-scope (базовые OAuth installations работают через btx_oauth_installations и страницу согласия app.botix.pro/oauth/authorize, но partner-scope, позволяющий приложению видеть все sub-проекты аккаунта, а не только тот, куда установлено, требовал отдельной семантики — отложили). Разбор развилки «OAuth vs Partner-key и что нужно, чтобы стать платформой» — в статье Multi-tenant, OAuth и white-label в одной интеграции.

Что сломалось: удобная возможность в обычном ключе

Было место, где недооценили клиентский CI/CD. В первой версии Partner API разрешили обычным project-ключам создавать новые проекты — для удобства интеграторов, которые часто создают тестовые проекты для прогона тестов в CI. Зачем заставлять их получать отдельный partner-ключ ради этого. Дали.

Через две недели один из ключей начал плодить десятки пустых проектов в фоне. У клиента в CI сломался cleanup-шаг, и каждая ветка с PR создавала новый тестовый проект, не удаляя старый. За неделю — 800 проектов. Заметили только тогда, когда btx_projects начал заметно расти.

Откатили: возможность создавать новые проекты убрали из обычного ключа, оставили только в /partner/v1/projects. Урок: любая «удобная» возможность в обычном ключе, которая выходит за рамки одного проекта, — будущая утечка ресурсов через чужой CI. Лучше отдельный префикс, отдельный тип ключа, явное разделение.

Цифры

  • 92 эндпоинта в v1.1.0
  • 92 блока x-codeSamples в openapi.yaml по 3-4 языка на каждом
  • 3 SDK с одного openapi.yaml: botix-pro/sdk (Composer), botix (PyPI), @botix/sdk (npm)
  • 0 транзитивных зависимостей в sdk-php после перехода на zero-deps (было 14, выкинули все)
  • rate-limit заголовки во всех ответах v1.1.0: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset; на 429 — дополнительно Retry-After
  • 5 миграций БД для запуска OAuth, webhook receiver, developer trial и API changelog subscribers

Что бы сделали иначе

Версионирование OpenAPI стоит закладывать с первого дня, не с первого breaking change. Запустились с info.version: 1.0.0 без отдельной папки /v1/. Через два месяца, переходя на cursor-пагинацию и поддерживая обе версии параллельно, пришлось переразвернуть структуру: openapi/v1.0.yaml, openapi/v1.1.yaml, скрипт сборки SDK по версии. Заложи сразу — миграция была бы одной командой, а не полудневным рефакторингом.

И второе: партнёрский API стоило запустить в публичном виде вместе с v1.0, а не «по запросу избранным по NDA». Это работает, пока партнёров три. Когда двадцать — разные обещания, разные SLA, теневая документация, техдолг дороже, чем сразу опубликовать. Будь partner-эндпоинты в той же openapi.yaml с разделом «Partner API» в Redoc — онбординг партнёра шёл бы сам собой. Что именно должно быть в API, чтобы он стал платформой для интеграторов, — в разборе API как платформа: партнёрский слой и open-source SDK.