Logo
Overview

Версионирование REST API: URL, заголовки, стратегии эволюции

October 21, 2026
10 min read

Версионирование REST API — это не про то, чтобы пририсовать /v2 к URL и отчитаться на дейли. Это про то, как менять API, не роняя клиентов, которые работают на нём уже полгода и обновляться не планируют.

Поначалу иллюзия проста: клиентов один-два, оба внутренние, деплоим вместе. Добавили поле в ответ — поправили в двух местах, развернули. Проходит год. Клиентов двадцать, три из них — внешние партнёры с релизным циклом раз в квартал. Вы добавляете обязательное поле в ответ GET /orders. Код партнёра, который парсил жёстко по индексам, ложится. Звонок в три часа ночи, саппорт пишет, вы делаете ретроградный мерж и теряете полдня.

Если с правилами названий ручек вы уже знакомы и с основными принципами REST API разобрались — версионирование следующий логичный шаг.

Когда версионирование нужно, а когда это паранойя

Не каждое изменение в API требует новой версии. Добавление поля в ответ — обратно совместимо: старый клиент его проигнорирует. Новый эндпоинт — тоже не ломает ничего. А вот что реально убивает обратную совместимость:

  • Удаление поля из ответа. Клиент парсил "discount" — а его больше нет. Парсер падает.
  • Изменение типа поля. Было "count": 5 (число) — стало "count": "5" (строка). Для слаботипизированного клиента вроде JS-фронтенда разницы может и не быть. Для строготипизированного — гарантированный exception.
  • Изменение семантики. POST /orders раньше создавал заказ сразу, теперь требует подтверждения и возвращает 202 Accepted вместо 201 Created. Клиент, ожидавший 201, считает, что заказ не создался, и шлёт повтор.
  • Удаление эндпоинта. Очевидно.
  • Добавление обязательного параметра. GET /orders работал без ?status=, теперь требует — старый код не передаёт параметр и получает 400.

Правило простое: если клиент, написанный год назад, отработает с новым API без правок — версионировать не надо. Достаточно пристроить новое и пометить старое как deprecated. Если не отработает — готовьте следующую версию.

Оговорюсь: это не серебряная пуля. Я видел команды, которые мажорили версию ради смены дефолтного таймаута с 30 на 60 секунд. Это как стрелять из пушки по воробьям: больше затрат на поддержку двух параллельных версий, чем реальной пользы.

Три способа версионирования REST API

Как именно сообщить серверу, какую версию клиент хочет? Ответ — в любом из трёх мест HTTP-запроса. Разбираться начнём с самого популярного.

Версионирование через URL

GET /api/v1/orders
GET /api/v2/orders

Самый распространённый способ. Версия зашита прямо в путь — её видно в логах, в curl, в инструментах мониторинга. Swagger спокойно генерит документацию для каждой версии отдельным файлом (или группирует по тегам). API Gateway тривиально рулит запросы: prefix: /v1/ → старый бэкенд, prefix: /v2/ → новый.

Минусы ровно те же, что и плюсы. URL меняется — это новый ресурс по стандартам REST, хотя семантически это тот же самый заказ. Кэши по v1/orders и v2/orders живут отдельно, хотя данные физически одни и те же. При переходе с v1 на v2 клиент обязан поменять все URL в коде, а не только конфигурацию заголовков. И если вы держите 5 мажорных версий — у вас 5 копий всего роутинга и документации.

Когда осмысленно: публичное API, внешние партнёры, простая маршрутизация через API Gateway.

Версионирование через HTTP-заголовки

GET /orders
Accept-Version: v1

Или кастомный заголовок — X-API-Version: 2, Api-Version: 2024-01. URL остаётся чистым, ресурс один. Версионирование ложится в конфигурацию HTTP-клиента, а не размазывается по всем URL.

Плата: дебаг усложняется. В логах запрос выглядит как GET /orders, и без копания в заголовках непонятно, v1 это или v2. Swagger требует допиливания, чтобы развести документацию по версиям через селектор. Кэширование: заголовок Vary: Accept-Version в ответе заставляет CDN и браузеры учитывать версию при определении уникальности кэша. Забыли Vary — получили скрещивание данных v1 и v2 в одном кэш-ключе. Боль.

Когда осмысленно: внутренние сервисы, все клиенты под вашим контролем, есть дисциплина в команде.

Версионирование через Content Negotiation (Accept)

GET /orders
Accept: application/vnd.myapi.v1+json

Самый «академически правильный» способ. Тип контента (media type) включает версию. Ни URL, ни кастомные заголовки не страдают. Стандарт HTTP это поддерживает нативно — RFC 6838.

На практике этим почти никто не пользуется. Причина проста: неудобно. Каждый вызов требует указывать длинный Accept-заголовок с вендорным медиатипом. Отладка через curl превращается в упражнение по набору -H 'Accept: application/vnd.myapi.v2+json' каждый раз. CDN и промежуточные прокси не понимают вендорных типов и кэшируют как попало. Swagger требует ручной настройки разделения схем.

