Попробовать API
</>
Multi-tenant, polling vs webhook и OAuth для агентства
integraciyaРазбор
17 мин

Multi-tenant, polling vs webhook и OAuth для агентства

TL;DR: агентство приходит с запросом «у нас 47 ботов под разные ниши, дайте один SDK на всех». Ответ «заведите 47 аккаунтов и 47 ключей» разваливается на третьем месяце. Разбираю три связанных архитектурных решения, которые закрывают этот сценарий: sub-проекты вместо отдельных аккаунтов, webhook с polling-fallback и OAuth 2.0 поверх API-key. Что рассматривали, что отвергли и сколько это стоило в человеко-месяцах.

Коротко

  • Multi-tenant — не «аккаунт на каждого клиента», а один партнёрский аккаунт + N sub-проектов с жёсткой изоляцией по project_id, отдельными ключами, scope и ротацией без даунтайма.
  • Доставка событий — webhook как основной канал (X-Botix-Signature, retry, проверка подписи по raw body), cursor-polling как fallback для сценариев, где webhook поднять нельзя.
  • Авторизация — API-key для intra-агентских воркеров (действуете от своего имени), OAuth 2.0 с PKCE для third-party (клиент дал разрешение стороннему приложению); подмена одного другим = утечка доступа.

Это разбор архитектурного выбора — не «как реализовать», а «почему выбрали именно так и что это стоило». Три связанных решения, принятых одной партией:

  1. Multi-tenant модель: один партнёрский аккаунт + N sub-проектов, не «отдельный аккаунт на каждый бот».
  2. Webhook + cursor-полинг как fallback, не голый polling и не голый webhook без альтернативы.
  3. OAuth 2.0 для third-party интеграций поверх существующего API-key механизма, не «один тип ключа на все случаи».

Каждое решение — компромисс между сложностью разработки на нашей стороне, сложностью интеграции на стороне партнёра и стоимостью эксплуатации в продакшене у обеих сторон.

BOTIX — российская SaaS-платформа AI-ассистентов. Public API v1.1.0, три открытых SDK (PHP, Python, Node), CLI, встроенный webhook-инспектор. Документация — developers.botix.pro, репозитории — github.com/BOTIX-pro. Авторизация — заголовок Authorization: Bearer btx_live_…, каждый ключ привязан к project_id.

Сценарий, с которого всё началось

Агентство строит чат-ботов на BOTIX. На счету — 47 клиентов: барбершопы, автосервисы, стоматологии, аренда квартир. Каждый клиент — отдельный логический проект на стороне BOTIX со своей базой контактов, сценариями, лимитами по тарифу. Со стороны агентства — одна команда разработчиков, один репозиторий с инструментами автоматизации, один деплой.

Мост между этими двумя реальностями должен: не утечь данными из одного клиента в другой; выдержать рост с 47 до 200-300 без переписывания; пережить ротацию ключей без даунтайма; не превратиться в куст if'ов по проектам; дать агентству биллить клиентов по своей модели.

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

Тезис 1. Один партнёрский аккаунт + N sub-проектов с изоляцией project_id

Первый и самый частый ответ на запрос агентства: «заведите отдельный аккаунт на каждого клиента». На бумаге работает: 47 кабинетов, 47 ключей, 47 биллингов. В реальной эксплуатации разваливается на третьем месяце.

Агентство не хочет 47 раз вводить реквизиты и держать 47 платёжных аккаунтов. Конечный клиент тоже — у него уже есть отношения с агентством, третий поставщик в счёте непонятен. И главное: агентство не может централизованно мониторить здоровье всех 47 интеграций — каждый раз надо логиниться в отдельный кабинет.

