{
  "openapi": "3.1.0",
  "info": {
    "title": "VPN Reseller API",
    "description": "Публичный API для партнёров платформы: выдавайте VPN-подписки своим пользователям из своего сайта, бота или биллинга. Оплата с предоплаченного депозита по вашей закупочной цене, розницу назначаете вы. Ключ выпускается в кабинете партнёра, раздел «Ключ и вебхук»: hexpartners.pro/api/vpn/app/key.\n\n## Быстрый старт\n\n1. В кабинете, в разделе «Ключ и вебхук», выпустите ключ и пополните депозит.\n2. `GET /plans` покажет тарифы и цены в USD.\n3. `POST /subscriptions` с `plan_uuid`, вашим `external_user_id` и новым `custom_id` вернёт подписку, а в ней `subscription_url`.\n4. Отдайте ссылку пользователю: он вставляет её в VPN-клиент (INCY, Happ, v2rayTun, Hiddify, Streisand).\n5. Укажите адрес вебхука (в кабинете или `PUT /webhook`) и получайте `subscription.expires_in_24h`, чтобы вовремя предложить продление.\n\nПервый запрос, чтобы проверить ключ:\n\n```bash\ncurl https://api.relayhub.surf/api/v1/vpn/plans \\\n  -H \"Authorization: Bearer $VPN_API_KEY\"\n```\n\nОтвет 200 со списком `plans` значит, что ключ работает. 401 `unauthorized` значит, что ключ не тот или в заголовке опечатка. Все вызовы по шагам, с телами запросов и полями ответов, собраны в главе «Сценарии».\n\nГотовые файлы для импорта: [спецификация OpenAPI](https://hexpartners.pro/vpn-api/openapi.json) (Postman, Insomnia, Bruno, генераторы клиентов) и [коллекция Postman](https://hexpartners.pro/vpn-api/postman.json) с переменными `base_url` и `api_key`.\n\n## Песочница\n\nПроверить интеграцию целиком можно без настоящих денег. В кабинете, раздел «Песочница», выпустите тестовый ключ: он начинается с `sb_` и ходит на те же адреса `/api/v1/vpn/*`. Ответы такие же, как в бою, с заголовком `X-Sandbox: true`, а данные свои: настоящие деньги не списываются, запросы к поставщику не идут, боевые подписки тестовому ключу не видны, тестовые боевому тоже. Цены и тарифы настоящие, поэтому суммы совпадут с боем.\n\n**Тестовый депозит** до $1 000. Пополняется кнопкой в кабинете или `POST /deposit` с провайдером `sandbox`: такой счёт оплачивается сам через 10 секунд и присылает `deposit.credited`. Нехватку денег проверяйте честно: потратьте депозит, и покупка ответит 402 `insufficient_balance`.\n\n**Все операции** работают как в бою: покупка на срок и «Навсегда», пробный период, продление, апгрейд, трафик, докупки, заморозка, ссылка, устройства (выдуманные), `GET /operations`, повтор с тем же `custom_id`. Ссылка подписки открывается, но к VPN не подключает.\n\n**Имитация сбоев.** Заголовок `X-Sandbox-Simulate` на любом платном запросе:\n\n- `upstream_unavailable`: 503 с `Retry-After`, деньги не списаны, повтор с тем же `custom_id` выполнится;\n- `lost_response`: операция прошла, а ответ 503; повтор с тем же `custom_id` вернёт результат с `idempotent_replay: true`. Главная проверка, что ваш клиент не покупает второй раз;\n- `slow`: ответ через 8 секунд, проверка ваших таймаутов;\n- `in_progress`: 409 `operation_in_progress`.\n\n**Машина времени.** `POST /sandbox/subscriptions/{id}/advance` с телом `{\"hours\": 700}` сдвигает срок подписки и шлёт те же события, что в бою: `subscription.expires_in_72h`, `_48h`, `_24h` и `subscription.expired`. В ответе список отправленных событий. Работает только с тестовым ключом.\n\n**Вебхук** свой: отдельный адрес и секрет в кабинете, подпись `X-Signature-V2` та же, в теле `sandbox: true`, в заголовках `X-Sandbox: true` и `User-Agent: vpnfranchise-webhooks-sandbox/1`. Ответ ждём 3 секунды. Повторов три (через 1, 5 и 15 минут), лента `GET /events` и журнал `GET /webhook/deliveries` тоже свои. Боевой приёмник пусть проверяет подпись боевым секретом: тестовое событие она не пройдёт.\n\n**Пределы.** Не больше 600 изменяющих запросов в час (дальше 429 `rate_limited`), до 1 000 подписок. События и операции хранятся неделю: повтор с тем же `custom_id` позже выполнится заново. Тестовые данные, к которым не обращались 30 дней, стираются целиком. Стереть их сразу можно кнопкой в кабинете: ключ и адрес вебхука останутся.\n\n**Прежде чем идти в бой**, прогоните в песочнице: покупку, повтор после `lost_response`, отказ `upstream_unavailable` с ожиданием `Retry-After`, 402 при пустом депозите, приём вебхука с проверкой подписи, окончание срока через машину времени. Потом замените ключ на боевой.\n\n## Термины\n\n- **Подписка на срок**: обычная подписка на 14, 30, 90, 180 или 365 дней. Её можно продлевать, замораживать и менять тариф.\n- **«Навсегда»**: бессрочная подписка. Она продаётся отдельным блоком `lifetime` в `GET /plans`, а её докупки идут через `/addons`.\n- **Лимитные серверы**: серверы обхода белых списков. На обычных серверах трафик безлимитный, на лимитных действует лимит `traffic_limit_bytes`. «Купить белые списки» значит «докупить трафик».\n- **Депозит**: ваш предоплаченный счёт. Покупки через API списываются с него по закупочной цене.\n- **`external_user_id`**: идентификатор человека в вашей системе.\n- **`custom_id`**: ключ операции, который придумываете вы. По нему повтор запроса не списывает деньги второй раз.\n- **`subscription_url`**: ссылка, которую человек вставляет в VPN-клиент.\n- **`bot_id`**: UUID вашего бота. Нужен только если пользователь должен увидеть подписку в мини-приложении бота.\n\n## Авторизация\n\nКаждый запрос несёт заголовок `Authorization: Bearer <ключ>`. Ключ показывается один раз при выпуске, у нас хранится только его хэш. Ключ тратит ваши деньги: храните его в переменной окружения, не в коде и не в логах, и вызывайте API только со своего сервера, а не из браузера или приложения пользователя.\n\n**Перевыпуск без простоя.** В кабинете (раздел «Ключ и вебхук») при перевыпуске можно оставить прежний ключ рабочим ещё на 1, 24 или 72 часа: за это время замените его во всех скриптах. Без перекрытия прежний ключ гаснет сразу. Отозванный ключ отвечает 401 `key_revoked`, а не общим `unauthorized`.\n\n**Ключ только для чтения.** Рядом с полным ключом можно выпустить второй, только для GET-запросов: списки, карточки подписок, баланс, операции, события. Покупки, продления и любые изменения он не делает (403 `key_read_only`). Удобен для дашбордов, аналитики и подрядчиков, которым незачем тратить депозит.\n\n**Привязка к адресам.** В кабинете можно задать до 20 адресов и подсетей (`203.0.113.7`, `203.0.113.0/24`, IPv6 тоже), с которых принимаются ключи. Запрос с другого адреса получит 403 `ip_not_allowed`, адрес будет в тексте ошибки. Список меняется только в кабинете: утёкший ключ не снимет ограничение сам.\n\nЛимит: 60 запросов в минуту на ключ. Каждый ответ после проверки ключа, и успешный, и с ошибкой, несёт `X-RateLimit-Limit` (лимит), `X-RateLimit-Remaining` (сколько осталось в текущей минуте) и `X-RateLimit-Reset` (через сколько секунд освободится ближайший запрос). Превышение отвечает 429 с теми же заголовками и `Retry-After`.\n\n## Формат ответов\n\nТело ответа всегда JSON. Успешный ответ содержит `\"success\": true` и данные. Отказ приходит с HTTP-статусом 4xx или 5xx и телом `{\"error\": \"текст\", \"error_code\": \"snake_case\", \"doc_url\": \"...\", \"request_id\": \"...\"}`: `doc_url` ведёт на разбор этого кода в главе «Ошибки», `request_id` это номер запроса.\n\nУ каждого ответа есть заголовок `X-Request-ID`. Пишите его в свои логи, а при обращении в поддержку называйте: по нему запрос находится за секунды. Можно прислать свой `X-Request-ID` в запросе, тогда ответ вернёт его же.\n\nДаты приходят строкой ISO-8601 в UTC с суффиксом `Z`, например `2026-09-02T12:00:00Z`. Исключение: `first_seen` и `last_seen` у устройств поставщик отдаёт со смещением `+00:00`, это тоже UTC.\n\nСуммы приходят парой `*_minor` и `*_usd`, подробно в главе «Деньги».\n\n## Версионирование\n\nВсе пути начинаются с `/api/v1/vpn`. Новые поля в ответах добавляются без смены версии: не ломайтесь на незнакомых ключах. Удаление или переименование поля выйдет только под `/v2` с предупреждением в кабинете.\n\n## Деньги\n\nВсе суммы в долларах США и приходят парой: `*_minor` (целые центы, по ним считайте) и `*_usd` (строка «10.25», её показывайте). Цена тарифа это ваша закупка: себестоимость поставщика плюс наценка платформы. Цены подписок на срок от курса рубля не зависят. Исключение одно: бессрочная подписка закреплена в рублях (`lifetime.price_display_minor`), и в долларах она списывается по курсу на момент покупки.\n\nДепозит состоит из двух карманов: `paid` (внесено пополнениями, вывести нельзя) и `earned` (заработано на продажах в ботах платформы, выводится). Покупки через API списываются сначала с `paid`, потом с `earned`; `spendable` это их сумма. Нехватка средств отвечает 402 `insufficient_balance` до обращения к поставщику: подписка при этом не создаётся.\n\nКаждая платная операция возвращает `charged_minor` (сколько списано) и `balance_after_minor` (сколько осталось потратить). Сверяйте свой учёт по ним, а не пересчитывайте цену сами.\n\n## Потолок цены\n\nЦена может измениться между тем, как вы показали её покупателю, и тем, как отправили запрос: тариф подорожал или, у «Навсегда», сдвинулся курс рубля. Чтобы не списать больше показанного, передайте в любой платной операции `max_charge_minor`: сумму в центах, которую готовы списать. Если цена выше, операция не выполняется, ответ 409 `max_charge_exceeded` с полями `charge_minor` (текущая цена) и `max_charge_minor`, деньги не списаны.\n\nГде взять цену до операции: покупка и пакеты трафика в `GET /plans`, продление в `GET /subscriptions/{id}/renew-quote` (с `days` для продления на N дней), смена тарифа в `GET /subscriptions/{id}/upgrade-quote`. Поле необязательное: без него операция идёт по текущей цене, как раньше.\n\nОдно исключение. Если ответ первой попытки потерялся, а запрос к поставщику «Навсегда» уже ушёл, повтор с тем же `custom_id` потолком не отбивается: покупка, возможно, уже прошла, и отказ с новым `custom_id` купил бы второй раз. Такой повтор доводит первую попытку до конца, и списание может отличаться от потолка на сдвиг курса за это время.\n\n## Депозит через API\n\n`POST /deposit` выставляет счёт на сумму в центах, ответ содержит `invoice_url`. Комиссия провайдера прибавляется сверху, на депозит зачислится ровно указанная сумма. Одновременно открытых счетов не больше пяти. Зачисление приходит вебхуком `deposit.credited` и видно в `GET /deposit/{custom_id}`.\n\n## Идемпотентность\n\nУ каждой денежной операции есть `custom_id`, который придумываете вы (удобнее всего UUID). Повтор запроса с тем же `custom_id` и тем же телом возвращает результат первой попытки с `idempotent_replay: true` и денег не списывает. Тот же `custom_id` с другим телом отбивается 409 `custom_id_conflict`. Пока первая попытка выполняется, повтор получает 409 `operation_in_progress`: подождите секунду и повторите.\n\nПравило одно: новая операция это новый `custom_id`, ретрай упавшего запроса это тот же `custom_id`. При 503 `upstream_unavailable`, 500 и таймауте повторяйте с тем же `custom_id`, двойного списания не будет.\n\nСохраните `custom_id` у себя до отправки запроса: если ответ потеряется по дороге, повтор без сохранённого id превратится в новую покупку.\n\nУзнать судьбу операции, ничего не повторяя, можно через `GET /operations/{custom_id}`: `in_progress` (ещё выполняется), `done` (готово, ответ в `result`, ровно тот, что получил бы повтор; у пополнения статус в `result` живой), `failed` (отказ, тело в `error`, тот же `custom_id` можно отправить снова) или `unknown` (запрос к поставщику ушёл, а ответ потерялся: подписка могла появиться). При `unknown` не повторяйте подписку на срок вслепую: сначала проверьте `GET /subscriptions?external_user_id=...` или напишите в поддержку с `custom_id`. У «Навсегда» повтор с тем же `custom_id` безопасен. 404 `operation_not_found` значит, что запрос до нас не дошёл и его можно отправить с тем же `custom_id`. Удобно для восстановления после перезапуска вашего сервера: пройдитесь по незавершённым заказам и спросите каждый.\n\n## Пользователь и Telegram\n\n`external_user_id` это идентификатор человека в вашей системе, один человек = один id. Подписок у него может быть несколько. Больше о пользователе знать не нужно: ни телефона, ни почты.\n\nЕсли ваш пользователь есть в Telegram, приложите `telegram_id` и `bot_id` вашего бота: тогда он увидит подписку в мини-приложении бота и получит его уведомления. `bot_id` это UUID бота, его можно скопировать в кабинете: раздел «Ключ и вебхук», карточка «Ваши боты». Без Telegram хватает одного `external_user_id`, `bot_id` не нужен. Один Telegram нельзя привязать к двум разным `external_user_id`: второй получит 409 `external_user_conflict`.\n\nЕсли у вашего бота включён «Пробник только подписчикам канала», `POST /subscriptions/trial` с `telegram_id` и `bot_id` выдаёт пробник только подписчику каналов бота, иначе 403 `trial_channel_required` с полем `channels` (ссылки и названия каналов). Без `bot_id` условие не действует: оно привязано к боту.\n\n## Переход с бота на API\n\nПодписки, проданные в ваших ботах, уже видны в `GET /subscriptions` (поле `source`: `bot`, `miniapp`, `web`) и управляются по `subscription_id`: продление, заморозка, трафик, устройства, вебхуки по ним приходят как по своим. Чтобы находить их по своему `external_user_id`, привяжите людей из бота к своим id.\n\n**1. Посмотрите, кто есть.** `GET /clients?linked=false` отдаёт людей с Telegram, но без `external_user_id`, у каждого до 20 последних подписок. `GET /clients?telegram_id=...` найдёт одного.\n\n**2. Привяжите.** `POST /clients/link` с `external_user_id` и `telegram_id`, без покупки и без списаний. Повтор безопасен (`already_linked`). Перенести всю базу: `POST /clients/link/batch`, до 1000 строк за запрос, ответ по каждой строке.\n\n**3. Работайте как обычно.** После привязки `GET /subscriptions?external_user_id=...` находит подписки, купленные в боте, а `POST /subscriptions` и `POST /subscriptions/trial` с тем же `external_user_id` выдают новые тому же человеку: он увидит их в мини-приложении бота.\n\nКонфликты отвечают 409 `external_user_conflict` с полем `conflict`: `telegram_taken` значит, что этот Telegram уже под другим `external_user_id` (он в поле `external_user_id` ответа), `external_id_taken` значит, что ваш `external_user_id` уже у другого человека. Частый случай второго: раньше вы выдавали подписку этому id без `telegram_id`, и у нас уже есть отдельный пользователь API без Telegram. Двух пользователей с подписками API не склеивает, такую пару разберёт поддержка.\n\nЧеловеку, которого у вас ещё нет (`result: created`), бот не записывается, даже если вы передали `bot_id`: он появится у бота, когда человек сам его откроет. В песочнице людей из ботов нет, поэтому `linked=false` там всегда пустой, а привязка и конфликты работают как на боевом.\n\nНазвание подписки в VPN-клиенте (Happ, v2rayTun) вы задаёте в кабинете, раздел «Ключ и вебхук», карточка «Название подписки». Оно действует на подписки без `bot_id`, подключать для этого бота не нужно. Если передать `bot_id`, бот подключается к платформе целиком: мы забираем его апдейты и снимаем вебхук, который стоял у вас, а название берётся из настроек бота.\n\n## Подписка и её ссылка\n\n`subscription_url` это ссылка подписки для VPN-клиента. При продлении и апгрейде она не меняется; меняется только по вашему вызову `rotate-link`, после него старая ссылка перестаёт работать, а новую вы отдаёте пользователю заново. Статус подписки: `active`, `frozen` или `expired`. Истёкшая продлевается с текущего момента, действующая получает дни к концу срока. Замороженную сначала разморозьте: продлить её нельзя.\n\nПробный период бесплатный, один на `external_user_id`, доступен, если включён в кабинете. Пробную подписку нельзя продлить или заморозить, только перевести на платный тариф апгрейдом.\n\n## Продление на N дней\n\n`POST /subscriptions/{id}/renew-custom` добавляет от 3 до 1095 дней. Цена пропорциональна тарифу: `цена_тарифа / дней_в_тарифе × дней`, округление вверх до цента. Трафик на лимитных серверах прибавляется в той же пропорции.\n\n## Трафик и счётчик продлений\n\n«Лимитные серверы» в этом API это серверы обхода белых списков: на обычных серверах трафик безлимитный, а на них действует лимит. Поэтому «купить белые списки» значит «докупить трафик».\n\n`traffic_limit_bytes` это накопленный лимит на лимитных серверах, а не квота тарифа: полное продление прибавляет квоту тарифа к текущему лимиту, продление на N дней прибавляет её долю `N / дней_в_тарифе`, апгрейд прибавляет трафик за оставшиеся дни, пакет трафика прибавляет купленные гигабайты. `GET /subscriptions/{id}` отдаёт живое значение поставщика вместе с `traffic_used_bytes`, список и вебхуки отдают значение на момент последней операции. `null` значит, что лимита нет.\n\n`renewal_count` считает полные периоды тарифа. Продление на N дней добавляет `N / дней_в_тарифе` в `renewal_progress` (от 0 до 1, не включая 1), и каждый набранный целый период переходит в `renewal_count`. Пример: 30-дневный тариф продлили на 45 дней, счётчик вырос на 1, а `renewal_progress` стал 0.5. Продление на 3 дня того же тарифа даёт `renewal_progress` 0.1 и не меняет `renewal_count`.\n\n## Устройства\n\n`GET /subscriptions/{id}/devices` показывает устройства, которые подключали конфиг, лимит задаёт тариф. Работает для подписок обоих видов: и на срок, и «Навсегда». У «Навсегда» `last_seen` значит последнее обновление конфига устройством, а отключить пользователя целиком поставщик не умеет, заморозки у такой подписки нет. `DELETE /subscriptions/{id}/devices/{device_id}` освобождает слот и по умолчанию отключает устройство: по этой ссылке оно больше не получит конфиг. Переустановка VPN-клиента тут не помогает, идентификатор устройства остаётся прежним. Если нужно только освободить место, чтобы подключить то же устройство заново, передайте `?block=false`.\n\nОтключённые устройства отдаёт `GET /subscriptions/{id}/devices/blocked`. Вернуть одно можно вызовом `DELETE /subscriptions/{id}/devices/blocked/{block_id}`: при следующем обновлении подписки в VPN-клиенте оно подключится снова и займёт слот, если он свободен. `rotate-link` тоже снимает все отключения, но меняет ссылку.\n\n## Белые списки: докупка трафика\n\nОбход белых списков в нашем API это трафик на лимитных серверах. Он докупается пакетами, и вход один для подписки любого вида: `POST /subscriptions/{id}/traffic` с телом `{\"custom_id\": \"...\", \"gb\": 50}`.\n\nКакие `gb` можно купить, видно в `GET /plans`: подписке на срок пакеты из `traffic_packs`, подписке «Навсегда» из `lifetime.traffic_packs`. Объём не из списка отвечает 422 со списком допустимых значений. Лимит `traffic_limit_bytes` вырастает сразу, деньги списываются с депозита. У «Навсегда» купленный трафик не сгорает и прибавляется к остатку.\n\nПодписке на срок пакет продаётся, только пока она действует: замороженной или истёкшей ответ 409 `subscription_frozen` или `subscription_expired`. Пробной «Навсегда» докупки не продаются: 409 `addon_unavailable`.\n\nВебхук о покупке разный: у подписки на срок `subscription.traffic_purchased`, у «Навсегда» `subscription.addon_purchased` с `addon_id` (например `gb_50`). Подпишитесь на оба, если продаёте оба вида.\n\nПошаговый пример есть в сценарии «Докупить трафик (белые списки)».\n\n## Бессрочная подписка «Навсегда»\n\nКроме подписок на срок продаётся бессрочная: 15 устройств, безлимитный трафик на обычных серверах и лимит на лимитных серверах, который не сгорает. В `GET /plans` она лежит отдельным блоком `lifetime`, а не в `plans`: `available`, `plan_uuid`, `devices`, `limited_traffic_bytes`, цена `price_minor` и рублёвая `price_display_minor`, докупки `addons`. `available: false` значит, что сейчас она не продаётся.\n\nПокупается тем же `POST /subscriptions` с `plan_uuid` из блока `lifetime`. Сколько списано, смотрите в `charged_minor` ответа: доллары считаются по курсу рубля в момент покупки. У такой подписки `product: \"lifetime\"` и `is_lifetime: true`, `end_date` стоит в 2100 году ради совместимости, показывать его как дату не нужно. Продление, заморозка и апгрейд к ней не применяются: 409 `unsupported_for_product`. Трафик и устройства работают как у подписки на срок: `/traffic` и `/devices`.\n\nЕщё 15 устройств добавляет `POST /subscriptions/{id}/addons` с `addon_id: \"devices_15\"` из `lifetime.addons`. Трафик тем же методом тоже покупается (`gb_50`, `gb_200`, `gb_1000`, `gb_2000`), но удобнее `/traffic`: он один для обоих видов подписки. Цена докупки это ваша закупка, без наценки.\n\nЕсли ответ на покупку или докупку потерялся, повторите запрос с тем же `custom_id`: мы сначала выясняем у поставщика, прошла ли первая попытка, и повторно не покупаем, пока это не ясно. 503 `upstream_unavailable` на таком повторе значит, что результат ещё выясняется: повторите через несколько минут. Новый `custom_id` после потерянного ответа может купить вторую подписку, поэтому прежде спросите `GET /operations/{custom_id}`. Событие `subscription.addon_purchased` несёт рядом с `subscription` поле `addon_id`: что именно докуплено.\n\n## Вебхуки\n\nУкажите https-адрес в кабинете (раздел «Ключ и вебхук») или через `PUT /webhook`. Секрет подписи выдаётся только в кабинете. Каждое событие приходит POST с JSON-телом и заголовками `X-Event`, `X-Delivery-Id`, `X-Webhook-Timestamp` (unix-время отправки), `X-Signature-V2` и `X-Signature`.\n\nПроверяйте `X-Signature-V2`: это `sha256=` и hex от HMAC-SHA256 на вашем секрете по строке `<X-Webhook-Timestamp>.<сырое тело>`. Сравнивайте constant-time до разбора JSON и отбивайте доставку, если метка расходится с вашими часами больше чем на 5 минут: так перехваченную доставку не переиграть позже. `X-Signature` (HMAC только от тела) остаётся для тех, кто проверяет её сейчас; её подделать тоже нельзя, но от повтора она не защищает.\n\nВ теле всегда есть `event` (имя события), `event_id` (уникален для события, по нему дедупьте), `occurred_at`, `schema_version` (версия формата тела, сейчас `1`) и `source`, откуда пришло событие: `api` (ваш вызов), `platform` (бот или мини-приложение платформы), `network` (событие сети VPN), `test` (тестовая отправка). События подписки несут объект `subscription` в том же виде, что `GET /subscriptions/{id}`, но без `traffic_used_bytes`. `deposit.credited` несёт `deposit` и `balance_after_minor`.\n\nСобытия могут прийти не по порядку: повтор старой доставки придёт после свежей. Чтобы не откатить подписку к устаревшему состоянию, храните у себя `occurred_at` последнего применённого события по каждой подписке и пропускайте более старые.\n\n## Догнать пропущенное\n\nЕсли ваш сервер лежал, ждать повторов не обязательно. `GET /events?since=<момент>` отдаёт события, которые готовились вашему вебхуку, по порядку и вместе со статусом доставки, тело в `payload` ровно такое же, как у вебхука. Листайте по `next_cursor`, пока он не `null`. События хранятся 30 дней; если вебхук не задан, их нет.\n\nЖурнал доставок с ответами вашего сервера есть и в API: `GET /webhook/deliveries` (фильтры `status` и `event`). Повторить одну доставку прямо сейчас: `POST /webhook/deliveries/{delivery_id}/redeliver`, ответ скажет, что ответил ваш сервер.\n\n## Синхронизация подписок\n\nДержать свою базу в актуальном виде можно без запроса на каждую подписку. `GET /subscriptions?updated_since=<момент>` отдаёт подписки, изменившиеся с этого момента, по возрастанию `updated_at`, страницы по `next_cursor`. Запомните `updated_at` последней подписки и в следующий раз начните с него. `updated_at` меняется при любом изменении подписки, в том числе когда VPN-клиент подключается (не чаще раза в час).\n\nВ списке есть фильтры `telegram_id`, `plan_uuid`, `product` (`term` или `lifetime`) и `is_trial`. Живого расхода трафика в списке нет: он в карточке `GET /subscriptions/{id}` вместе с `usage_updated_at`, моментом, на который он верен.\n\nОтвечайте 2xx быстро, работу уносите в фон. При ошибке доставка повторяется с растущим интервалом (1, 2, 4 … 512 минут, всего 10 попыток), потом уходит в `dead`, и вы получаете сообщение от бота платформы. Каждую попытку с ответом вашего сервера видно в кабинете, раздел «Доставки вебхуков». Одно и то же событие может прийти повторно: дедупите по `event_id`. Незнакомое событие игнорируйте, список пополняется. Источник истины остаётся за `GET /subscriptions/{id}`.\n\n## Ошибки\n\nОтказ приходит с HTTP-статусом 4xx или 5xx и телом `{\"error\": \"текст\", \"error_code\": \"snake_case\"}`. Ветвитесь по `error_code`: он стабилен, а `error` написан для разработчика и может меняться. Пользователю показывайте своё сообщение.\n\nЧасть отказов несёт подробности: у `validation_error` (422) в `details` список полей с причинами, у `insufficient_balance` (402) могут быть `required_minor` и `balance_minor`.\n\nМожно ли повторить запрос как есть, подсказывает столбец «Повтор» в таблице ниже. Денежную операцию повторяйте только с тем же `custom_id`. 429 `rate_limited` приходит с заголовком `Retry-After`: подождите столько секунд. 503 и 500 повторяйте с растущей паузой; у 503 `upstream_unavailable` есть `Retry-After`, раньше него не повторяйте. Повтор всегда с тем же `custom_id`: новый `custom_id` на каждой попытке при сбое поставщика это новая покупка на каждой попытке.\n\n## Что вызвать\n\n- **Показать тарифы и цены** → `GET /plans`\n- **Узнать, хватит ли денег** → `GET /balance`\n- **Пополнить депозит** → `POST /deposit`, статус в `GET /deposit/{custom_id}`\n- **Выдать подписку на срок** → `POST /subscriptions` с `plan_uuid` из `plans`\n- **Выдать «Навсегда»** → `POST /subscriptions` с `plan_uuid` из блока `lifetime`\n- **Дать пробный период** → `POST /subscriptions/trial`\n- **Посмотреть подписку и остаток трафика** → `GET /subscriptions/{id}`\n- **Найти подписки пользователя** → `GET /subscriptions?external_user_id=...`\n- **Найти человека из бота и его подписки** → `GET /clients?telegram_id=...`\n- **Привязать людей из бота к своим id** → `POST /clients/link`, пачкой `POST /clients/link/batch`\n- **Узнать цену продления** → `GET /subscriptions/{id}/renew-quote` (с `days` для N дней)\n- **Продлить на период тарифа** → `POST /subscriptions/{id}/renew`\n- **Продлить на любое число дней** → `POST /subscriptions/{id}/renew-custom`\n- **Сменить тариф** → `GET /subscriptions/{id}/upgrade-quote`, затем `POST /subscriptions/{id}/upgrade`\n- **Купить белые списки (трафик) любой подписке** → `POST /subscriptions/{id}/traffic`\n- **Добавить 15 устройств к «Навсегда»** → `POST /subscriptions/{id}/addons` с `devices_15`\n- **Не списать больше показанного** → `max_charge_minor` в теле любой платной операции\n- **Узнать, чем кончилась операция** → `GET /operations/{custom_id}`\n- **Заморозить и разморозить** → `POST /subscriptions/{id}/freeze` и `/unfreeze`\n- **Выдать новую ссылку, если старая утекла** → `POST /subscriptions/{id}/rotate-link`\n- **Посмотреть и отключить устройства (оба вида подписки)** → `GET /subscriptions/{id}/devices`, `DELETE /subscriptions/{id}/devices/{device_id}`\n- **Проверить интеграцию без денег** → тестовый ключ `sb_…` из раздела «Песочница»\n- **Узнать о продлении, окончании срока, трафике** → вебхук: `PUT /webhook`, проверка `POST /webhook/test`\n- **Догнать события после сбоя вашего сервера** → `GET /events?since=...`\n- **Посмотреть и повторить доставки вебхука** → `GET /webhook/deliveries`, `POST /webhook/deliveries/{delivery_id}/redeliver`\n- **Синхронизировать свою базу подписок** → `GET /subscriptions?updated_since=...`\n\n## Частые вопросы\n\n**Где взять `bot_id`?** В кабинете: раздел «Ключ и вебхук», карточка «Ваши боты», UUID с кнопкой «Скопировать». Параметр нужен только вместе с `telegram_id`, чтобы подписка появилась в мини-приложении вашего бота. Без Telegram `bot_id` не нужен.\n\n**Как купить белые списки?** Это докупка трафика, один метод для любой подписки: `POST /subscriptions/{id}/traffic` с `gb`. Подписке на срок `gb` из `traffic_packs`, «Навсегда» из `lifetime.traffic_packs`. Пошагово в сценарии «Докупить трафик (белые списки)».\n\n**Сколько трафика осталось?** `GET /subscriptions/{id}` отдаёт `traffic_limit_bytes` (лимит) и `traffic_used_bytes` (расход). Лимит `null` значит, что ограничения нет. Когда израсходовано 80 %, приходит вебхук `subscription.traffic_threshold`.\n\n**Нужен ли пользователю Telegram?** Нет. Достаточно вашего `external_user_id`: вы получаете `subscription_url` и отдаёте его сами.\n\n**Ответ на покупку потерялся. Что делать?** Спросите `GET /operations/{custom_id}`: там статус и готовый ответ. Или просто повторите тот же запрос с тем же `custom_id`: деньги второй раз не спишутся, а в ответе будет `idempotent_replay: true`. Новый `custom_id` создаст вторую подписку.\n\n**Как не списать больше, чем показали покупателю?** Передайте `max_charge_minor` с показанной ценой. Если цена выросла, придёт 409 `max_charge_exceeded`, а деньги останутся на депозите. Цену продления заранее даёт `GET /subscriptions/{id}/renew-quote`.\n\n**Как найти мой запрос в логах поддержки?** У каждого ответа есть заголовок `X-Request-ID`, а у ошибки ещё и поле `request_id`. Назовите его поддержке.\n\n**Можно ли вызывать API из браузера или приложения?** Нет. Ключ тратит ваши деньги, поэтому вызывайте API только со своего сервера.\n\n**Как продлить на произвольный срок?** `POST /subscriptions/{id}/renew-custom` с `days` от 3 до 1095. Цена пропорциональна тарифу.\n\n**Как перевести пробный период на платный тариф?** Апгрейдом: `GET /subscriptions/{id}/upgrade-quote`, затем `POST /subscriptions/{id}/upgrade`. Продлить или заморозить пробную нельзя.\n\n**Почему «Навсегда» нельзя продлить или заморозить?** Она бессрочная. Продление, заморозка и апгрейд к ней не применяются, API отвечает 409 `unsupported_for_product`. Трафик и устройства работают так же, как у подписки на срок.\n\n**Ссылка утекла. Что делать?** `POST /subscriptions/{id}/rotate-link` выдаст новую, старая перестанет работать. Уже подключённые устройства продолжат работать, чтобы закрыть доступ совсем, заморозьте подписку.\n\n**Деньги на депозите есть, а API отвечает 402.** Покупки списываются с депозита по закупочной цене, а не по вашей розничной. Сверьтесь с `GET /balance`: нужна сумма `spendable`, в ответе 402 есть `required_minor` и `balance_minor`.\n\n**Можно ли проверить интеграцию без настоящих денег?** Да: тестовый ключ из раздела «Песочница». Те же адреса и ответы, тестовый депозит, сбои по заказу заголовком `X-Sandbox-Simulate`, машина времени для окончания срока.\n\n**Как перевыпустить ключ, не уронив интеграцию?** В кабинете при перевыпуске выберите перекрытие на 1, 24 или 72 часа: прежний ключ поработает рядом с новым, пока вы его меняете.\n\n**Как дать доступ подрядчику без права тратить депозит?** Выпустите ключ только для чтения: он видит подписки, баланс и операции, но ничего не покупает.\n\n**Сколько запросов можно делать?** 60 в минуту на ключ. Сколько осталось, видно в заголовке `X-RateLimit-Remaining` каждого ответа. При превышении ответ 429 с заголовком `Retry-After`.\n\n**Мой сервер лежал час. Как узнать, что я пропустил?** `GET /events?since=<когда упал>`: события по порядку с телами, как у вебхука. Плюс автоматические повторы доставки ещё идут.\n\n**Как держать свою базу подписок актуальной?** `GET /subscriptions?updated_since=<прошлый заход>` и листать по `next_cursor`: придут только изменившиеся.\n\n**Есть ли файлы для Postman или генерации клиента?** Да: [OpenAPI](https://hexpartners.pro/vpn-api/openapi.json) и [коллекция Postman](https://hexpartners.pro/vpn-api/postman.json).\n\n## Что нового\n\n- **16 октября 2026, переход с бота на API.** `POST /clients/link` и `POST /clients/link/batch` (до 1000 строк) привязывают ваш `external_user_id` к человеку из бота без покупки, `GET /clients` ищет людей по `telegram_id` и `external_user_id` вместе с подписками. В подписке новые поля `client_id` и `source` (где куплена). Пробник у бота с условием «только подписчикам канала» отвечает 403 `trial_channel_required`.\n- **15 октября 2026, песочница.** Тестовый ключ `sb_…` с тестовым депозитом до $1 000, имитацией сбоев (`X-Sandbox-Simulate`), машиной времени и своим вебхуком. Ничего не списывает и к поставщику не ходит.\n- **15 октября 2026, ключи.** Перевыпуск ключа с перекрытием на 1, 24 или 72 часа, ключ только для чтения (`key_read_only`), привязка ключей к адресам (`ip_not_allowed`).\n- **15 октября 2026, обновление API.** Подпись вебхука с меткой времени (`X-Webhook-Timestamp`, `X-Signature-V2`), в теле `schema_version`. `GET /events`: лента событий для догона после сбоя. Журнал доставок и повтор доставки через API. Список подписок: режим синхронизации `updated_since` с `next_cursor`, фильтры `telegram_id`, `plan_uuid`, `product`, `is_trial`, поле `updated_at`. В карточке `usage_updated_at`. Продление пробной подписки теперь отвечает `trial_not_renewable` (раньше `plan_not_found`), покупка пробного тарифа подсказывает `POST /subscriptions/trial`, у 503 есть `Retry-After`. Всё прежнее работает как раньше.\n- **14 октября 2026, обновление API.** `GET /operations/{custom_id}`: судьба операции без повтора. `max_charge_minor` в платных операциях и код `max_charge_exceeded`. `GET /subscriptions/{id}/renew-quote`: цена продления без списания. Трафик для «Навсегда» через тот же `POST /subscriptions/{id}/traffic` (раньше 409 `unsupported_for_product`), пакеты в `lifetime.traffic_packs`. Устройства у «Навсегда» через `/devices` (раньше 409). У ошибок поля `request_id` и `doc_url`, у ответов заголовки `X-RateLimit-*`. Файлы OpenAPI и Postman. Всё прежнее работает как раньше.\n- **14 октября 2026.** Белые списки названы прямо: раздел «Белые списки: докупка трафика», шпаргалка «Что вызвать», вопросы и ответы. В кабинете появилась карточка «Ваши боты» с UUID для `bot_id`.\n- **6 октября 2026.** «Навсегда» в API: блок `lifetime` в `GET /plans`, докупки `POST /subscriptions/{id}/addons`, поля `product` и `is_lifetime`, код ошибки `unsupported_for_product`.\n- **29 сентября 2026.** `traffic_limit_bytes` теперь итоговый лимит после продлений и докупок, а не квота одного периода. Добавлено поле `renewal_progress`.\n- **22 сентября 2026.** `POST /subscriptions/{id}/rotate-link`: новая ссылка, если старая утекла.\n- **2 сентября 2026.** Запуск VPN Reseller API.\n",
    "version": "1.0.0"
  },
  "servers": [
    {
      "url": "https://api.relayhub.surf"
    }
  ],
  "paths": {
    "/api/v1/vpn/plans": {
      "get": {
        "tags": [
          "plans"
        ],
        "summary": "Тарифы и цены",
        "description": "Активные тарифы с ценой, которую спишем с вашего депозита за покупку или\nпродление. Пробный тариф в `trial` (если включён в кабинете), пакеты трафика в\n`traffic_packs`. Цены в USD и меняются редко, но кэшировать их дольше часа не стоит.",
        "operationId": "listPlans",
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "additionalProperties": true,
                  "type": "object",
                  "title": "Response Listplans"
                },
                "example": {
                  "success": true,
                  "currency": "USD",
                  "plans": [
                    {
                      "plan_uuid": "550e8400-e29b-41d4-a716-446655440000",
                      "name": "Standard 30d",
                      "days": 30,
                      "devices": 2,
                      "traffic_limit_bytes": null,
                      "price_minor": 450,
                      "price_usd": "4.50"
                    }
                  ],
                  "trial": {
                    "available": true,
                    "plan_uuid": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
                    "name": "Trial",
                    "days": 3,
                    "devices": 1
                  },
                  "traffic_packs": [
                    {
                      "gb": 10,
                      "price_minor": 120,
                      "price_usd": "1.20"
                    }
                  ],
                  "lifetime": {
                    "available": true,
                    "plan_uuid": "3f0b2c1e-9a4d-5e6f-8a7b-1c2d3e4f5a6b",
                    "name": "Навсегда",
                    "devices": 15,
                    "limited_traffic_bytes": 80530636800,
                    "price_minor": 1001,
                    "price_usd": "10.01",
                    "price_display_minor": 79900,
                    "price_currency": "RUB",
                    "addons": [
                      {
                        "addon_id": "gb_200",
                        "kind": "traffic",
                        "gb": 200,
                        "devices": null,
                        "price_minor": 320,
                        "price_usd": "3.20"
                      },
                      {
                        "addon_id": "devices_15",
                        "kind": "devices",
                        "gb": null,
                        "devices": 15,
                        "price_minor": 280,
                        "price_usd": "2.80"
                      }
                    ],
                    "traffic_packs": [
                      {
                        "gb": 200,
                        "price_minor": 320,
                        "price_usd": "3.20"
                      }
                    ]
                  }
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          }
        }
      }
    },
    "/api/v1/vpn/balance": {
      "get": {
        "tags": [
          "balance"
        ],
        "summary": "Баланс депозита",
        "description": "Сколько можно потратить прямо сейчас.\n\n`spendable` = `paid` (внесено пополнениями, не выводится) + `earned`\n(заработано на продажах в ботах, выводится). Покупки через API списываются\nсначала с `paid`, потом с `earned`.",
        "operationId": "getBalance",
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "additionalProperties": true,
                  "type": "object",
                  "title": "Response Getbalance"
                },
                "example": {
                  "success": true,
                  "spendable_minor": 12050,
                  "spendable_usd": "120.50",
                  "paid_minor": 10000,
                  "paid_usd": "100.00",
                  "earned_minor": 2050,
                  "earned_usd": "20.50",
                  "currency": "USD"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          }
        }
      }
    },
    "/api/v1/vpn/subscriptions": {
      "post": {
        "tags": [
          "subscriptions"
        ],
        "summary": "Купить подписку",
        "description": "Списывает цену тарифа с депозита и выдаёт подписку. В ответе `subscription_url`:\nеё вы отдаёте пользователю, он вставляет ссылку в VPN-клиент.\n\nОдин `external_user_id` может иметь несколько подписок. Повтор с тем же `custom_id`\nвернёт ту же подписку с `idempotent_replay: true`.",
        "operationId": "createSubscription",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateSubscriptionBody"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Createsubscription"
                },
                "example": {
                  "success": true,
                  "custom_id": "11111111-1111-4111-8111-111111111111",
                  "subscription": {
                    "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                    "client_id": "01a06310-8f2b-7c11-9a3e-2b5c7d9e1f00",
                    "external_user_id": "user-42",
                    "telegram_id": null,
                    "bot_id": null,
                    "source": "api",
                    "plan_uuid": "550e8400-e29b-41d4-a716-446655440000",
                    "plan_name": "Standard 30d",
                    "is_trial": false,
                    "product": "term",
                    "is_lifetime": false,
                    "status": "active",
                    "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K",
                    "days": 30,
                    "days_left": 30,
                    "devices": 2,
                    "traffic_limit_bytes": null,
                    "auto_renew": false,
                    "renewal_count": 0,
                    "renewal_progress": 0.0,
                    "created_at": "2026-09-02T12:00:00Z",
                    "end_date": "2026-10-02T12:00:00Z",
                    "frozen_at": null,
                    "price_minor": 450,
                    "price_usd": "4.50"
                  },
                  "charged_minor": 450,
                  "charged_usd": "4.50",
                  "balance_after_minor": 11600,
                  "balance_after_usd": "116.00"
                }
              }
            }
          },
          "404": {
            "description": "`plan_not_found`: Тариф не найден или недоступен"
          },
          "402": {
            "description": "`insufficient_balance`: Недостаточно средств на депозите"
          },
          "409": {
            "description": "`max_charge_exceeded`: Цена операции выше max_charge_minor; `custom_id_conflict`: Этот custom_id уже использован с другими параметрами; `operation_in_progress`: Операция с этим custom_id ещё выполняется; `external_user_conflict`: telegram_id или external_user_id уже привязан к другому пользователю"
          },
          "403": {
            "description": "`forbidden`: Объект принадлежит другому аккаунту; `api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "422": {
            "description": "`validation_error`: Неверные параметры запроса"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          }
        }
      },
      "get": {
        "tags": [
          "subscriptions"
        ],
        "summary": "Список подписок",
        "description": "Все подписки аккаунта, включая проданные через ботов. Без живого трафика: он есть\nтолько в карточке одной подписки.\n\nОбычный режим: новые сверху, страницы по `offset`. Режим синхронизации (задан\n`updated_since` или `cursor`): по возрастанию `updated_at`, в ответе `next_cursor`,\nлистайте, пока он не `null`, и запомните `updated_at` последней подписки для\nследующего захода. Подходит, чтобы держать свою базу в актуальном виде без запроса\nна каждую подписку.",
        "operationId": "listSubscriptions",
        "parameters": [
          {
            "name": "external_user_id",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "maxLength": 128
                },
                {
                  "type": "null"
                }
              ],
              "description": "Только подписки этого пользователя.",
              "title": "External User Id"
            },
            "description": "Только подписки этого пользователя."
          },
          {
            "name": "telegram_id",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "integer",
                  "exclusiveMinimum": 0
                },
                {
                  "type": "null"
                }
              ],
              "description": "Только этого Telegram id.",
              "title": "Telegram Id"
            },
            "description": "Только этого Telegram id."
          },
          {
            "name": "plan_uuid",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "format": "uuid"
                },
                {
                  "type": "null"
                }
              ],
              "description": "Только этого тарифа.",
              "title": "Plan Uuid"
            },
            "description": "Только этого тарифа."
          },
          {
            "name": "product",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "enum": [
                    "term",
                    "lifetime"
                  ],
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "description": "`term`: на срок, `lifetime`: «Навсегда».",
              "title": "Product"
            },
            "description": "`term`: на срок, `lifetime`: «Навсегда»."
          },
          {
            "name": "is_trial",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "boolean"
                },
                {
                  "type": "null"
                }
              ],
              "description": "Только пробные или только платные.",
              "title": "Is Trial"
            },
            "description": "Только пробные или только платные."
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "enum": [
                    "active",
                    "frozen",
                    "expired"
                  ],
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "description": "Фильтр по статусу.",
              "title": "Status"
            },
            "description": "Фильтр по статусу."
          },
          {
            "name": "updated_since",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "format": "date-time"
                },
                {
                  "type": "null"
                }
              ],
              "description": "Режим синхронизации: подписки, изменившиеся с этого момента (ISO-8601), по возрастанию `updated_at`, страницы по `next_cursor`.",
              "examples": [
                "2026-10-14T00:00:00Z"
              ],
              "title": "Updated Since"
            },
            "description": "Режим синхронизации: подписки, изменившиеся с этого момента (ISO-8601), по возрастанию `updated_at`, страницы по `next_cursor`."
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "maxLength": 200
                },
                {
                  "type": "null"
                }
              ],
              "description": "`next_cursor` из прошлой страницы.",
              "title": "Cursor"
            },
            "description": "`next_cursor` из прошлой страницы."
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "description": "Сколько пропустить. Только в обычном режиме.",
              "default": 0,
              "title": "Offset"
            },
            "description": "Сколько пропустить. Только в обычном режиме."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "maximum": 100,
              "minimum": 1,
              "description": "Размер страницы, до 100.",
              "default": 20,
              "title": "Limit"
            },
            "description": "Размер страницы, до 100."
          }
        ],
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Listsubscriptions"
                },
                "example": {
                  "success": true,
                  "total": 1,
                  "offset": 0,
                  "limit": 20,
                  "items": [
                    {
                      "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                      "client_id": "01a06310-8f2b-7c11-9a3e-2b5c7d9e1f00",
                      "external_user_id": "user-42",
                      "telegram_id": null,
                      "bot_id": null,
                      "source": "api",
                      "plan_uuid": "550e8400-e29b-41d4-a716-446655440000",
                      "plan_name": "Standard 30d",
                      "is_trial": false,
                      "product": "term",
                      "is_lifetime": false,
                      "status": "active",
                      "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K",
                      "days": 30,
                      "days_left": 30,
                      "devices": 2,
                      "traffic_limit_bytes": null,
                      "auto_renew": false,
                      "renewal_count": 0,
                      "renewal_progress": 0.0,
                      "created_at": "2026-09-02T12:00:00Z",
                      "end_date": "2026-10-02T12:00:00Z",
                      "frozen_at": null,
                      "price_minor": 450,
                      "price_usd": "4.50",
                      "updated_at": "2026-09-10T07:58:12Z"
                    }
                  ]
                }
              }
            }
          },
          "422": {
            "description": "`validation_error`: Неверные параметры запроса"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          }
        }
      }
    },
    "/api/v1/vpn/subscriptions/trial": {
      "post": {
        "tags": [
          "subscriptions"
        ],
        "summary": "Выдать пробный период",
        "description": "Бесплатно, один раз на `external_user_id`. Работает, если пробный период включён в\nкабинете. Пробную подписку нельзя продлить и заморозить, только перевести на платный\nтариф апгрейдом.",
        "operationId": "claimTrial",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TrialBody"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "additionalProperties": true,
                  "type": "object",
                  "title": "Response Claimtrial"
                },
                "example": {
                  "success": true,
                  "custom_id": "11111111-1111-4111-8111-111111111111",
                  "subscription": {
                    "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                    "client_id": "01a06310-8f2b-7c11-9a3e-2b5c7d9e1f00",
                    "external_user_id": "user-42",
                    "telegram_id": null,
                    "bot_id": null,
                    "source": "api",
                    "plan_uuid": "550e8400-e29b-41d4-a716-446655440000",
                    "plan_name": "Trial",
                    "is_trial": true,
                    "product": "term",
                    "is_lifetime": false,
                    "status": "active",
                    "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K",
                    "days": 3,
                    "days_left": 3,
                    "devices": 1,
                    "traffic_limit_bytes": null,
                    "auto_renew": false,
                    "renewal_count": 0,
                    "renewal_progress": 0.0,
                    "created_at": "2026-09-02T12:00:00Z",
                    "end_date": "2026-10-02T12:00:00Z",
                    "frozen_at": null,
                    "price_minor": 0,
                    "price_usd": "0.00"
                  },
                  "charged_minor": 0,
                  "charged_usd": "0.00",
                  "balance_after_minor": 12050,
                  "balance_after_usd": "120.50"
                }
              }
            }
          },
          "403": {
            "description": "`trial_disabled`: Пробный период выключен в настройках партнёра; `api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "409": {
            "description": "`trial_already_used`: Этот пользователь уже получал пробный период; `custom_id_conflict`: Этот custom_id уже использован с другими параметрами; `external_user_conflict`: telegram_id или external_user_id уже привязан к другому пользователю"
          },
          "404": {
            "description": "`trial_plan_missing`: Пробный тариф временно недоступен"
          },
          "422": {
            "description": "`validation_error`: Неверные параметры запроса"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          }
        }
      }
    },
    "/api/v1/vpn/subscriptions/{subscription_id}": {
      "get": {
        "tags": [
          "subscriptions"
        ],
        "summary": "Статус подписки",
        "description": "Карточка с остатком дней, израсходованным трафиком (`traffic_used_bytes`,\n`null` если поставщик не ответил), живым лимитом `traffic_limit_bytes` и моментом,\nна который верен расход (`usage_updated_at`): цифры кэшируются до минуты.",
        "operationId": "getSubscription",
        "parameters": [
          {
            "name": "subscription_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid",
              "title": "Subscription Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Getsubscription"
                },
                "example": {
                  "success": true,
                  "subscription": {
                    "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                    "client_id": "01a06310-8f2b-7c11-9a3e-2b5c7d9e1f00",
                    "external_user_id": "user-42",
                    "telegram_id": null,
                    "bot_id": null,
                    "source": "api",
                    "plan_uuid": "550e8400-e29b-41d4-a716-446655440000",
                    "plan_name": "Standard 30d",
                    "is_trial": false,
                    "product": "term",
                    "is_lifetime": false,
                    "status": "active",
                    "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K",
                    "days": 30,
                    "days_left": 30,
                    "devices": 2,
                    "traffic_limit_bytes": null,
                    "auto_renew": false,
                    "renewal_count": 0,
                    "renewal_progress": 0.0,
                    "created_at": "2026-09-02T12:00:00Z",
                    "end_date": "2026-10-02T12:00:00Z",
                    "frozen_at": null,
                    "price_minor": 450,
                    "price_usd": "4.50",
                    "traffic_used_bytes": 5368709120,
                    "usage_updated_at": "2026-09-10T08:00:00Z",
                    "updated_at": "2026-09-10T07:58:12Z"
                  }
                }
              }
            }
          },
          "404": {
            "description": "`subscription_not_found`: Подписка не найдена"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/vpn/subscriptions/{subscription_id}/renew": {
      "post": {
        "tags": [
          "subscriptions"
        ],
        "summary": "Продлить на период тарифа",
        "description": "Добавляет полный период тарифа по текущей цене из GET /plans. Истёкшая подписка\nвозобновляется с текущего момента, действующая получает дни к концу срока.\nЗамороженную сначала разморозьте.",
        "operationId": "renewSubscription",
        "parameters": [
          {
            "name": "subscription_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid",
              "title": "Subscription Id"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RenewBody"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Renewsubscription"
                },
                "example": {
                  "success": true,
                  "custom_id": "11111111-1111-4111-8111-111111111111",
                  "subscription": {
                    "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                    "client_id": "01a06310-8f2b-7c11-9a3e-2b5c7d9e1f00",
                    "external_user_id": "user-42",
                    "telegram_id": null,
                    "bot_id": null,
                    "source": "api",
                    "plan_uuid": "550e8400-e29b-41d4-a716-446655440000",
                    "plan_name": "Standard 30d",
                    "is_trial": false,
                    "product": "term",
                    "is_lifetime": false,
                    "status": "active",
                    "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K",
                    "days": 30,
                    "days_left": 60,
                    "devices": 2,
                    "traffic_limit_bytes": null,
                    "auto_renew": false,
                    "renewal_count": 1,
                    "renewal_progress": 0.0,
                    "created_at": "2026-09-02T12:00:00Z",
                    "end_date": "2026-11-01T12:00:00Z",
                    "frozen_at": null,
                    "price_minor": 450,
                    "price_usd": "4.50"
                  },
                  "charged_minor": 450,
                  "charged_usd": "4.50",
                  "balance_after_minor": 11600,
                  "balance_after_usd": "116.00"
                }
              }
            }
          },
          "409": {
            "description": "`unsupported_for_product`: Это действие не подходит к этому виду подписки; `subscription_frozen`: Подписка заморожена; `trial_not_renewable`: Пробную подписку нельзя продлить; `max_charge_exceeded`: Цена операции выше max_charge_minor; `custom_id_conflict`: Этот custom_id уже использован с другими параметрами; `operation_in_progress`: Операция с этим custom_id ещё выполняется"
          },
          "404": {
            "description": "`subscription_not_found`: Подписка не найдена; `plan_not_found`: Тариф не найден или недоступен"
          },
          "402": {
            "description": "`insufficient_balance`: Недостаточно средств на депозите"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/vpn/subscriptions/{subscription_id}/renew-custom": {
      "post": {
        "tags": [
          "subscriptions"
        ],
        "summary": "Продлить на N дней",
        "description": "Цена и трафик считаются пропорционально тарифу: `price / plan_days × days`,\nокругление вверх до цента.",
        "operationId": "renewSubscriptionCustom",
        "parameters": [
          {
            "name": "subscription_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid",
              "title": "Subscription Id"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RenewCustomBody"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Renewsubscriptioncustom"
                },
                "example": {
                  "success": true,
                  "custom_id": "11111111-1111-4111-8111-111111111111",
                  "subscription": {
                    "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                    "client_id": "01a06310-8f2b-7c11-9a3e-2b5c7d9e1f00",
                    "external_user_id": "user-42",
                    "telegram_id": null,
                    "bot_id": null,
                    "source": "api",
                    "plan_uuid": "550e8400-e29b-41d4-a716-446655440000",
                    "plan_name": "Standard 30d",
                    "is_trial": false,
                    "product": "term",
                    "is_lifetime": false,
                    "status": "active",
                    "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K",
                    "days": 30,
                    "days_left": 75,
                    "devices": 2,
                    "traffic_limit_bytes": null,
                    "auto_renew": false,
                    "renewal_count": 1,
                    "renewal_progress": 0.5,
                    "created_at": "2026-09-02T12:00:00Z",
                    "end_date": "2026-11-16T12:00:00Z",
                    "frozen_at": null,
                    "price_minor": 450,
                    "price_usd": "4.50"
                  },
                  "charged_minor": 675,
                  "charged_usd": "6.75",
                  "balance_after_minor": 11375,
                  "balance_after_usd": "113.75",
                  "days_added": 45
                }
              }
            }
          },
          "409": {
            "description": "`unsupported_for_product`: Это действие не подходит к этому виду подписки; `subscription_frozen`: Подписка заморожена; `trial_not_renewable`: Пробную подписку нельзя продлить; `max_charge_exceeded`: Цена операции выше max_charge_minor; `custom_id_conflict`: Этот custom_id уже использован с другими параметрами; `operation_in_progress`: Операция с этим custom_id ещё выполняется"
          },
          "404": {
            "description": "`subscription_not_found`: Подписка не найдена; `plan_not_found`: Тариф не найден или недоступен"
          },
          "402": {
            "description": "`insufficient_balance`: Недостаточно средств на депозите"
          },
          "422": {
            "description": "`validation_error`: Неверные параметры запроса"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          }
        }
      }
    },
    "/api/v1/vpn/subscriptions/{subscription_id}/upgrade-quote": {
      "get": {
        "tags": [
          "subscriptions"
        ],
        "summary": "Сколько стоит апгрейд",
        "description": "Доплата за переход: полная цена нового тарифа минус неиспользованный остаток\nтекущего. Апгрейд перезапускает срок с текущего момента.",
        "operationId": "quoteUpgrade",
        "parameters": [
          {
            "name": "subscription_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid",
              "title": "Subscription Id"
            }
          },
          {
            "name": "new_plan_uuid",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid",
              "description": "Тариф из GET /plans.",
              "examples": [
                "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
              ],
              "title": "New Plan Uuid"
            },
            "description": "Тариф из GET /plans."
          }
        ],
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Quoteupgrade"
                },
                "example": {
                  "success": true,
                  "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                  "new_plan_uuid": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
                  "new_plan_name": "Pro 30d",
                  "remaining_days": 20,
                  "charge_minor": 300,
                  "charge_usd": "3.00"
                }
              }
            }
          },
          "409": {
            "description": "`unsupported_for_product`: Это действие не подходит к этому виду подписки; `subscription_frozen`: Подписка заморожена; `subscription_expired`: Подписка истекла; `subscription_state`: Операция не подходит к текущему состоянию подписки"
          },
          "404": {
            "description": "`subscription_not_found`: Подписка не найдена; `plan_not_found`: Тариф не найден или недоступен"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/vpn/subscriptions/{subscription_id}/renew-quote": {
      "get": {
        "tags": [
          "subscriptions"
        ],
        "summary": "Сколько стоит продление",
        "description": "Цена продления без списания: столько спишут `renew` (без `days`) или\n`renew-custom` (с `days`) прямо сейчас. Передайте её в `max_charge_minor`, чтобы\nпродление не прошло дороже показанного покупателю.",
        "operationId": "quoteRenew",
        "parameters": [
          {
            "name": "subscription_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid",
              "title": "Subscription Id"
            }
          },
          {
            "name": "days",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "integer"
                },
                {
                  "type": "null"
                }
              ],
              "description": "Сколько дней (от 3 до 1095). Не задано: полный период тарифа.",
              "examples": [
                45
              ],
              "title": "Days"
            },
            "description": "Сколько дней (от 3 до 1095). Не задано: полный период тарифа."
          }
        ],
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Quoterenew"
                },
                "example": {
                  "success": true,
                  "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                  "days": 45,
                  "end_date_after": "2026-11-16T12:00:00Z",
                  "charge_minor": 450,
                  "charge_usd": "4.50"
                }
              }
            }
          },
          "409": {
            "description": "`unsupported_for_product`: Это действие не подходит к этому виду подписки; `subscription_frozen`: Подписка заморожена; `trial_not_renewable`: Пробную подписку нельзя продлить"
          },
          "404": {
            "description": "`subscription_not_found`: Подписка не найдена; `plan_not_found`: Тариф не найден или недоступен"
          },
          "422": {
            "description": "`validation_error`: Неверные параметры запроса"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          }
        }
      }
    },
    "/api/v1/vpn/subscriptions/{subscription_id}/upgrade": {
      "post": {
        "tags": [
          "subscriptions"
        ],
        "summary": "Сменить тариф",
        "description": "Списывает доплату из upgrade-quote и переводит подписку на новый тариф.",
        "operationId": "upgradeSubscription",
        "parameters": [
          {
            "name": "subscription_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid",
              "title": "Subscription Id"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpgradeBody"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Upgradesubscription"
                },
                "example": {
                  "success": true,
                  "custom_id": "11111111-1111-4111-8111-111111111111",
                  "subscription": {
                    "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                    "client_id": "01a06310-8f2b-7c11-9a3e-2b5c7d9e1f00",
                    "external_user_id": "user-42",
                    "telegram_id": null,
                    "bot_id": null,
                    "source": "api",
                    "plan_uuid": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
                    "plan_name": "Pro 30d",
                    "is_trial": false,
                    "product": "term",
                    "is_lifetime": false,
                    "status": "active",
                    "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K",
                    "days": 30,
                    "days_left": 30,
                    "devices": 5,
                    "traffic_limit_bytes": null,
                    "auto_renew": false,
                    "renewal_count": 0,
                    "renewal_progress": 0.0,
                    "created_at": "2026-09-02T12:00:00Z",
                    "end_date": "2026-10-02T12:00:00Z",
                    "frozen_at": null,
                    "price_minor": 750,
                    "price_usd": "7.50"
                  },
                  "charged_minor": 300,
                  "charged_usd": "3.00",
                  "balance_after_minor": 11600,
                  "balance_after_usd": "116.00"
                }
              }
            }
          },
          "409": {
            "description": "`unsupported_for_product`: Это действие не подходит к этому виду подписки; `subscription_frozen`: Подписка заморожена; `subscription_expired`: Подписка истекла; `subscription_state`: Операция не подходит к текущему состоянию подписки; `max_charge_exceeded`: Цена операции выше max_charge_minor; `custom_id_conflict`: Этот custom_id уже использован с другими параметрами; `operation_in_progress`: Операция с этим custom_id ещё выполняется"
          },
          "404": {
            "description": "`subscription_not_found`: Подписка не найдена; `plan_not_found`: Тариф не найден или недоступен"
          },
          "402": {
            "description": "`insufficient_balance`: Недостаточно средств на депозите"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/vpn/subscriptions/{subscription_id}/traffic": {
      "post": {
        "tags": [
          "subscriptions"
        ],
        "summary": "Докупить трафик (белые списки)",
        "description": "Трафик для серверов обхода белых списков, для подписки любого вида. Объём `gb`:\nподписке на срок из `traffic_packs`, подписке «Навсегда» из `lifetime.traffic_packs`\n(GET /plans). Лимит растёт сразу, у «Навсегда» купленный трафик не сгорает.\nВебхук: у подписки на срок `subscription.traffic_purchased`, у «Навсегда»\n`subscription.addon_purchased` с `addon_id`. К пробной подписке на срок трафик\nне докупается: 409 `trial_no_traffic`.",
        "operationId": "purchaseTraffic",
        "parameters": [
          {
            "name": "subscription_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid",
              "title": "Subscription Id"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TrafficBody"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Purchasetraffic"
                },
                "example": {
                  "success": true,
                  "custom_id": "11111111-1111-4111-8111-111111111111",
                  "subscription": {
                    "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                    "client_id": "01a06310-8f2b-7c11-9a3e-2b5c7d9e1f00",
                    "external_user_id": "user-42",
                    "telegram_id": null,
                    "bot_id": null,
                    "source": "api",
                    "plan_uuid": "550e8400-e29b-41d4-a716-446655440000",
                    "plan_name": "Standard 30d",
                    "is_trial": false,
                    "product": "term",
                    "is_lifetime": false,
                    "status": "active",
                    "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K",
                    "days": 30,
                    "days_left": 30,
                    "devices": 2,
                    "traffic_limit_bytes": 118111600640,
                    "auto_renew": false,
                    "renewal_count": 0,
                    "renewal_progress": 0.0,
                    "created_at": "2026-09-02T12:00:00Z",
                    "end_date": "2026-10-02T12:00:00Z",
                    "frozen_at": null,
                    "price_minor": 450,
                    "price_usd": "4.50"
                  },
                  "charged_minor": 120,
                  "charged_usd": "1.20",
                  "balance_after_minor": 11600,
                  "balance_after_usd": "116.00",
                  "gb_added": 10
                }
              }
            }
          },
          "404": {
            "description": "`subscription_not_found`: Подписка не найдена"
          },
          "409": {
            "description": "`unsupported_for_product`: Это действие не подходит к этому виду подписки; `addon_unavailable`: Эту докупку сейчас нельзя оформить; `max_charge_exceeded`: Цена операции выше max_charge_minor; `subscription_frozen`: Подписка заморожена; `subscription_expired`: Подписка истекла; `trial_no_traffic`: К пробной подписке нельзя докупить трафик; `custom_id_conflict`: Этот custom_id уже использован с другими параметрами; `operation_in_progress`: Операция с этим custom_id ещё выполняется"
          },
          "402": {
            "description": "`insufficient_balance`: Недостаточно средств на депозите"
          },
          "422": {
            "description": "`validation_error`: Неверные параметры запроса"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          }
        }
      }
    },
    "/api/v1/vpn/subscriptions/{subscription_id}/addons": {
      "post": {
        "tags": [
          "subscriptions"
        ],
        "summary": "Докупка к бессрочной (трафик, устройства)",
        "description": "Трафик для лимитных серверов или +15 устройств к подписке «Навсегда». Варианты и\nцены в `lifetime.addons` (GET /plans). Купленный трафик не сгорает и прибавляется к\nостатку. Подписке на срок трафик докупается через `/traffic`.",
        "operationId": "purchaseAddon",
        "parameters": [
          {
            "name": "subscription_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid",
              "title": "Subscription Id"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AddonBody"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Purchaseaddon"
                },
                "example": {
                  "success": true,
                  "custom_id": "11111111-1111-4111-8111-111111111111",
                  "subscription": {
                    "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                    "client_id": "01a06310-8f2b-7c11-9a3e-2b5c7d9e1f00",
                    "external_user_id": "user-42",
                    "telegram_id": null,
                    "bot_id": null,
                    "source": "api",
                    "plan_uuid": "3f0b2c1e-9a4d-5e6f-8a7b-1c2d3e4f5a6b",
                    "plan_name": "Навсегда",
                    "is_trial": false,
                    "product": "lifetime",
                    "is_lifetime": true,
                    "status": "active",
                    "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K",
                    "days": 36500,
                    "days_left": null,
                    "devices": 15,
                    "traffic_limit_bytes": 295279001600,
                    "auto_renew": false,
                    "renewal_count": 0,
                    "renewal_progress": 0.0,
                    "created_at": "2026-09-02T12:00:00Z",
                    "end_date": "2100-01-01T00:00:00Z",
                    "frozen_at": null,
                    "price_minor": 1001,
                    "price_usd": "10.01"
                  },
                  "charged_minor": 320,
                  "charged_usd": "3.20",
                  "balance_after_minor": 11730,
                  "balance_after_usd": "117.30",
                  "addon_id": "gb_200"
                }
              }
            }
          },
          "404": {
            "description": "`subscription_not_found`: Подписка не найдена"
          },
          "409": {
            "description": "`unsupported_for_product`: Это действие не подходит к этому виду подписки; `addon_unavailable`: Эту докупку сейчас нельзя оформить; `max_charge_exceeded`: Цена операции выше max_charge_minor; `custom_id_conflict`: Этот custom_id уже использован с другими параметрами; `operation_in_progress`: Операция с этим custom_id ещё выполняется"
          },
          "402": {
            "description": "`insufficient_balance`: Недостаточно средств на депозите"
          },
          "422": {
            "description": "`validation_error`: Неверные параметры запроса"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          }
        }
      }
    },
    "/api/v1/vpn/subscriptions/{subscription_id}/freeze": {
      "post": {
        "tags": [
          "subscriptions"
        ],
        "summary": "Заморозить",
        "description": "Срок останавливается, конфиг не отдаётся. Повтор по уже замороженной вернёт\n409 `subscription_state`.",
        "operationId": "freezeSubscription",
        "parameters": [
          {
            "name": "subscription_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid",
              "title": "Subscription Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Freezesubscription"
                },
                "example": {
                  "success": true,
                  "subscription": {
                    "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                    "client_id": "01a06310-8f2b-7c11-9a3e-2b5c7d9e1f00",
                    "external_user_id": "user-42",
                    "telegram_id": null,
                    "bot_id": null,
                    "source": "api",
                    "plan_uuid": "550e8400-e29b-41d4-a716-446655440000",
                    "plan_name": "Standard 30d",
                    "is_trial": false,
                    "product": "term",
                    "is_lifetime": false,
                    "status": "frozen",
                    "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K",
                    "days": 30,
                    "days_left": 30,
                    "devices": 2,
                    "traffic_limit_bytes": null,
                    "auto_renew": false,
                    "renewal_count": 0,
                    "renewal_progress": 0.0,
                    "created_at": "2026-09-02T12:00:00Z",
                    "end_date": "2026-10-02T12:00:00Z",
                    "frozen_at": "2026-09-10T08:00:00Z",
                    "price_minor": 450,
                    "price_usd": "4.50"
                  }
                }
              }
            }
          },
          "409": {
            "description": "`unsupported_for_product`: Это действие не подходит к этому виду подписки; `subscription_state`: Операция не подходит к текущему состоянию подписки; `trial_not_freezable`: Пробную подписку нельзя заморозить"
          },
          "404": {
            "description": "`subscription_not_found`: Подписка не найдена"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/vpn/subscriptions/{subscription_id}/rotate-link": {
      "post": {
        "tags": [
          "subscriptions"
        ],
        "summary": "Обновить ссылку",
        "description": "Выдаёт подписке новый адрес. Старая ссылка перестаёт отдавать конфиг сразу,\nновая в `subscription_url`: отдайте её пользователю заново. Нужно, когда ссылка\nутекла.\n\nУстройства, которые уже импортировали профиль, продолжат подключаться: ключи\nсерверов при обновлении не меняются. Чтобы закрыть доступ полностью, заморозьте\nподписку.\n\nНе чаще раза в час на подписку, иначе 409 `subscription_state`.",
        "operationId": "rotateSubscriptionLink",
        "parameters": [
          {
            "name": "subscription_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid",
              "title": "Subscription Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Rotatesubscriptionlink"
                },
                "example": {
                  "success": true,
                  "subscription": {
                    "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                    "client_id": "01a06310-8f2b-7c11-9a3e-2b5c7d9e1f00",
                    "external_user_id": "user-42",
                    "telegram_id": null,
                    "bot_id": null,
                    "source": "api",
                    "plan_uuid": "550e8400-e29b-41d4-a716-446655440000",
                    "plan_name": "Standard 30d",
                    "is_trial": false,
                    "product": "term",
                    "is_lifetime": false,
                    "status": "active",
                    "subscription_url": "https://api.relayhub.surf/sub/Qm4kLc7VZp2xR9tHf6yWn0Ea",
                    "days": 30,
                    "days_left": 30,
                    "devices": 2,
                    "traffic_limit_bytes": null,
                    "auto_renew": false,
                    "renewal_count": 0,
                    "renewal_progress": 0.0,
                    "created_at": "2026-09-02T12:00:00Z",
                    "end_date": "2026-10-02T12:00:00Z",
                    "frozen_at": null,
                    "price_minor": 450,
                    "price_usd": "4.50"
                  },
                  "rotated_at": "2026-09-10T08:00:00Z"
                }
              }
            }
          },
          "404": {
            "description": "`subscription_not_found`: Подписка не найдена"
          },
          "409": {
            "description": "`subscription_state`: Операция не подходит к текущему состоянию подписки"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/vpn/subscriptions/{subscription_id}/unfreeze": {
      "post": {
        "tags": [
          "subscriptions"
        ],
        "summary": "Разморозить",
        "description": "Срок продолжается с того места, где был остановлен. Новый `end_date` в ответе.",
        "operationId": "unfreezeSubscription",
        "parameters": [
          {
            "name": "subscription_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid",
              "title": "Subscription Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Unfreezesubscription"
                },
                "example": {
                  "success": true,
                  "subscription": {
                    "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                    "client_id": "01a06310-8f2b-7c11-9a3e-2b5c7d9e1f00",
                    "external_user_id": "user-42",
                    "telegram_id": null,
                    "bot_id": null,
                    "source": "api",
                    "plan_uuid": "550e8400-e29b-41d4-a716-446655440000",
                    "plan_name": "Standard 30d",
                    "is_trial": false,
                    "product": "term",
                    "is_lifetime": false,
                    "status": "active",
                    "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K",
                    "days": 30,
                    "days_left": 30,
                    "devices": 2,
                    "traffic_limit_bytes": null,
                    "auto_renew": false,
                    "renewal_count": 0,
                    "renewal_progress": 0.0,
                    "created_at": "2026-09-02T12:00:00Z",
                    "end_date": "2026-10-02T12:00:00Z",
                    "frozen_at": null,
                    "price_minor": 450,
                    "price_usd": "4.50"
                  }
                }
              }
            }
          },
          "409": {
            "description": "`unsupported_for_product`: Это действие не подходит к этому виду подписки; `subscription_state`: Операция не подходит к текущему состоянию подписки"
          },
          "404": {
            "description": "`subscription_not_found`: Подписка не найдена"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/vpn/clients/link": {
      "post": {
        "tags": [
          "clients"
        ],
        "summary": "Привязать пользователя из бота",
        "description": "Связывает ваш `external_user_id` с человеком из вашего бота по `telegram_id`, без\nпокупки. После этого его подписки, купленные в боте, находятся по\n`external_user_id` и управляются через API.\n\n`result`: `linked` (привязали), `already_linked` (уже было так, повтор безопасен),\n`created` (человека с таким Telegram у вас ещё не было: заведён, и бот увидит его,\nкогда он придёт). 409 `external_user_conflict` с полем `conflict`: `telegram_taken`\n(этот Telegram уже под другим `external_user_id`, он в поле `external_user_id`) или\n`external_id_taken` (ваш `external_user_id` уже у другого человека).",
        "operationId": "linkClient",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LinkClientBody"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "additionalProperties": true,
                  "type": "object",
                  "title": "Response Linkclient"
                },
                "example": {
                  "success": true,
                  "result": "linked",
                  "client": {
                    "client_id": "01a06310-8f2b-7c11-9a3e-2b5c7d9e1f00",
                    "external_user_id": "user-42",
                    "telegram_id": 123456789,
                    "bot_id": "01a06300-1c4e-7d2a-8b6f-9e0a1b2c3d4e",
                    "source": "bot",
                    "created_at": "2026-08-14T09:30:00Z"
                  }
                }
              }
            }
          },
          "409": {
            "description": "`external_user_conflict`: telegram_id или external_user_id уже привязан к другому пользователю"
          },
          "403": {
            "description": "`forbidden`: Объект принадлежит другому аккаунту; `api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "422": {
            "description": "`validation_error`: Неверные параметры запроса"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          }
        }
      }
    },
    "/api/v1/vpn/clients/link/batch": {
      "post": {
        "tags": [
          "clients"
        ],
        "summary": "Привязать пользователей пачкой",
        "description": "То же, что `POST /clients/link`, до 1000 строк за раз: перенести базу бота одним\nвызовом. Строки независимы: конфликт одной не отменяет остальные, ответ по каждой в\nтом же порядке (`result` или `error_code` с `error`). Повтор всего пакета безопасен.",
        "operationId": "linkClientsBatch",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LinkClientsBatchBody"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "additionalProperties": true,
                  "type": "object",
                  "title": "Response Linkclientsbatch"
                },
                "example": {
                  "success": true,
                  "total": 2,
                  "failed": 1,
                  "items": [
                    {
                      "external_user_id": "user-42",
                      "telegram_id": 123456789,
                      "result": "linked",
                      "client_id": "01a06310-8f2b-7c11-9a3e-2b5c7d9e1f00"
                    },
                    {
                      "external_user_id": "user-43",
                      "telegram_id": 987654321,
                      "result": "error",
                      "error_code": "external_user_conflict",
                      "error": "telegram_id или external_user_id уже привязан к другому пользователю",
                      "conflict": "telegram_taken",
                      "conflict_external_user_id": "user-17"
                    }
                  ]
                }
              }
            }
          },
          "422": {
            "description": "`validation_error`: Неверные параметры запроса"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          }
        }
      }
    },
    "/api/v1/vpn/clients": {
      "get": {
        "tags": [
          "clients"
        ],
        "summary": "Найти пользователей",
        "description": "Пользователи аккаунта, новые сверху, у каждого до 20 последних подписок (полный\nсписок в `GET /subscriptions?external_user_id=…`). Сюда попадают и люди из ботов, и\nзаведённые через API.",
        "operationId": "listClients",
        "parameters": [
          {
            "name": "telegram_id",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "integer",
                  "maximum": 9223372036854775807,
                  "exclusiveMinimum": 0
                },
                {
                  "type": "null"
                }
              ],
              "description": "Только этот Telegram id.",
              "title": "Telegram Id"
            },
            "description": "Только этот Telegram id."
          },
          {
            "name": "external_user_id",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "maxLength": 128
                },
                {
                  "type": "null"
                }
              ],
              "description": "Только этот ваш id.",
              "title": "External User Id"
            },
            "description": "Только этот ваш id."
          },
          {
            "name": "linked",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "boolean"
                },
                {
                  "type": "null"
                }
              ],
              "description": "`true`: только с `external_user_id`, `false`: только без него (кого ещё не привязали).",
              "title": "Linked"
            },
            "description": "`true`: только с `external_user_id`, `false`: только без него (кого ещё не привязали)."
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "description": "Сколько пропустить.",
              "default": 0,
              "title": "Offset"
            },
            "description": "Сколько пропустить."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "maximum": 100,
              "minimum": 1,
              "description": "Размер страницы, до 100.",
              "default": 20,
              "title": "Limit"
            },
            "description": "Размер страницы, до 100."
          }
        ],
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Listclients"
                },
                "example": {
                  "success": true,
                  "total": 1,
                  "offset": 0,
                  "limit": 20,
                  "items": [
                    {
                      "client_id": "01a06310-8f2b-7c11-9a3e-2b5c7d9e1f00",
                      "external_user_id": "user-42",
                      "telegram_id": 123456789,
                      "bot_id": "01a06300-1c4e-7d2a-8b6f-9e0a1b2c3d4e",
                      "source": "bot",
                      "created_at": "2026-08-14T09:30:00Z",
                      "subscriptions": [
                        {
                          "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                          "client_id": "01a06310-8f2b-7c11-9a3e-2b5c7d9e1f00",
                          "external_user_id": "user-42",
                          "telegram_id": 123456789,
                          "bot_id": "01a06300-1c4e-7d2a-8b6f-9e0a1b2c3d4e",
                          "source": "bot",
                          "plan_uuid": "550e8400-e29b-41d4-a716-446655440000",
                          "plan_name": "Standard 30d",
                          "is_trial": false,
                          "product": "term",
                          "is_lifetime": false,
                          "status": "active",
                          "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K",
                          "days": 30,
                          "days_left": 30,
                          "devices": 2,
                          "traffic_limit_bytes": null,
                          "auto_renew": false,
                          "renewal_count": 0,
                          "renewal_progress": 0.0,
                          "created_at": "2026-09-02T12:00:00Z",
                          "end_date": "2026-10-02T12:00:00Z",
                          "frozen_at": null,
                          "price_minor": 450,
                          "price_usd": "4.50"
                        }
                      ]
                    }
                  ]
                }
              }
            }
          },
          "422": {
            "description": "`validation_error`: Неверные параметры запроса"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          }
        }
      }
    },
    "/api/v1/vpn/operations/{custom_id}": {
      "get": {
        "tags": [
          "operations"
        ],
        "summary": "Результат операции",
        "description": "Что стало с денежной операцией: `in_progress` (ещё выполняется), `done` (готово,\nответ в `result`) или `failed` (отказ, тело в `error`, тот же `custom_id` можно\nповторить). Ничего не меняет и денег не списывает: годится для восстановления после\nпотерянного ответа или перезапуска вашего сервера. 404 `operation_not_found` значит,\nчто запрос до нас не дошёл.",
        "operationId": "getOperation",
        "parameters": [
          {
            "name": "custom_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "maxLength": 128,
              "description": "Ваш custom_id операции.",
              "title": "Custom Id"
            },
            "description": "Ваш custom_id операции."
          }
        ],
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Getoperation"
                },
                "example": {
                  "success": true,
                  "operation": {
                    "custom_id": "11111111-1111-4111-8111-111111111111",
                    "operation": "subscription.create",
                    "status": "done",
                    "http_status": 201,
                    "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                    "error_code": null,
                    "created_at": "2026-09-02T12:00:00Z",
                    "updated_at": "2026-09-02T12:00:01Z",
                    "result": {
                      "success": true,
                      "custom_id": "11111111-1111-4111-8111-111111111111",
                      "subscription": {
                        "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                        "client_id": "01a06310-8f2b-7c11-9a3e-2b5c7d9e1f00",
                        "external_user_id": "user-42",
                        "telegram_id": null,
                        "bot_id": null,
                        "source": "api",
                        "plan_uuid": "550e8400-e29b-41d4-a716-446655440000",
                        "plan_name": "Standard 30d",
                        "is_trial": false,
                        "product": "term",
                        "is_lifetime": false,
                        "status": "active",
                        "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K",
                        "days": 30,
                        "days_left": 30,
                        "devices": 2,
                        "traffic_limit_bytes": null,
                        "auto_renew": false,
                        "renewal_count": 0,
                        "renewal_progress": 0.0,
                        "created_at": "2026-09-02T12:00:00Z",
                        "end_date": "2026-10-02T12:00:00Z",
                        "frozen_at": null,
                        "price_minor": 450,
                        "price_usd": "4.50"
                      },
                      "charged_minor": 450,
                      "charged_usd": "4.50",
                      "balance_after_minor": 11600,
                      "balance_after_usd": "116.00"
                    },
                    "error": null
                  }
                }
              }
            }
          },
          "404": {
            "description": "`operation_not_found`: Операция с таким custom_id не найдена"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/vpn/subscriptions/{subscription_id}/devices": {
      "get": {
        "tags": [
          "devices"
        ],
        "summary": "Устройства подписки",
        "description": "Устройства, подключавшие конфиг, у подписки любого вида, и на срок, и «Навсегда».\nЛимит задаёт тариф (`devices` в подписке). `last_seen` у «Навсегда» это последнее\nобновление конфига устройством.",
        "operationId": "listDevices",
        "parameters": [
          {
            "name": "subscription_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid",
              "title": "Subscription Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Listdevices"
                },
                "example": {
                  "success": true,
                  "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                  "devices": [
                    {
                      "id": 7,
                      "hwid": "9f2a6c1e-iphone",
                      "name": "iPhone 15",
                      "os": "iOS",
                      "model": "iPhone15,2",
                      "os_version": "18.5",
                      "ip": "203.0.113.7",
                      "first_seen": "2026-09-02T12:05:00+00:00",
                      "last_seen": "2026-09-02T18:40:00+00:00"
                    }
                  ]
                }
              }
            }
          },
          "409": {
            "description": "`unsupported_for_product`: Это действие не подходит к этому виду подписки"
          },
          "404": {
            "description": "`subscription_not_found`: Подписка не найдена"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/vpn/subscriptions/{subscription_id}/devices/{device_id}": {
      "delete": {
        "tags": [
          "devices"
        ],
        "summary": "Удалить устройство",
        "description": "Освобождает слот. По умолчанию ещё и отключает устройство: переустановка\nVPN-клиента не помогает, идентификатор устройства остаётся тем же. Вернуть его\nможно через `DELETE /subscriptions/{id}/devices/blocked/{block_id}`.",
        "operationId": "deleteDevice",
        "parameters": [
          {
            "name": "subscription_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid",
              "title": "Subscription Id"
            }
          },
          {
            "name": "device_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "title": "Device Id"
            }
          },
          {
            "name": "block",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "description": "`true`: освободить слот и отключить устройство, по этой ссылке оно больше не получит конфиг. `false`: только освободить слот, устройство подключится заново.",
              "default": true,
              "title": "Block"
            },
            "description": "`true`: освободить слот и отключить устройство, по этой ссылке оно больше не получит конфиг. `false`: только освободить слот, устройство подключится заново."
          }
        ],
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Deletedevice"
                },
                "example": {
                  "success": true,
                  "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                  "device_id": 7,
                  "blocked": true
                }
              }
            }
          },
          "409": {
            "description": "`unsupported_for_product`: Это действие не подходит к этому виду подписки"
          },
          "404": {
            "description": "`subscription_not_found`: Подписка не найдена; `device_not_found`: Устройство не найдено"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/vpn/subscriptions/{subscription_id}/devices/blocked": {
      "get": {
        "tags": [
          "devices"
        ],
        "summary": "Отключённые устройства",
        "description": "Устройства, удалённые с отключением: по этой ссылке они конфиг не получают.",
        "operationId": "listBlockedDevices",
        "parameters": [
          {
            "name": "subscription_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid",
              "title": "Subscription Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Listblockeddevices"
                },
                "example": {
                  "success": true,
                  "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                  "blocked_devices": [
                    {
                      "block_id": "01a10bb4-3455-7a54-b46a-886c3f61f723",
                      "hwid": "9f2a6c1e-iphone",
                      "label": "iPhone 15 (iOS)",
                      "blocked_at": "2026-09-02T19:00:00Z"
                    }
                  ]
                }
              }
            }
          },
          "409": {
            "description": "`unsupported_for_product`: Это действие не подходит к этому виду подписки"
          },
          "404": {
            "description": "`subscription_not_found`: Подписка не найдена"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/vpn/subscriptions/{subscription_id}/devices/blocked/{block_id}": {
      "delete": {
        "tags": [
          "devices"
        ],
        "summary": "Вернуть отключённое устройство",
        "description": "Снимает отключение: при следующем обновлении подписки в VPN-клиенте устройство\nподключится заново и займёт слот, если он свободен. Ссылка не меняется.",
        "operationId": "unblockDevice",
        "parameters": [
          {
            "name": "subscription_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid",
              "title": "Subscription Id"
            }
          },
          {
            "name": "block_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid",
              "title": "Block Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Unblockdevice"
                },
                "example": {
                  "success": true,
                  "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                  "block_id": "01a10bb4-3455-7a54-b46a-886c3f61f723",
                  "unblocked": true
                }
              }
            }
          },
          "409": {
            "description": "`unsupported_for_product`: Это действие не подходит к этому виду подписки"
          },
          "404": {
            "description": "`subscription_not_found`: Подписка не найдена; `device_block_not_found`: Отключённое устройство не найдено"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/vpn/deposit/providers": {
      "get": {
        "tags": [
          "deposits"
        ],
        "summary": "Способы пополнения",
        "description": "Провайдеры, доступные прямо сейчас. Набор зависит от настроек площадки.",
        "operationId": "listDepositProviders",
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "additionalProperties": true,
                  "type": "object",
                  "title": "Response Listdepositproviders"
                },
                "example": {
                  "success": true,
                  "providers": [
                    "cryptobot",
                    "heleket",
                    "lava",
                    "platega"
                  ]
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          }
        }
      }
    },
    "/api/v1/vpn/deposit": {
      "post": {
        "tags": [
          "deposits"
        ],
        "summary": "Выставить счёт на пополнение",
        "description": "Возвращает `invoice_url`: откройте его сами или отдайте бухгалтеру. Зачисление\nприходит вебхуком `deposit.credited` и видно в GET /deposit/{custom_id}.\n\nПовтор с тем же `custom_id` и другой суммой или провайдером отбивается 409: для\nденег тихий повтор со старой суммой опаснее ошибки.",
        "operationId": "createDeposit",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DepositBody"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "additionalProperties": true,
                  "type": "object",
                  "title": "Response Createdeposit"
                },
                "example": {
                  "success": true,
                  "deposit": {
                    "custom_id": "11111111-1111-4111-8111-111111111111",
                    "deposit_id": "01a06320-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
                    "status": "pending",
                    "provider": "cryptobot",
                    "amount_minor": 1000,
                    "amount_usd": "10.00",
                    "amount_display_minor": 92500,
                    "gross_display_minor": 95275,
                    "display_currency": "RUB",
                    "invoice_url": "https://t.me/CryptoBot?start=IVxxxxxxxx",
                    "created_at": "2026-09-02T12:00:00Z",
                    "paid_at": null,
                    "expires_at": "2026-09-02T13:00:00Z"
                  }
                }
              }
            }
          },
          "422": {
            "description": "`validation_error`: Неверные параметры запроса"
          },
          "409": {
            "description": "`custom_id_conflict`: Этот custom_id уже использован с другими параметрами; `too_many_open_deposits`: Слишком много неоплаченных пополнений"
          },
          "503": {
            "description": "`provider_unavailable`: Платёжный способ временно недоступен; `upstream_unavailable`: Поставщик VPN временно недоступен"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          }
        }
      }
    },
    "/api/v1/vpn/deposit/{custom_id}": {
      "get": {
        "tags": [
          "deposits"
        ],
        "summary": "Статус пополнения",
        "operationId": "getDeposit",
        "parameters": [
          {
            "name": "custom_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Custom Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Getdeposit"
                },
                "example": {
                  "success": true,
                  "deposit": {
                    "custom_id": "11111111-1111-4111-8111-111111111111",
                    "deposit_id": "01a06320-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
                    "status": "credited",
                    "provider": "cryptobot",
                    "amount_minor": 1000,
                    "amount_usd": "10.00",
                    "amount_display_minor": 92500,
                    "gross_display_minor": 95275,
                    "display_currency": "RUB",
                    "invoice_url": "https://t.me/CryptoBot?start=IVxxxxxxxx",
                    "created_at": "2026-09-02T12:00:00Z",
                    "paid_at": "2026-09-02T12:07:00Z",
                    "expires_at": "2026-09-02T13:00:00Z"
                  }
                }
              }
            }
          },
          "404": {
            "description": "`deposit_not_found`: Пополнение не найдено"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/vpn/deposits": {
      "get": {
        "tags": [
          "deposits"
        ],
        "summary": "История пополнений",
        "operationId": "listDeposits",
        "parameters": [
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "description": "Сколько пропустить.",
              "default": 0,
              "title": "Offset"
            },
            "description": "Сколько пропустить."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "maximum": 100,
              "minimum": 1,
              "description": "Размер страницы, до 100.",
              "default": 20,
              "title": "Limit"
            },
            "description": "Размер страницы, до 100."
          }
        ],
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Listdeposits"
                },
                "example": {
                  "success": true,
                  "total": 1,
                  "offset": 0,
                  "limit": 20,
                  "items": [
                    {
                      "custom_id": "11111111-1111-4111-8111-111111111111",
                      "deposit_id": "01a06320-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
                      "status": "pending",
                      "provider": "cryptobot",
                      "amount_minor": 1000,
                      "amount_usd": "10.00",
                      "amount_display_minor": 92500,
                      "gross_display_minor": 95275,
                      "display_currency": "RUB",
                      "invoice_url": "https://t.me/CryptoBot?start=IVxxxxxxxx",
                      "created_at": "2026-09-02T12:00:00Z",
                      "paid_at": null,
                      "expires_at": "2026-09-02T13:00:00Z"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/vpn/webhook": {
      "get": {
        "tags": [
          "webhooks"
        ],
        "summary": "Настройки вебхука",
        "operationId": "getWebhook",
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "additionalProperties": true,
                  "type": "object",
                  "title": "Response Getwebhook"
                },
                "example": {
                  "success": true,
                  "url": "https://example.com/vpn-webhook",
                  "events": [
                    "deposit.credited",
                    "subscription.expired",
                    "subscription.expires_in_24h"
                  ],
                  "is_active": true,
                  "secret_set": true,
                  "all_events": [
                    "subscription.created",
                    "subscription.renewed",
                    "subscription.upgraded",
                    "subscription.traffic_purchased",
                    "subscription.frozen",
                    "subscription.unfrozen",
                    "subscription.expires_in_72h",
                    "subscription.expires_in_48h",
                    "subscription.expires_in_24h",
                    "subscription.expired",
                    "subscription.traffic_threshold",
                    "deposit.credited"
                  ],
                  "last_success_at": "2026-09-02T12:07:01Z",
                  "last_failure_at": null,
                  "consecutive_failures": 0
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          }
        }
      },
      "put": {
        "tags": [
          "webhooks"
        ],
        "summary": "Задать адрес и события",
        "description": "Секрет подписи возьмите в кабинете (раздел «Ключ и вебхук»). Проверяйте подпись:\n`X-Signature: sha256=hex(HMAC-SHA256(secret, raw_body))`, сравнение\nconstant-time по сырым байтам тела до разбора JSON.",
        "operationId": "setWebhook",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookBody"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "additionalProperties": true,
                  "type": "object",
                  "title": "Response Setwebhook"
                },
                "example": {
                  "success": true,
                  "url": "https://example.com/vpn-webhook",
                  "events": [
                    "deposit.credited",
                    "subscription.expired",
                    "subscription.expires_in_24h"
                  ],
                  "is_active": true,
                  "secret_set": true,
                  "all_events": [
                    "subscription.created",
                    "subscription.renewed",
                    "subscription.upgraded",
                    "subscription.traffic_purchased",
                    "subscription.frozen",
                    "subscription.unfrozen",
                    "subscription.expires_in_72h",
                    "subscription.expires_in_48h",
                    "subscription.expires_in_24h",
                    "subscription.expired",
                    "subscription.traffic_threshold",
                    "deposit.credited"
                  ],
                  "last_success_at": "2026-09-02T12:07:01Z",
                  "last_failure_at": null,
                  "consecutive_failures": 0
                }
              }
            }
          },
          "422": {
            "description": "`validation_error`: Неверные параметры запроса"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          }
        }
      }
    },
    "/api/v1/vpn/webhook/test": {
      "post": {
        "tags": [
          "webhooks"
        ],
        "summary": "Тестовая отправка",
        "description": "Шлёт событие `webhook.test` на ваш адрес прямо сейчас и возвращает, что\nответил сервер. Одна попытка, без ретраев.",
        "operationId": "testWebhook",
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "additionalProperties": true,
                  "type": "object",
                  "title": "Response Testwebhook"
                },
                "example": {
                  "success": true,
                  "delivered": true,
                  "status": "delivered",
                  "http_status": 200,
                  "error": null,
                  "duration_ms": 143,
                  "delivery_id": "01a06321-0000-7000-8000-000000000001"
                }
              }
            }
          },
          "409": {
            "description": "`webhook_not_configured`: Адрес вебхука не задан"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          }
        }
      }
    },
    "/api/v1/vpn/events": {
      "get": {
        "tags": [
          "webhooks"
        ],
        "summary": "Лента событий",
        "description": "События, которые готовились вашему вебхуку, в порядке появления: чтобы догнать\nпропущенное после сбоя вашего сервера, не дожидаясь повторов. В `payload` ровно то\nтело, что уходит вебхуком, рядом статус его доставки. Листайте по `next_cursor`, пока\nон не `null`. Хранятся 30 дней; событий нет, если вебхук не задан.",
        "operationId": "listEvents",
        "parameters": [
          {
            "name": "since",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "format": "date-time"
                },
                {
                  "type": "null"
                }
              ],
              "description": "С какого момента (ISO-8601). Не задан: самые старые из хранимых.",
              "examples": [
                "2026-10-14T00:00:00Z"
              ],
              "title": "Since"
            },
            "description": "С какого момента (ISO-8601). Не задан: самые старые из хранимых."
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "maxLength": 200
                },
                {
                  "type": "null"
                }
              ],
              "description": "`next_cursor` из прошлой страницы. С ним `since` не нужен.",
              "title": "Cursor"
            },
            "description": "`next_cursor` из прошлой страницы. С ним `since` не нужен."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "maximum": 100,
              "minimum": 1,
              "description": "До 100.",
              "default": 50,
              "title": "Limit"
            },
            "description": "До 100."
          }
        ],
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Listevents"
                },
                "example": {
                  "success": true,
                  "items": [
                    {
                      "event_id": "01a06321-0000-7000-8000-000000000002",
                      "event": "subscription.expires_in_24h",
                      "occurred_at": "2026-10-01T12:00:00Z",
                      "delivery_status": "dead",
                      "payload": {
                        "event": "subscription.expires_in_24h",
                        "event_id": "01a06321-0000-7000-8000-000000000002",
                        "occurred_at": "2026-10-01T12:00:00Z",
                        "source": "network",
                        "schema_version": 1,
                        "subscription": {
                          "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
                          "client_id": "01a06310-8f2b-7c11-9a3e-2b5c7d9e1f00",
                          "external_user_id": "user-42",
                          "telegram_id": null,
                          "bot_id": null,
                          "source": "api",
                          "plan_uuid": "550e8400-e29b-41d4-a716-446655440000",
                          "plan_name": "Standard 30d",
                          "is_trial": false,
                          "product": "term",
                          "is_lifetime": false,
                          "status": "active",
                          "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K",
                          "days": 30,
                          "days_left": 1,
                          "devices": 2,
                          "traffic_limit_bytes": null,
                          "auto_renew": false,
                          "renewal_count": 0,
                          "renewal_progress": 0.0,
                          "created_at": "2026-09-02T12:00:00Z",
                          "end_date": "2026-10-02T12:00:00Z",
                          "frozen_at": null,
                          "price_minor": 450,
                          "price_usd": "4.50"
                        }
                      }
                    }
                  ],
                  "next_cursor": "MjAyNi0xMC0wMVQxMjowMDowMCswMDowMHwwMWEwNjMyMS0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwMDI"
                }
              }
            }
          },
          "422": {
            "description": "`validation_error`: Неверные параметры запроса"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          }
        }
      }
    },
    "/api/v1/vpn/webhook/deliveries": {
      "get": {
        "tags": [
          "webhooks"
        ],
        "summary": "Журнал доставок",
        "description": "Попытки доставки вебхуков, новые сверху: статус, что ответил ваш сервер, сколько\nшло. То же, что раздел «Доставки вебхуков» в кабинете.",
        "operationId": "listWebhookDeliveries",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "enum": [
                    "delivered",
                    "failed",
                    "pending",
                    "dead"
                  ],
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "description": "Фильтр по статусу доставки.",
              "title": "Status"
            },
            "description": "Фильтр по статусу доставки."
          },
          {
            "name": "event",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "maxLength": 48
                },
                {
                  "type": "null"
                }
              ],
              "description": "Фильтр по событию.",
              "title": "Event"
            },
            "description": "Фильтр по событию."
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "description": "Сколько пропустить.",
              "default": 0,
              "title": "Offset"
            },
            "description": "Сколько пропустить."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "maximum": 100,
              "minimum": 1,
              "description": "Размер страницы, до 100.",
              "default": 20,
              "title": "Limit"
            },
            "description": "Размер страницы, до 100."
          }
        ],
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Listwebhookdeliveries"
                },
                "example": {
                  "success": true,
                  "total": 1,
                  "offset": 0,
                  "limit": 20,
                  "items": [
                    {
                      "delivery_id": "01a06321-0000-7000-8000-000000000002",
                      "event": "subscription.expires_in_24h",
                      "status": "dead",
                      "attempts": 10,
                      "http_status": 502,
                      "error": "Bad Gateway",
                      "duration_ms": 1840,
                      "is_manual": false,
                      "created_at": "2026-10-01T12:00:00Z",
                      "delivered_at": null,
                      "next_attempt_at": null
                    }
                  ]
                }
              }
            }
          },
          "422": {
            "description": "`validation_error`: Неверные параметры запроса"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          }
        }
      }
    },
    "/api/v1/vpn/webhook/deliveries/{delivery_id}/redeliver": {
      "post": {
        "tags": [
          "webhooks"
        ],
        "summary": "Отправить событие заново",
        "description": "Повторяет доставку прямо сейчас тем же телом (новая метка времени и подпись) и\nвозвращает, что ответил ваш сервер. Одна попытка, счётчик неудач адреса не растёт.\nНе больше 10 ручных отправок в минуту вместе с тестовой.",
        "operationId": "redeliverWebhook",
        "parameters": [
          {
            "name": "delivery_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid",
              "title": "Delivery Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Успех",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "title": "Response Redeliverwebhook"
                },
                "example": {
                  "success": true,
                  "delivered": true,
                  "status": "delivered",
                  "http_status": 200,
                  "error": null,
                  "duration_ms": 143,
                  "delivery_id": "01a06321-0000-7000-8000-000000000001"
                }
              }
            }
          },
          "409": {
            "description": "`webhook_not_configured`: Адрес вебхука не задан"
          },
          "404": {
            "description": "`not_found`: Объект не найден"
          },
          "401": {
            "description": "`unauthorized`: Неверный или отсутствующий API-ключ"
          },
          "403": {
            "description": "`api_disabled`: Доступ к API для этого аккаунта закрыт; `ip_not_allowed`: Адрес запроса не входит в список разрешённых"
          },
          "429": {
            "description": "`rate_limited`: Слишком много запросов"
          },
          "503": {
            "description": "`upstream_unavailable`: Поставщик VPN временно недоступен"
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "AddonBody": {
        "properties": {
          "custom_id": {
            "type": "string",
            "maxLength": 128,
            "minLength": 1,
            "pattern": "^[A-Za-z0-9._:\\-]+$",
            "title": "Custom Id",
            "description": "Ваш идентификатор операции, уникальный в рамках аккаунта. Повтор запроса с тем же custom_id вернёт результат первой попытки без повторного списания.",
            "examples": [
              "11111111-1111-4111-8111-111111111111"
            ]
          },
          "addon_id": {
            "type": "string",
            "maxLength": 32,
            "minLength": 1,
            "title": "Addon Id",
            "description": "Докупка из `lifetime.addons` в GET /plans: `gb_50`, `gb_200`, `gb_1000`, `gb_2000` или `devices_15`.",
            "examples": [
              "gb_200"
            ]
          },
          "max_charge_minor": {
            "anyOf": [
              {
                "type": "integer",
                "minimum": 0.0
              },
              {
                "type": "null"
              }
            ],
            "title": "Max Charge Minor",
            "description": "Необязательно. Сколько центов вы готовы списать. Если цена операции выше, она не выполняется и отвечает 409 `max_charge_exceeded`, деньги не списаны. Передайте сумму, которую показали покупателю.",
            "examples": [
              300
            ]
          }
        },
        "additionalProperties": false,
        "type": "object",
        "required": [
          "custom_id",
          "addon_id"
        ],
        "title": "AddonBody"
      },
      "CreateSubscriptionBody": {
        "properties": {
          "external_user_id": {
            "type": "string",
            "maxLength": 128,
            "minLength": 1,
            "title": "External User Id",
            "description": "Идентификатор пользователя в вашей системе. Один человек = один id.",
            "examples": [
              "user-42"
            ]
          },
          "telegram_id": {
            "anyOf": [
              {
                "type": "integer",
                "exclusiveMinimum": 0.0
              },
              {
                "type": "null"
              }
            ],
            "title": "Telegram Id",
            "description": "Telegram id пользователя. Если задан, подписка появится у него в мини-аппе вашего бота (укажите bot_id) и он получит уведомления бота.",
            "examples": [
              123456789
            ]
          },
          "bot_id": {
            "anyOf": [
              {
                "type": "string",
                "format": "uuid"
              },
              {
                "type": "null"
              }
            ],
            "title": "Bot Id",
            "description": "UUID вашего бота (кабинет, «Ключ и вебхук», «Ваши боты»). Нужен вместе с telegram_id, чтобы подписка появилась в мини-приложении бота."
          },
          "custom_id": {
            "type": "string",
            "maxLength": 128,
            "minLength": 1,
            "pattern": "^[A-Za-z0-9._:\\-]+$",
            "title": "Custom Id",
            "description": "Ваш идентификатор операции, уникальный в рамках аккаунта. Повтор запроса с тем же custom_id вернёт результат первой попытки без повторного списания.",
            "examples": [
              "11111111-1111-4111-8111-111111111111"
            ]
          },
          "plan_uuid": {
            "type": "string",
            "format": "uuid",
            "title": "Plan Uuid",
            "description": "Тариф из GET /plans.",
            "examples": [
              "550e8400-e29b-41d4-a716-446655440000"
            ]
          },
          "max_charge_minor": {
            "anyOf": [
              {
                "type": "integer",
                "minimum": 0.0
              },
              {
                "type": "null"
              }
            ],
            "title": "Max Charge Minor",
            "description": "Необязательно. Сколько центов вы готовы списать. Если цена операции выше, она не выполняется и отвечает 409 `max_charge_exceeded`, деньги не списаны. Передайте сумму, которую показали покупателю.",
            "examples": [
              300
            ]
          }
        },
        "additionalProperties": false,
        "type": "object",
        "required": [
          "external_user_id",
          "custom_id",
          "plan_uuid"
        ],
        "title": "CreateSubscriptionBody"
      },
      "DepositBody": {
        "properties": {
          "custom_id": {
            "type": "string",
            "maxLength": 128,
            "minLength": 1,
            "pattern": "^[A-Za-z0-9._:\\-]+$",
            "title": "Custom Id",
            "description": "Ваш идентификатор операции, уникальный в рамках аккаунта. Повтор запроса с тем же custom_id вернёт результат первой попытки без повторного списания.",
            "examples": [
              "11111111-1111-4111-8111-111111111111"
            ]
          },
          "provider": {
            "type": "string",
            "title": "Provider",
            "description": "Способ оплаты из GET /deposit/providers.",
            "examples": [
              "cryptobot"
            ]
          },
          "amount_minor": {
            "type": "integer",
            "exclusiveMinimum": 0.0,
            "title": "Amount Minor",
            "description": "Сумма пополнения в центах USD. Зачислится ровно она; комиссия провайдера сверху.",
            "examples": [
              1000
            ]
          }
        },
        "additionalProperties": false,
        "type": "object",
        "required": [
          "custom_id",
          "provider",
          "amount_minor"
        ],
        "title": "DepositBody"
      },
      "HTTPValidationError": {
        "properties": {
          "detail": {
            "items": {
              "$ref": "#/components/schemas/ValidationError"
            },
            "type": "array",
            "title": "Detail"
          }
        },
        "type": "object",
        "title": "HTTPValidationError"
      },
      "LinkClientBody": {
        "properties": {
          "external_user_id": {
            "type": "string",
            "maxLength": 128,
            "minLength": 1,
            "title": "External User Id",
            "description": "Идентификатор пользователя в вашей системе. Один человек = один id.",
            "examples": [
              "user-42"
            ]
          },
          "telegram_id": {
            "type": "integer",
            "maximum": 9.223372036854776e+18,
            "exclusiveMinimum": 0.0,
            "title": "Telegram Id",
            "description": "Telegram id человека, который уже есть в вашем боте (или придёт туда).",
            "examples": [
              123456789
            ]
          },
          "bot_id": {
            "anyOf": [
              {
                "type": "string",
                "format": "uuid"
              },
              {
                "type": "null"
              }
            ],
            "title": "Bot Id",
            "description": "Необязательно. UUID вашего бота: запишем его человеку из бота, у которого бот ещё не записан. Новому человеку бот не ставим: он запишется, когда человек откроет бота."
          }
        },
        "additionalProperties": false,
        "type": "object",
        "required": [
          "external_user_id",
          "telegram_id"
        ],
        "title": "LinkClientBody",
        "description": "Привязка своего id к человеку из бота (#232). Telegram обязателен: по нему и\nищем клиента бота."
      },
      "LinkClientsBatchBody": {
        "properties": {
          "items": {
            "items": {
              "$ref": "#/components/schemas/LinkClientBody"
            },
            "type": "array",
            "maxItems": 1000,
            "minItems": 1,
            "title": "Items",
            "description": "До 1000 привязок за запрос. Каждая строка обрабатывается отдельно."
          }
        },
        "additionalProperties": false,
        "type": "object",
        "required": [
          "items"
        ],
        "title": "LinkClientsBatchBody"
      },
      "RenewBody": {
        "properties": {
          "custom_id": {
            "type": "string",
            "maxLength": 128,
            "minLength": 1,
            "pattern": "^[A-Za-z0-9._:\\-]+$",
            "title": "Custom Id",
            "description": "Ваш идентификатор операции, уникальный в рамках аккаунта. Повтор запроса с тем же custom_id вернёт результат первой попытки без повторного списания.",
            "examples": [
              "11111111-1111-4111-8111-111111111111"
            ]
          },
          "max_charge_minor": {
            "anyOf": [
              {
                "type": "integer",
                "minimum": 0.0
              },
              {
                "type": "null"
              }
            ],
            "title": "Max Charge Minor",
            "description": "Необязательно. Сколько центов вы готовы списать. Если цена операции выше, она не выполняется и отвечает 409 `max_charge_exceeded`, деньги не списаны. Передайте сумму, которую показали покупателю.",
            "examples": [
              300
            ]
          }
        },
        "additionalProperties": false,
        "type": "object",
        "required": [
          "custom_id"
        ],
        "title": "RenewBody"
      },
      "RenewCustomBody": {
        "properties": {
          "custom_id": {
            "type": "string",
            "maxLength": 128,
            "minLength": 1,
            "pattern": "^[A-Za-z0-9._:\\-]+$",
            "title": "Custom Id",
            "description": "Ваш идентификатор операции, уникальный в рамках аккаунта. Повтор запроса с тем же custom_id вернёт результат первой попытки без повторного списания.",
            "examples": [
              "11111111-1111-4111-8111-111111111111"
            ]
          },
          "days": {
            "type": "integer",
            "maximum": 1095.0,
            "minimum": 3.0,
            "title": "Days",
            "description": "Сколько дней добавить: от 3 до 1095.",
            "examples": [
              45
            ]
          },
          "max_charge_minor": {
            "anyOf": [
              {
                "type": "integer",
                "minimum": 0.0
              },
              {
                "type": "null"
              }
            ],
            "title": "Max Charge Minor",
            "description": "Необязательно. Сколько центов вы готовы списать. Если цена операции выше, она не выполняется и отвечает 409 `max_charge_exceeded`, деньги не списаны. Передайте сумму, которую показали покупателю.",
            "examples": [
              300
            ]
          }
        },
        "additionalProperties": false,
        "type": "object",
        "required": [
          "custom_id",
          "days"
        ],
        "title": "RenewCustomBody"
      },
      "TrafficBody": {
        "properties": {
          "custom_id": {
            "type": "string",
            "maxLength": 128,
            "minLength": 1,
            "pattern": "^[A-Za-z0-9._:\\-]+$",
            "title": "Custom Id",
            "description": "Ваш идентификатор операции, уникальный в рамках аккаунта. Повтор запроса с тем же custom_id вернёт результат первой попытки без повторного списания.",
            "examples": [
              "11111111-1111-4111-8111-111111111111"
            ]
          },
          "gb": {
            "type": "integer",
            "exclusiveMinimum": 0.0,
            "title": "Gb",
            "description": "Сколько гигабайт трафика для серверов обхода белых списков. Подписке на срок: значение из `traffic_packs` в GET /plans, подписке «Навсегда»: из `lifetime.traffic_packs`.",
            "examples": [
              10
            ]
          },
          "max_charge_minor": {
            "anyOf": [
              {
                "type": "integer",
                "minimum": 0.0
              },
              {
                "type": "null"
              }
            ],
            "title": "Max Charge Minor",
            "description": "Необязательно. Сколько центов вы готовы списать. Если цена операции выше, она не выполняется и отвечает 409 `max_charge_exceeded`, деньги не списаны. Передайте сумму, которую показали покупателю.",
            "examples": [
              300
            ]
          }
        },
        "additionalProperties": false,
        "type": "object",
        "required": [
          "custom_id",
          "gb"
        ],
        "title": "TrafficBody"
      },
      "TrialBody": {
        "properties": {
          "external_user_id": {
            "type": "string",
            "maxLength": 128,
            "minLength": 1,
            "title": "External User Id",
            "description": "Идентификатор пользователя в вашей системе. Один человек = один id.",
            "examples": [
              "user-42"
            ]
          },
          "telegram_id": {
            "anyOf": [
              {
                "type": "integer",
                "exclusiveMinimum": 0.0
              },
              {
                "type": "null"
              }
            ],
            "title": "Telegram Id",
            "description": "Telegram id пользователя. Если задан, подписка появится у него в мини-аппе вашего бота (укажите bot_id) и он получит уведомления бота.",
            "examples": [
              123456789
            ]
          },
          "bot_id": {
            "anyOf": [
              {
                "type": "string",
                "format": "uuid"
              },
              {
                "type": "null"
              }
            ],
            "title": "Bot Id",
            "description": "UUID вашего бота (кабинет, «Ключ и вебхук», «Ваши боты»). Нужен вместе с telegram_id, чтобы подписка появилась в мини-приложении бота."
          },
          "custom_id": {
            "type": "string",
            "maxLength": 128,
            "minLength": 1,
            "pattern": "^[A-Za-z0-9._:\\-]+$",
            "title": "Custom Id",
            "description": "Ваш идентификатор операции, уникальный в рамках аккаунта. Повтор запроса с тем же custom_id вернёт результат первой попытки без повторного списания.",
            "examples": [
              "11111111-1111-4111-8111-111111111111"
            ]
          }
        },
        "additionalProperties": false,
        "type": "object",
        "required": [
          "external_user_id",
          "custom_id"
        ],
        "title": "TrialBody"
      },
      "UpgradeBody": {
        "properties": {
          "custom_id": {
            "type": "string",
            "maxLength": 128,
            "minLength": 1,
            "pattern": "^[A-Za-z0-9._:\\-]+$",
            "title": "Custom Id",
            "description": "Ваш идентификатор операции, уникальный в рамках аккаунта. Повтор запроса с тем же custom_id вернёт результат первой попытки без повторного списания.",
            "examples": [
              "11111111-1111-4111-8111-111111111111"
            ]
          },
          "new_plan_uuid": {
            "type": "string",
            "format": "uuid",
            "title": "New Plan Uuid",
            "description": "Тариф из GET /plans, на который переходим.",
            "examples": [
              "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
            ]
          },
          "max_charge_minor": {
            "anyOf": [
              {
                "type": "integer",
                "minimum": 0.0
              },
              {
                "type": "null"
              }
            ],
            "title": "Max Charge Minor",
            "description": "Необязательно. Сколько центов вы готовы списать. Если цена операции выше, она не выполняется и отвечает 409 `max_charge_exceeded`, деньги не списаны. Передайте сумму, которую показали покупателю.",
            "examples": [
              300
            ]
          }
        },
        "additionalProperties": false,
        "type": "object",
        "required": [
          "custom_id",
          "new_plan_uuid"
        ],
        "title": "UpgradeBody"
      },
      "ValidationError": {
        "properties": {
          "loc": {
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "integer"
                }
              ]
            },
            "type": "array",
            "title": "Location"
          },
          "msg": {
            "type": "string",
            "title": "Message"
          },
          "type": {
            "type": "string",
            "title": "Error Type"
          },
          "input": {
            "title": "Input"
          },
          "ctx": {
            "type": "object",
            "title": "Context"
          }
        },
        "type": "object",
        "required": [
          "loc",
          "msg",
          "type"
        ],
        "title": "ValidationError"
      },
      "WebhookBody": {
        "properties": {
          "url": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Url",
            "description": "https-адрес. null выключает доставку, секрет сохраняется.",
            "examples": [
              "https://example.com/vpn-webhook"
            ]
          },
          "events": {
            "anyOf": [
              {
                "items": {
                  "type": "string"
                },
                "type": "array"
              },
              {
                "type": "null"
              }
            ],
            "title": "Events",
            "description": "Подмножество событий. null или пустой список = все, включая будущие.",
            "examples": [
              [
                "subscription.expired",
                "deposit.credited"
              ]
            ]
          }
        },
        "additionalProperties": false,
        "type": "object",
        "title": "WebhookBody"
      }
    },
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Ключ из кабинета: hexpartners.pro/api/vpn/app/key"
      }
    }
  },
  "security": [
    {
      "bearerAuth": []
    }
  ]
}