Я за всю практику видел такой подход один раз — в API крупной финтех-компании, где каноничность REST была принципиальной позицией архитектора. Остальные 99% команд в какой-то момент переезжали на URL или заголовки, потому что поддержка content negotiation съедала больше времени, чем само версионирование.

Когда осмысленно: если у вас в команде есть человек, который готов защищать этот подход перед разработчиками, тестировщиками и техподдержкой каждый спринт.

Дерево решений: какую стратегию выбрать

Вместо того чтобы пересказывать плюсы-минусы словами — схема. Проходите по стрелкам в зависимости от вашего контекста.

100%
graph TD
  START["Выбор стратегии версионирования API"] --> Q1{"Обратно совместимое изменение?"}
  Q1 -->|"Да"| EVOLVE["Эволюция без смены версии
добавление полей, новые эндпоинты"]
  Q1 -->|"Нет — ломает клиентов"| Q2{"Кто потребители API?"}
  Q2 -->|"Внешние партнёры, открытое API"| URL["URL-версия: /v1/ /v2/"]
  Q2 -->|"Внутренние сервисы"| Q3{"Есть API Gateway?"}
  Q3 -->|"Да"| GATEWAY["Версионирование на шлюзе
роутинг по заголовкам"]
  Q3 -->|"Нет"| Q4{"Клиенты готовы менять код?"}
  Q4 -->|"Да"| URL2["URL-версия: /v1/ /v2/"]
  Q4 -->|"Нет — хотят прозрачности"| HEADER["Заголовочное версионирование
Accept-Version"]
  EVOLVE --> DEPR["Настроить депрекацию
для устаревших полей"]
  URL --> DEPR
  GATEWAY --> DEPR
  URL2 --> DEPR
  HEADER --> DEPR
  style START fill:#7b68ee,stroke:#5a4db2,color:#fff
  style Q1 fill:#f0a500,stroke:#c88400,color:#fff
  style Q2 fill:#f0a500,stroke:#c88400,color:#fff
  style Q3 fill:#f0a500,stroke:#c88400,color:#fff
  style Q4 fill:#f0a500,stroke:#c88400,color:#fff
  style EVOLVE fill:#50c878,stroke:#3a9a5c,color:#fff
  style URL fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style GATEWAY fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style URL2 fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style HEADER fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style DEPR fill:#e0e0e0,stroke:#999,color:#333

Диаграмма показывает главную развилку. Обратно совместимые изменения (зелёная ветка) — едем без версионирования, просто добавляем новое. Ломающие (синие узлы) — выбираем стратегию в зависимости от аудитории API и инфраструктуры. Внешние партнёры почти всегда диктуют URL-версии: им нужен curl, Postman и Swagger без танцев с заголовками. Внутри своей компании — пространство для манёвра шире.

Конечная точка у всех стратегий одна: настройка депрекации устаревших полей и версий. К этому вернёмся через пару разделов.

Обратная совместимость: как эволюционировать без /v2

Не каждый «слом» требует мажорной версии. Часто достаточно соблюдать несколько правил при изменениях — и API живёт без версионирования годами. Вот что работает на практике.

Добавлять поля — да. Новое поле в JSON-ответе не ломает клиента, который его не ждёт. Клиент парсит то, что знает, остальное игнорирует. Это базовый принцип, но я видел, как команды всё равно мажорили версию ради поля middleName — от перестраховки.

Удалять поля — только через депрекацию. Хотите выпилить "legacyDiscount" из ответа? Сначала задокументируйте, что поле устарело, добавьте в ответ заголовок Deprecation: true (или Sunset), продержите месяц-два. Только потом удаляйте. Или просто никогда — если поле не создаёт проблем, пусть висит.

Менять тип поля — никогда. Это ломающая операция, тут только новая версия.

Добавлять эндпоинт — свободно. GET /orders/\{id\}/history не трогает существующие URL.

Удалять эндпоинт — через 410 Gone. Сначала возвращайте 410 с телом, объясняющим миграцию, потом удаляйте.

Менять обязательность параметров — добавлять можно, убирать нельзя. Сделали необязательный параметр обязательным — всё, кто не передавал, получают 400. Разворачивайте.

Отдельный нюанс — enum-значения. Добавили "ARCHIVED" в статус заказа? Если клиент жёстко проверяет соответствие enum’у — он упадёт на незнакомом значении. Если просто выводит строку — переживёт. Совет: в контракте явно пишите, что клиент должен толерантно обрабатывать неизвестные значения enum’ов. Не все читают контракты, но те, кто читают, скажут спасибо.

Жизненный цикл версии: от запуска до выключения

Версию мало запустить. Её нужно сопроводить до выключения — и вот тут начинается то, о чём забывают в 80% проектов.

100%
sequenceDiagram
  participant Dev as Команда разработки
  participant API as API-сервер
  participant Old as Старый клиент v1
  participant New as Новый клиент v2
  Note over API: Версия v1 активна
  Old->>API: GET /v1/orders
  API-->>Old: 200 OK
  Note over Dev,API: Запуск v2 и депрекация v1
  Dev->>API: Добавлен заголовок Sunset
  Old->>API: GET /v1/orders
  API-->>Old: 200 OK + Warning 299
  New->>API: GET /v2/orders
  API-->>New: 200 OK
  Note over Old,New: Период миграции (1-3 месяца)
  Old->>API: GET /v1/orders
  API-->>Old: 200 OK + Sunset
  New->>API: GET /v2/orders
  API-->>New: 200 OK
  Note over API: Дата Sunset прошла
  Old->>API: GET /v1/orders
  API-->>Old: 410 Gone

Диаграмма показывает три фазы. Первая — v1 живёт и работает. Вторая — запущена v2, v1 объявлена устаревшей: каждый ответ содержит заголовок Sunset с датой отключения и Warning: 299 с описанием. Период миграции — минимум месяц, а для внешних партнёров лучше три. Третья фаза — после даты Sunset: v1 возвращает 410 Gone (или 301 Moved Permanently с редиректом на v2, если семантика совместима).

Трюк с Sunset-заголовком — RFC 8594, поддержан не всеми клиентами, но хотя бы в документации и в ответах API он должен быть. Клиент, который читает заголовки, увидит дату отключения и запланирует миграцию. Клиент, который не читает — всё равно не заметит, пока не упадёт на 410. Но это уже не ваша проблема.

Sunset на практике

HTTP/1.1 200 OK
Sunset: Sat, 01 Feb 2027 00:00:00 GMT
Warning: 299 - "v1 устарела, переходите на v2. Документация: https://api.example.com/docs/v2/migration"

Пара замечаний из опыта:

  • Деплойте Sunset отдельно от запуска v2. Сначала v2 появляется и работает без пометок о v1. Убедились, что v2 стабильна — только тогда объявляете дату отключения старой версии.
  • Предупреждайте клиентов не только через заголовок. Email, changelog, личное сообщение в Slack/Telegram ключевым партнёрам. Заголовок — это минимум, а не единственный канал.
  • Не отключайте сразу после Sunset. Если клиент ещё шлёт запросы в v1 — дайте неделю-две «песочницы», где 410 приходит с телом-объяснением и ссылкой на миграцию. Жёсткое выключение без объяснений — верный способ получить эскалацию на уровне CTO партнёра.

Таблица: сравнение стратегий версионирования

КритерийURL (/v1/)Заголовок (Accept-Version)Content Negotiation (Accept)
Простота отладкиВысокая: версия в URLНизкая: нужно смотреть заголовкиОчень низкая
Работа с API GatewayТривиально: роутинг по префиксуНужна маршрутизация по заголовкамСложно: кастомные правила
Swagger/OpenAPIГенерится из коробкиНужен селектор версийРучная настройка
Чистота URLНизкая: /v1/ — новый ресурсВысокая: URL не меняетсяВысокая
Кэширование (CDN)Разные кэш-ключиVary: Accept-Version обязателенCDN не понимает вендорных типов
Совместимость с RESTСпорная: разные ресурсыСоответствует HATEOASКанонически правильно
Порог входа для клиентаНизкий: curl без доп. флаговСредний: нужен -HВысокий: длинный -H
Когда выбиратьВнешние партнёры, простотаВнутренние сервисыПочти никогда

Чек-лист для аналитика

При проектировании версионирования зафиксируйте в требованиях ответы на эти вопросы. На архитектурном ревью они сэкономят вам и команде полчаса.

  1. Ломает ли изменение обратную совместимость? Если нет — версионирование не нужно, эволюционируйте без смены версии.
  2. Кто потребители API? Внешние партнёры → URL-версии. Внутренние сервисы → можно заголовками.
  3. Сколько версий поддерживать параллельно? Правильный ответ — две: текущая и одна предыдущая. Три и больше — симптом того, что вы боитесь отключать старые версии.
  4. Срок жизни предыдущей версии. Для внешнего API — 3–6 месяцев. Для внутреннего — 1–2 месяца.
  5. Как клиенты узнают о депрекации? Sunset-заголовок + email + changelog. Каналы дублировать.
  6. Что отвечать после отключения версии? 410 Gone с телом-объяснением или 301 с редиректом, если семантика совместима.
  7. Как мониторить, кто ещё сидит на старой версии? Метрики по версиям в Prometheus/Grafana, алерт при остаточном трафике после Sunset.

Главный вывод

Версионирование — это не технический вопрос. Это договорённость с клиентами о том, как вы будете меняться и не ломать им жизнь. URL-версии — честный и прямой способ сказать «API изменился». Заголовки — более элегантный, но требующий дисциплины. Content negotiation — академически безупречный, но на практике почти не используемый.

Ключевое — не стратегия сама по себе, а то, что вы о ней думаете до того, как API пошёл в прод с первым внешним клиентом. Поменять /v1 на заголовки через год после запуска — больно. Выбрать и придерживаться с самого начала — рутина.

PS. Если ваш API до сих пор не имеет ни одной версии и меняется «по согласованию в Slack» — время кончилось. Первый же партнёр, который не читает ваш Slack, превратит деплой в пожар. Я проверял.