Публичный API-контракт: deprecation, rate-limit, white-label
TL;DR: публичный API — это не строка в URL, а контракт, и ломается он дороже, чем кажется. На 23-й минуте после релиза мы получили первое письмо: «у нас вся интеграция в проде сыпется». Мы переименовали `created` в `created_at` в одном эндпоинте — косметика на бумаге, breaking change для 4 SDK. Ниже — три части контракта, за которые платит бизнес: что считается breaking change и зачем 12 месяцев deprecation, как клиент должен делать backoff по `X-RateLimit-*`, и что нужно от API, чтобы отдать его white-label-партнёрам без инцидента на первой неделе.
Коротко
- Breaking change — это не про код, это про обязательство. Переименование поля, ужесточение rate-limit, тихая смена семантики — всё это ломает прод у всех интеграторов сразу. Мы зафиксировали 12 месяцев deprecation и машиночитаемый заголовок
X-API-Deprecation, и это стоит 10–15% ресурса команды API. - Rate-limit нельзя игнорировать и ретраить сразу. Платформа отдаёт
X-RateLimit-Limit/Remaining/Resetв каждом ответе иRetry-Afterна 429. Правильный backoff — это jitter + exponential, а зрелый режим — притормаживать заранее поRemaining, не доводя до 429. - White-label — набор архитектурных свойств, заложенных заранее: отдельный тип ключа, sub-проекты, жёсткая изоляция
WHERE project_id = ?,Auth::assertProjectAccess()в каждом эндпоинте, отдельная документация и партнёрский канал уведомлений с форой в 90 дней.
BOTIX — российская SaaS-платформа AI-ассистентов и чат-ботов. С 2026 года у нас публичный REST API (api.botix.pro/public/v1/*, версия 1.1.0), три открытых SDK на github.com/BOTIX-pro (PHP, Python, Node), CLI и встроенный webhook-инспектор. API подключён к нескольким десяткам внешних команд — и какую цену каждая платит при breaking change, мы знаем с точностью до часа.
Контракт, а не код: почему breaking change стоит человеко-месяцев
Внутри одной команды breaking change в приватном API — разговор в чатике, час правок, деплой. В публичном ситуация другая. Контракт — это уже не код, это обязательство. Когда команда подключается, она в неявной форме подписывается на «этот эндпоинт возвращает такие поля, так выглядят коды ошибок, так пагинируется коллекция». Если поставщик решит, что одно из утверждений больше не верно — у интегратора посыпется прод. Не у одного — у всех, кто хоть раз поставил интеграцию и больше к ней не возвращался. А таких большинство.
История с created → created_at стоила трёх дней: откат миграции БД на копии, hotfix в SDK, рассылка интеграторам, созвон с двумя агентствами. Если бы изменение было заложено в политику и анонсировано за 12 месяцев — это были бы три коммита в changelog. После инцидента мы зафиксировали в developers.botix.pro/versioning документ из трёх разделов: что считается breaking, сколько живёт старая версия, как клиент узнаёт об изменениях. Без этого документа любой публичный API — это не контракт, а серия односторонних обещаний.
Что считается breaking change
Самая дорогая часть документа: каждый пункт связывает руки команде разработки на 12 месяцев вперёд. Breaking change — любое изменение, которое заставит существующий код интегратора сломаться или начать работать иначе без явного действия с его стороны:
- Удаление эндпоинта.
- Удаление поля из ответа.
- Переименование поля (включая косметические — наш кейс с
created). - Изменение типа поля: был
string, сталobject. - Изменение HTTP-статуса: был 200, стал 202.
- Добавление обязательного параметра в существующий запрос.
- Изменение формата идентификаторов: был int, стал ULID.
- Изменение семантики кода ошибки.
- Изменение порядка обработки, видимое через побочные эффекты.
Non-breaking — всё что не влияет на корректность: добавление эндпоинта, опционального параметра, нового поля в ответ (при условии что клиент игнорирует неизвестные поля — это требование контракта, не предположение). Именно так у нас проехала версия 1.1.0: X-RateLimit-*-заголовки, cursor-пагинация (cursor + meta.next_cursor параллельно с page/per_page) и bulk-эндпоинты добавились, ничего не сломав — все эндпоинты v1.0 работают без правок на стороне клиента.
Серая зона — расширение enum. Если ваш OpenAPI описывает enum как закрытое множество, а клиент сгенерировал SDK через openapi-generator со строгой валидацией, добавление нового значения сломает ему парсинг. Либо документировать enum как открытый, либо считать расширение breaking — третьего не дано. Мы выбрали путь «enum в публичном API всегда открытый, клиент обязан graceful fallback на неизвестное значение» (developers.botix.pro/best-practices).
Ещё частая ошибка — считать изменение rate-limit non-breaking. Было 1000 запросов в минуту, стало 100. Структура не меняется, но у интегратора, спроектировавшего воркер под старый лимит, всё встанет. Это breaking: нарушается наблюдаемый контракт. Мы больше не можем «незаметно» ужесточить лимит — обязаны анонсировать снижение за 12 месяцев.
И отдельно — семантический контракт. POST /contacts/import, ранее считавший дубликаты ошибкой, а теперь тихо их пропускающий — это breaking, даже если формат запроса и ответа идентичен. Семантика — часть контракта, её изменения тоже проходят через цикл депрекации.
12 месяцев deprecation — и во сколько это обходится
Индустрия сходится на интервале от 6 до 24 месяцев с момента анонса депрекации. Меньше — неуважение к интегратору, который встроил вас в продакшен; больше — лишние затраты. Мы выбрали 12 и зафиксировали публично: с момента, когда новая мажорная версия объявлена стабильной, старая доступна ещё 12 месяцев и гарантированно не ломается. Безопасностные фиксы при этом продолжают наезжать на обе версии — это уточнение тоже в политике явно.
Сколько это стоит бизнесу:
- Полтора-два разработчика держат в голове две версии контракта одновременно.
- Интеграционные тесты на старой версии в CI — каждый коммит прогоняется по обоим контрактам.
- Документация в двух вариантах: changelog старой версии, migration-гайд между версиями.
- Два набора x-codeSamples в
openapi.yaml.
При команде из 5 инженеров 12-месячная поддержка съедает 10–15% ресурса разработки API — постоянная фоновая нагрузка. 6 месяцев — половина крупных агентств ушла бы к другому поставщику; 24 — нагрузка вдвое, пришлось бы поднимать тарифы. Это не «инженерная норма», это бизнес-решение в строке политики. Как проходить саму миграцию между мажорами без простоя интегратора — разбираем отдельно: миграция с v1 на v2 без downtime.
Как клиент узнаёт: заголовки депрекации и SDK
Минимум для любого публичного API: публичный changelog с RSS (developers.botix.pro/changelog/rss), email-подписка на анонсы, HTTP-заголовок депрекации в ответах API. Последнее особенно важно — если разработчик не следит за блогом и не читает email, единственный шанс достучаться это сам API. Каждый ответ депрекейтнутой версии содержит два заголовка:
X-API-Deprecation: version=1.0.0; sunset=2027-05-22; replacement=/public/v2
X-API-Notice: information=trial_quota_80pct; expires_at=2026-06-01T00:00:00ZX-API-Deprecation — формальный машиночитаемый сигнал о выводе версии. X-API-Notice — информационные уведомления, не привязанные к депрекации («через час окно техобслуживания», «80% месячного лимита»).
Реализация на клиенте — три строки. PHP из нашего SDK (github.com/BOTIX-pro/sdk-php, src/Http/Response.php):
public function checkDeprecation(): void
{
$deprecation = $this->headers['X-API-Deprecation'] ?? null;
if ($deprecation === null) {
return;
}
error_log("[BOTIX SDK] API version deprecated: {$deprecation}");
}Без связки «SDK логирует → команда мониторит → заводит задачу заранее» 12-месячный срок — фикция: интегратор узнаёт о sunset за два дня, когда начинает приходить 410 Gone. Минимальный пакет на каждую новую версию: запись в changelog с конкретным списком изменений, migration-гайд с примерами «было/стало» на всех языках SDK, дата релиза, дата начала депрекации, дата отключения. У нас это собрано в developers.botix.pro/migration-guide, RSS специально оставлен — у команд, встраивающих API в прод, обычно уже есть feed-агрегатор или Slack-бот.
Что отвергли: URL против header против content-type
Версионирование через URL (/v1/, /v2/). Самый популярный подход. Отказывались от него неохотно: дублирование кода в маршрутизаторе, каждая версия требует новой документации, snippet'ов, CI-пайплайна.
Через Content-Type (application/vnd.botix.v1+json). Технически чище, но Swagger UI 5.x плохо живёт с content-negotiation — Try-It-Out превращается в неработающий артефакт.
Одновременно URL и header. Создаёт неопределённость: какой контракт работает, если в URL /v1/, а в заголовке Accept-Version: 2. Матрица из четырёх состояний на эндпоинт.
Итого. Оставили URL-версионирование (api.botix.pro/public/v1/*), без content-negotiation, без header-версионирования. Один способ узнать версию — путь. И не прятать breaking changes в патч-релизах «улучшение надёжности», не делать silent deprecation, не относиться к политике «гибко» под запрос: правило 12 месяцев одинаково для всех, включая самого крупного клиента. Как только крупный получит исключение, остальные узнают — и доверие пропадёт.
Rate-limit: как клиент должен делать backoff по X-RateLimit-*
Второй пункт контракта, который ломают чаще всего на стороне клиента. Любой публичный API ограничивает число запросов на ключ за единицу времени — иначе один неоптимальный воркер парализует сервис для всех. Стандартная реакция на превышение — HTTP 429 Too Many Requests. Видя 429, многие SDK и интеграции считают своим долгом «повторить запрос», и здесь начинается недоразумение: повторить сразу — второй 429; в цикле — десятый; параллельным воркером — лавина ретраев, выживающая все попытки rate-limiter'а удержать клиента. Корректная обработка — это набор операций над заголовками, которые платформа отдаёт в каждом ответе, не только в 429.
Три заголовка в каждом успешном ответе и четвёртый в 429 (индустриальный стандарт — Stripe, GitHub, Twitter, Cloudflare):
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1716392460Limit — сколько запросов допускается в текущем окне (обычно minute). Remaining — сколько осталось. Reset — Unix-timestamp обнуления счётчика. При 429 добавляется четвёртый:
Retry-After: 13Это явная команда — «не пытайся раньше чем через 13 секунд» (RFC 7231 §7.1.3, поддерживается во всех нормально написанных HTTP-клиентах). У нас все четыре заголовка реализованы в PublicApiAuth middleware (см. app/Middleware/PublicApiAuth.php). Лимит берётся из btx_plans.limits.api_rate_per_minute (триал — 30/мин, бизнес — 2000/мин), счётчик хранится в Redis с TTL до конца текущей минуты. При превышении дневного лимита api_rate_per_day эндпоинт отдаёт код ошибки RATE_LIMIT_EXCEEDED.
Минимально жизнеспособная реализация — читать Retry-After при 429 и спать столько, сколько сказала платформа:
def call_api(method, url, **kwargs):
while True:
response = requests.request(method, url, **kwargs)
if response.status_code != 429:
return response
time.sleep(int(response.headers.get('Retry-After', 1)))Это уже лучше, чем «retry-immediately». Но при нескольких параллельных воркерах возникает проблема: все разом упёрлись в 429, все получили Retry-After: 13, все разом проснулись и выдали залп. Это *thundering herd*. Чтобы его избежать, к Retry-After добавляется случайная компонента (jitter), а backoff растёт при повторных 429 (exponential):
import time
import random
import requests
def call_api_with_backoff(method, url, max_retries=5, **kwargs):
attempt = 0
while attempt < max_retries:
response = requests.request(method, url, **kwargs)
if response.status_code != 429:
return response
retry_after = int(response.headers.get('Retry-After', 1))
jitter = random.uniform(0, retry_after * 0.3)
backoff = retry_after * (2 ** attempt) + jitter
time.sleep(min(backoff, 60))
attempt += 1
raise RuntimeError(f'Rate limit not cleared after {max_retries} retries')Здесь 2 ** attempt даёт экспоненциальный рост (1×, 2×, 4×, 8×, 16×), jitter размазывает повторы по времени, min(…, 60) — потолок, чтобы не зависнуть надолго. После N неудачных попыток функция падает с ошибкой — важно, чтобы при систематической проблеме воркер не висел молча. В tenacity та же логика встроена через @retry(stop=stop_after_attempt, wait=wait_exponential_jitter), у urllib3.util.Retry есть backoff_factor и respect_retry_after_header. Готовое — лучше своего, если не хочется поддерживать собственный код.
Более зрелый паттерн — реагировать не после 429, а следить за X-RateLimit-Remaining в каждом ответе и притормаживать заранее:
def maybe_sleep(response):
remaining = int(response.headers.get('X-RateLimit-Remaining', 1000))
reset = int(response.headers.get('X-RateLimit-Reset', 0))
if remaining < 5:
wait = max(0, reset - time.time())
time.sleep(wait)
response = requests.get(url, headers=headers)
maybe_sleep(response)
# ... следующий запросКогда осталось меньше 5 запросов в окне — клиент спит до обнуления, и 429 в нормальной работе не возникает совсем. Особенно полезно для batch: воркер по 10 000 контактов при лимите 60/мин даёт ровную скорость 1 запрос в секунду без всплесков, а не серии из 60 запросов с двухминутными паузами.
Когда один ключ используется параллельно несколькими воркерами, локальный счётчик в памяти не отражает реальной картины: воркер A посчитал, что осталось 47, воркер B параллельно сделал ещё 50 — на платформе −3, оба получают 429. Решение — централизованный счётчик: Redis или leaky-bucket-лимитер перед обращением к API (aiolimiter в Python, bottleneck в Node). Для двух-трёх воркеров достаточно, чтобы каждый уважал заголовки; при десяти и больше — централизованный счётчик обязателен.
Чего не делать: не подменять Retry-After константой «жди 5 секунд всегда» — платформа знает точное время окна, клиент нет. Не путать 429 с другими 4xx — 429 «попробуй позже», 401 «ключ невалиден, повторять бесполезно», 422 «запрос некорректен, повторять бесполезно»; retry срабатывает только на 429 (и опционально на 503 с Retry-After). Как это ложится на идемпотентные POST — в разборе почему без Idempotency-Key API не production-ready, а как отделить 429 от остального в контракте ошибок — что должно быть в error response. И не обходить лимит ротацией ключей: ключи привязаны к project_id, лимит считается на уровне ключа в проекте — нужно больше, это сигнал к смене тарифа. Все три языка SDK (botix-pro/sdk, botix, @botix/sdk) делают exponential backoff с jitter автоматически; на чистом HTTP-клиенте описанный алгоритм работает без модификаций — заголовки присутствуют в каждом ответе.
White-label через API: что нужно, чтобы стать платформой
Третий и самый дорогой уровень контракта. Один эндпоинт /contacts не делает API платформой. Платформа начинается там, где появляется второй уровень: партнёр — агентство под своим брендом, ISV со своим SaaS, системный интегратор — встраивает наш API внутрь своего продукта и продаёт дальше. Каждый breaking change тогда ломает не одну интеграцию, а всех конечных клиентов партнёра — десятки или сотни. Партнёр не может «просто обновиться»: ему нужно протестировать на всех клиентах, выкатить, отработать инциденты. Это месяцы работы для агентства из 5 человек.
У прямого клиента контур простой: один аккаунт, один проект, несколько ключей, один webhook. У партнёра — многоуровневый: сам партнёр имеет аккаунт, внутри него десятки конечных клиентов, у каждого свой логический проект со своими данными, биллингом, webhook'ом. Обычный ключ работает в контексте одного проекта — партнёрский должен работать в контексте партнёрского аккаунта и переключаться между конечными. Скрыть это под одним эндпоинтом — путь к ошибкам. Минимальный набор архитектурных свойств, который мы закладываем под партнёрский слой:
- Партнёрский уровень аутентификации — отдельный тип ключа (partner key) с более высокими привилегиями, чем у обычного project key: создавать новые проекты, перечислять существующие, получать ключи для каждого. Обращение к партнёрским операциям с обычным ключом возвращает 403.
- Sub-проекты под одним партнёрским аккаунтом — иерархия «партнёр сверху, конечные проекты снизу», каждый со своими данными, webhook'ом, биллингом.
- OAuth installations с реселлерскими scope — чтобы OAuth-приложение, установленное в партнёрский аккаунт, видело все sub-проекты, а не только тот, в который установлено напрямую.
- Биллинг-события через API — партнёр биллит по своей модели, ему нужны сырые данные (количество запросов, сообщений, активных контактов по каждому sub-проекту за период): либо отдельный эндпоинт usage, либо webhook-событие в конце расчётного периода.
- Audit-логи на партнёрском уровне — когда конечный клиент пишет «что-то не работает», партнёр диагностирует сам, без привлечения поставщика (cursor, polling и audit-логи).
- Кастомные домены и брендирование — сам API кастомного домена не требует; если в партнёрском решении есть UI под брендом партнёра, поставщику нужны white-label-домены. У нас этот пункт пока в плане — текущая партнёрская работа идёт через API без кастомного фронтенда.
Критическая граница — изоляция данных. Внутри одного партнёрского аккаунта могут быть sub-проекты конкурирующих компаний; возможность одного увидеть данные другого — катастрофа. Граница держится на трёх уровнях: на уровне БД — WHERE project_id = ? в каждом запросе; на уровне API — явный project_id в каждом запросе и проверка, что он принадлежит партнёрскому аккаунту; на уровне ключей — нельзя получить ответ по одному sub-проекту, передав ключ другого. У нас Auth::assertProjectAccess($projectId) вызывается в начале каждого эндпоинта, работающего с проектом:
// PublicApi\V1\<Resource>Controller — начало каждого метода
$this->requireScope('contacts:read');
Auth::assertProjectAccess($projectId);Без этой защиты данные потекут между проектами на первой же ошибке в коде. Партнёрский уровень добавляет слой: проверяется не только доступ к sub-проекту, но и то, что sub-проект принадлежит партнёрскому аккаунту, выпустившему ключ.
И отдельная фиксация в политике: партнёрам breaking change анонсируется не только в публичный changelog, но и в partner-channel (email + Telegram-канал) за 90 дней до публичного анонса. У партнёров есть три месяца форы. Без этого правила партнёрская модель не работает: партнёр готов жить с тем, что API меняется, но не готов узнавать об изменении в один день со всеми. Готовность к партнёрской работе оценивается за полчаса по чек-листу: отдельный тип ключа с расширенными правами? концепция sub-проектов? WHERE project_id = ? в каждом запросе? Auth::assertProjectAccess() в начале каждого эндпоинта? отдельные scope для партнёров в OAuth? способ получить сырые usage-данные? аудит-журнал по всем sub-проектам? отдельная документация и Postman-коллекция? Если «нет» начиная с третьего пункта — API технически не готов. Партнёрский API не навешивают на обычные эндпоинты флагами «если ты партнёр, передай ещё параметр» (запутанная семантика — лучше отдельный префикс /partner/v1/*), не выдают «по запросу» избранным по NDA (работает пока партнёров три, при двадцати начинаются теневая документация и техдолг) и не ставят высокий порог входа: на старте партнёрский слой — это маленькие команды, из которых вырастают крупные.
Тенденция в индустрии
Stripe держит 12-месячный sunset как стандарт больше пяти лет. GitHub Apps работают по схожей политике. AWS поддерживает версии SDK по 24–36 месяцев, Twilio — 12–24. На этом фоне мы с 12 месяцами — посередине спектра. Чем серьёзнее компания относится к публичному API как к продукту, тем длиннее sunset и тем раньше в архитектуру заложены rate-limit-заголовки и партнёрский слой. Решение о white-label — продуктовое, не техническое: не каждый API должен быть платформой. Но если решение принято, семь свойств закладывают с самого начала, а не на третьей итерации после первых жалоб.
Попробуйте API BOTIX
Получите ключ, отправьте первый запрос и поймайте webhook — прямо в браузере, без боевого канала.
Попробовать API бесплатноЧитайте также
Миграция клиентов с v1 на v2 публичного API без downtime
Production-ready POST: idempotency, retry и rate-limit
«Мелочи» публичного API: ошибки, cursor, rate-limit
Cursor-пагинация, polling vs webhook, audit-логи: три паттерна чтения из публичного API