Прежде чем выбирать модель, стоит развести три зоны и границы доверия между ними: интегратор, платформа, клиент платформы. Ключ как механизм аутентификации эти границы должен отражать явно. Здесь возможны три паттерна:

  • API-ключ на проект. Интегратор хранит по одному ключу на клиента, ключи выпущены через клиентский кабинет. Просто и предсказуемо, но для каждого нового клиента — ручной шаг: клиент заходит, генерирует ключ, отдаёт.
  • OAuth installation. Третья сторона регистрирует приложение, получает client_id/client_secret, любой клиент платформы одним кликом «подключает приложение к проекту». Стандарт для публичных third-party интеграций (GitHub Apps, Stripe Connect). Разбор — OAuth и хранение ключей в приложении партнёра.
  • Partner API. Платформа выделяет интегратору отдельный набор прав, позволяющий создавать проекты от имени партнёра. Уровень для крупных интеграторов и реселлеров.

Под сотню клиентов ручной API-ключ на проект — узкое место, поэтому правильная архитектура здесь — иерархия. Партнёрский аккаунт сверху, набор sub-проектов снизу. Каждый sub-проект — со своими данными, webhook'ом, биллингом. Изоляция: данные одного sub-проекта не пересекаются с другим даже под одним партнёром. Биллинг идёт через партнёра, агентство само раскладывает счёт на конечных клиентов.

Решение приняли в декабре 2025, реализация — около двух человеко-месяцев одного разработчика. Что сделано:

  • Отдельный тип ключа — partner-key — с расширенными правами: создание sub-проектов, перечисление существующих, получение ключей для каждого.
  • Concept sub-проекта в БД: btx_projects.parent_project_id с foreign key на родительский партнёрский проект.
  • В каждом запросе проверка Auth::assertProjectAccess($projectId) — метод смотрит не только право доступа к sub-проекту, но и то, что sub-проект принадлежит партнёрскому аккаунту, выпустившему ключ.
  • Отдельная страница в кабинете партнёра с агрегированным view: все 47 sub-проектов, для каждого — последние ошибки, количество запросов за сутки, статус webhook'ов.

Изоляция данных — критическая граница. Внутри одного партнёрского аккаунта могут быть sub-проекты конкурирующих компаний (барбершопы с разных концов района). Возможность одного увидеть данные другого — катастрофа. Защита многослойная: на уровне БД WHERE project_id = ? в каждом запросе; на уровне API явный project_id в каждом запросе и проверка принадлежности партнёрскому аккаунту; на уровне ключей нельзя получить ответ по одному sub-проекту, передав ключ другого. Отдельная деталь безопасности: при попытке получить ресурс чужого проекта BOTIX отдаёт 404, а не 403 — существование чужого объекта не утекает.

Auth::assertProjectAccess() вызывается в начале каждого эндпоинта, работающего с проектом — это формальное правило, зафиксированное в CLAUDE.md репозитория и проверяемое в code-review. Без него данные потекут между проектами на первой же ошибке.

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

python
from botix import BotixClient

clients = {
    project_id: BotixClient(api_key=XXXXXXXX(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)

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

Поверх изоляции — ещё два слоя. Scopes: по умолчанию ключ может всё, что доступно тарифу проекта, но сервису отправки сообщений не нужны права на удаление контактов. BOTIX хранит scope в поле scopes (btx_api_keys и btx_oauth_access_tokens); middleware Public API проверяет, что эндпоинт входит в scope токена, иначе — 403 с кодом INSUFFICIENT_SCOPE. На каждый изолированный кусок логики — свой ключ с минимальным scope: messages:write для рассыльщика, analytics:read для часового аналитического воркера. Ротация: ключ имеет два состояния — active и revoked (поле revoked_at в btx_api_keys). Ротация без даунтайма — выпустить новый ключ, переключить инстанс, отозвать старый; три шага независимы, grace-period с двумя живыми ключами не нужен. Audit: каждый вызов пишется строкой в btx_api_log (ключ, эндпоинт, HTTP-статус, project_id, время ответа), retention 90 дней, фильтр по ключу и статусу — на app.botix.pro/developers.

Соседние архитектурные разборы — хранение ключей и multi-tenant по шагам и multi-tenant с хранением API-ключей.

Тезис 2. Webhook как основной канал, polling как fallback

Когда у агентства 47 ботов и каждый отвечает в реальном времени, выбор между polling и webhook — вопрос экономики, а не вкуса.

Polling — запрос «дай мне всё, что появилось после такой-то отметки». Работает без публичного endpoint'а, без HTTPS, без обработки повторных доставок. Воркер — маленький while-loop с requests.get и cursor'ом. Недостаток — обратная сторона простоты: период опроса определяет лаг. Опрашивать раз в секунду — лаг до 1 секунды, но 60 запросов в минуту на ключ и 86 400 в сутки. Для 47 ботов — 4 миллиона запросов в сутки только на проверку «есть ли новые сообщения». При лимите в десятки запросов в минуту на ключ это 47 параллельных воркеров, каждый со своим счётчиком. Любой сбой одного из 47 — отдельная задача.

Webhook — HTTP-запрос, который платформа отправляет на endpoint клиента в момент события. Задержка — десятки-сотни миллисекунд, никаких пустых запросов. Цена — инфраструктура: публичный endpoint с HTTPS и проверкой подписи на каждом запросе. Для агентства с 47 ботами это один webhook-обработчик, в теле которого проверка подписи и роутинг события на нужного клиента. В десятки раз дешевле 47 polling-воркеров и гарантированно быстрее.

В BOTIX webhook через webhook.botix.pro — основной паттерн доставки. Получатель регистрируется через POST /public/v1/webhooks либо в кабинете на app.botix.pro/developers. Платформа поддерживает события message.created, chat.opened, contact.created, payment.confirmed и ещё около 15 типов. Подпись передаётся в header X-Botix-Signature как sha256=… от тела запроса с общим секретом, хранящимся у обеих сторон (рядом идут X-Botix-Event и X-Botix-Request-Id). Retry — экспоненциальный, до 5 попыток, после чего доставка помечается failed и видна в delivery-dashboard.

Пример проверки подписи на Python из open-source SDK (github.com/BOTIX-pro/sdk-python/blob/main/botix/webhooks.py):

python
import hmac
import hashlib

def verify_webhook(secret: str, body: bytes, signature_header: str) -> bool:
    if not signature_header.startswith('sha256='):
        return False
    expected = signature_header.removeprefix('sha256=')
    computed = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, computed)

Три тонкости в этих пяти строках:

  1. hmac.compare_digest — защита от timing-атак, нельзя сравнивать через ==.
  2. Подпись проверяется по raw body, не по парсенному JSON. Парсинг и обратная сериализация меняют пробелы и порядок ключей — подпись не совпадёт.
  3. У каждого webhook-endpoint'а отдельный секрет, не общий по всем sub-проектам. Иначе при компрометации одного клиента — компрометация всех.

Polling доступен как fallback через cursor-пагинацию: эндпоинты GET /public/v1/chats, GET /public/v1/messages, GET /public/v1/contacts поддерживают параметр cursor и возвращают meta.next_cursor для следующей страницы (cursor работает параллельно со старыми page/per_page — старые клиенты не ломаются). Клиент сохраняет последний cursor, опрашивает периодически, идёт вперёд по страницам до пустого next_cursor. Это покрывает сценарии, где webhook поднять невозможно: локальная разработка без туннеля, jobs из CRM по своему расписанию, реконсиляция после инцидента.

В чистом виде polling не рекомендуется как основной способ доставки и явно отмечен в документации developers.botix.pro/best-practices как fallback: при росте числа клиентов один опрашивающий воркер быстро упрётся в rate-limit (X-RateLimit-Limit per-minute зависит от тарифа), а растягивать период опроса нельзя без потери качества интеграции.

Зрелый компромисс — гибрид. Основной поток — webhook, потому что задержка важна. Резервный — periodic reconciliation: раз в час клиент опрашивает API через cursor и сверяет локальное состояние с состоянием платформы. Если webhook потерялся или обработчик вернул ошибку, которую не заметили вовремя, — реконсиляция это исправит. Паттерн особенно полезен, когда пропущенное событие дорогое (не доставили триггер по платёжному статусу): webhook закрывает 99% случаев, реконсиляция страхует оставшийся процент. Как строить backoff по X-RateLimit-* — в разборе rate-limit и надёжности.

Тезис 3. OAuth 2.0 для third-party, API-key для своего бэкенда

Когда у агентства появляется собственный продукт — например, дашборд для клиентов с агрегированной аналитикой по всем 47 ботам, — клиент агентства должен залогиниться в дашборд и увидеть свои данные, а дашборд должен забирать данные из BOTIX от имени клиента. Здесь важно развести два сценария, которые часто путают.

Сценарий 1 — интеграция собственного приложения. У агентства есть свой бэкенд, он общается с API от имени самого агентства. Бэкенд принадлежит агентству, ключ принадлежит агентству. Правильный инструмент — API-ключ.

Сценарий 2 — интеграция стороннего приложения с данными пользователя. Появилось третье приложение (тот же дашборд, но им пользуется конечный клиент). Клиент должен дать приложению разрешение на доступ к своим данным. Приложение не знает API-ключ клиента, не должно его знать и не должно получать. Правильный инструмент — OAuth 2.0.

Когда сценарии путают, появляется типичная ошибка: клиент копирует production-ключ и вставляет его в форму «введите API-ключ» стороннего сервиса. С этого момента сервис имеет полный доступ ко всему, что доступно по ключу — включая удаление данных и смену настроек биллинга. Это нарушение принципа наименьших привилегий. А когда клиент перестаёт пользоваться сервисом, ключ остаётся в чужой БД и продолжает работать. Поэтому третье решение — OAuth 2.0 поверх API-key механизма. Не вместо ключей (они нужны для intra-агентских интеграций), а в дополнение.

OAuth — это не «безопаснее, чем API-ключ», а другая модель доверия. Когда доступом владеет сама компания, короткоживущие токены, отзыв и scope не дают выгоды — только лишний код. Поэтому индустриальный паттерн: API-ключи — для собственных интеграций, OAuth — для third-party. В зрелых API оба механизма сосуществуют.

OAuth решает то, что API-ключ не решает:

  • Сторонний сервис никогда не получает долгоживущий ключ — у него access_token на час и refresh_token на 30 дней.
  • У токена есть scope — ограниченный набор разрешений. Умеет рассылать сообщения — не должен удалять контакты.
  • Клиент в любой момент отзывает установку через интерфейс основного сервиса — доступ исчезает немедленно.
  • Все запросы по OAuth логируются с привязкой к oauth_app_id — клиент видит, какое приложение делало запрос.

Оба механизма доступны на одних эндпоинтах api.botix.pro/public/v1/*. API-ключ передаётся как Authorization: Bearer btx_live_…. OAuth access token — тот же Bearer плюс заголовок X-OAuth-App-Id. Различие в учёте и аудите: запросы по OAuth логируются с привязкой к OAuth-приложению — это отдельная колонка oauth_app_id в таблице btx_api_log.

Полный OAuth-flow (Authorization Code with PKCE) реализован в app/Controllers/OauthController.php. Минимальный набор, ниже которого «OAuth» превращается в дырявое решето:

PKCE с code_verifier (43-128 символов, [A-Za-z0-9-._~])
   и code_challenge = base64url(sha256(verifier))
Параметр state — случайный, проверяемый на callback
Authorization code — TTL 10 минут, одноразовый
Access token — TTL 1 час
Refresh token — TTL 30 дней, ротируется при каждом обмене

Без PKCE открывается атака «authorization code interception» (RFC 7636). Без state — CSRF на callback. Без ротации refresh-токенов — компрометация одного токена даёт неограниченный доступ. Список scope в BOTIX небольшой и сознательно не дробится мелко: каждый scope — понятная пользователю группа действий; удаление scope из стабильного списка — breaking change.

Реализация заняла около трёх человеко-недель. Документация — developers.botix.pro/oauth с примерами на curl, PHP, Python, Node. Регистрация приложения — app.botix.pro/settings/oauth-apps (для разработчика). Подключённые приложения и их отзыв клиентом — app.botix.pro/settings/connected-apps (для пользователя). Страницы намеренно разнесены: разработчик регистрирует, пользователь даёт согласие. Граница «когда пора OAuth» проста: момент появления первого стороннего приложения. До него OAuth — преждевременная оптимизация; в этот момент — необходимость. Развёрнутый разбор партнёрского контура — API как платформа: OAuth, scope, white-label.

Архитектурная схема

Полная схема interaction'ов для агентства с 47 sub-проектами:

[Партнёр-агентство]
        |
        |---[Partner API-key] -- bulk-операции, создание sub-проектов
        |
        |---[Sub-project 1] -- [API-key 1] -- intra-агентский воркер
        |        |
        |        |-- [Webhook → endpoint агентства] -- основной канал
        |        |
        |        |-- [GET /chats?cursor=...] -- polling fallback
        |
        |---[Sub-project 2] -- [API-key 2]
        |        ...
        |
        |---[Sub-project N]

[Сторонний дашборд от агентства]
        |
        |---[OAuth-app (зарегистрировано агентством)]
                |
                |---[OAuth installation для Sub-project K]
                        |
                        |-- [access_token + refresh_token]
                        |-- Scope: chats:read, messages:read
                        |-- Видно клиенту в /settings/connected-apps
                        |-- Логируется отдельно от API-key запросов

Три независимых канала: partner-key для самого агентства, project API-key для intra-агентских воркеров (например, скрипт массовой рассылки), OAuth — для всех third-party интеграций, которые видит конечный клиент.

Что отвергли по дороге

Один тип ключа на все случаи (partner-master-key со всеми правами). Удобство для разработки, катастрофа для безопасности. Любой leaked-ключ открывает доступ ко всему партнёрскому контуру. Восстановление — недели. Отказались.

Только OAuth, без API-ключей. Современно, но создаёт лишнюю сложность для собственных бэкендов агентства. Когда вы владеете ключом сами — танец с access/refresh-токенами не даёт выгоды, только лишний код. API-ключи — для тех, кто действует от своего имени. OAuth — для тех, кому пользователь дал разрешение.

Webhook без альтернативы. «Архитектурно чисто», но падает на первом же «у нас локальный dev без туннеля, как тестировать». Polling через cursor закрывает этот зазор без потерь для production.

Polling как основной канал. «Просто», но не масштабируется. Для одного бота с низким лимитом работает. Для 47 — rate-limit через несколько минут, интеграция стоит.

Multi-tenant через API gateway без концепции sub-проекта. Внешне аккуратно: один эндпоинт /v1/{project_id}/.... Внутри — каша: у каждого sub-проекта свои webhook'и, настройки, биллинг. URL-подход не отражает иерархию. Лучше — sub-проект как первоклассная сущность в БД и API.

Сколько это стоило

Полная разработка трёх решений заняла около четырёх человеко-месяцев:

  • Multi-tenant и партнёрский уровень API: 2 человеко-месяца. Включая БД, миграции, RBAC, тесты на изоляцию sub-проектов.
  • Webhook delivery + retry + signature + dashboard: 1 человеко-месяц. Включая встроенный webhook-инспектор webhook.botix.pro (TTL 7 дней, аналог webhook.site).
  • OAuth 2.0 Authorization Code with PKCE: 3 человеко-недели. Включая страницы согласия, кабинет управления, аудит.

Это много, и каждое решение можно отложить. Но тогда первое же серьёзное агентство ушло бы к конкуренту с открытыми OAuth-возможностями. Это инвестиция в формат «платформа», а не «поставщик».

Что проверить интегратору

Если вы агентство или ISV, оценивающий платформу для multi-tenant работы:

  1. Спросите концепцию sub-проекта — отдельная сущность или эмуляция через флаги.
  2. Спросите про partner-уровень ключа с правом создавать sub-проекты через API. Если нет — каждый новый клиент ручной шаг, и при 100+ клиентах это узкое место.
  3. Проверьте retry-механизм webhook'ов — экспоненциальный, сколько попыток, есть ли delivery-dashboard.
  4. Проверьте изоляцию: доступен ли WHERE project_id = ? на уровне ответа и отдаёт ли API 404 (не 403) на чужой ресурс.
  5. Спросите про OAuth 2.0 с PKCE и страницей согласия в кабинете клиента. Если предлагают «обходные пути» через ваш API-ключ — OAuth ещё не реализован, third-party-интеграции для клиента вы делать не сможете.