# VPN Reseller API — бриф для ИИ-агента # Актуальная копия: https://hexpartners.pro/api/vpn/docs # VPN Reseller API hexpartners.pro — полный бриф для интеграции Ниже исчерпывающая спецификация API. Опирайся ТОЛЬКО на неё: не выдумывай поля, эндпоинты и значения, которых здесь нет. Если чего-то не хватает, скажи об этом прямо, а не подставляй правдоподобное. ## База и доступ - Base URL: `https://api.relayhub.surf`. Все пути ниже полные, начинаются с `/api/v1/vpn`. - Авторизация: заголовок `Authorization: Bearer ` на КАЖДОМ запросе. - Ключ читай из переменной окружения (`VPN_API_KEY`), не хардкодь и не логируй. - Лимит 60 запросов в минуту на ключ, превышение это 429 с `Retry-After`: уважай его и делай backoff. - Деньги в USD парой: `*_minor` (целые центы, считай в них) и `*_usd` (строка для показа). - Ошибки: `{"error": "текст", "error_code": "snake_case"}`. Ветвись по `error_code`, текст человеку не показывай. - Даты в ISO-8601 UTC с суффиксом `Z`. ## Авторизация Каждый запрос несёт заголовок `Authorization: Bearer <ключ>`. Ключ один на аккаунт, показывается один раз при выпуске; потерянный перевыпускается в кабинете, старый перестаёт работать сразу. Ключ тратит ваши деньги: храните его в переменной окружения, не в коде и не в логах. Лимит: 60 запросов в минуту на ключ. Превышение отвечает 429 с заголовком `Retry-After`. ## Деньги Все суммы в долларах США и приходят парой: `*_minor` (целые центы, по ним считайте) и `*_usd` (строка «10.25», её показывайте). Цена тарифа это ваша закупка: себестоимость поставщика плюс наценка платформы. Курс рубля в ответах не участвует, цены не меняются от курса. Депозит состоит из двух карманов: `paid` (внесено пополнениями, вывести нельзя) и `earned` (заработано на продажах в ботах платформы, выводится). Покупки через API списываются сначала с `paid`, потом с `earned`; `spendable` это их сумма. Нехватка средств отвечает 402 `insufficient_balance` до обращения к поставщику: подписка при этом не создаётся. ## Идемпотентность У каждой денежной операции есть `custom_id`, который придумываете вы (удобнее всего UUID). Повтор запроса с тем же `custom_id` и тем же телом возвращает результат первой попытки с `idempotent_replay: true` и денег не списывает. Тот же `custom_id` с другим телом отбивается 409 `custom_id_conflict`. Пока первая попытка выполняется, повтор получает 409 `operation_in_progress`: подождите секунду и повторите. Правило одно: новая операция это новый `custom_id`, ретрай упавшего запроса это тот же `custom_id`. При 503 `upstream_unavailable` и таймауте повторяйте с тем же `custom_id`, двойного списания не будет. ## Пользователь и Telegram `external_user_id` это идентификатор человека в вашей системе, один человек = один id. Подписок у него может быть несколько. Больше о пользователе знать не нужно: ни телефона, ни почты. Если ваш пользователь есть в Telegram, приложите `telegram_id` и `bot_id` вашего бота из кабинета. Тогда он увидит подписку в мини-приложении бота и получит его уведомления. Один Telegram нельзя привязать к двум разным `external_user_id`: второй получит 409 `external_user_conflict`. ## Подписка и её ссылка `subscription_url` это ссылка подписки для VPN-клиента, она не меняется при продлении и апгрейде. Статус подписки: `active`, `frozen` или `expired`. Истёкшая продлевается с текущего момента, действующая получает дни к концу срока. Замороженную сначала разморозьте: продлить её нельзя. Пробный период бесплатный, один на `external_user_id`, доступен, если включён в кабинете. Пробную подписку нельзя продлить или заморозить, только перевести на платный тариф апгрейдом. ## Продление на N дней `POST /subscriptions/{id}/renew-custom` добавляет от 3 до 1095 дней. Цена пропорциональна тарифу: `цена_тарифа / дней_в_тарифе × дней`, округление вверх до цента. Трафик на лимитных серверах прибавляется в той же пропорции. ## Вебхуки Укажите https-адрес в кабинете или через `PUT /webhook`. Секрет подписи выдаётся только в кабинете. Каждое событие приходит POST с JSON-телом и заголовками `X-Signature: sha256=`, `X-Event`, `X-Delivery-Id`. Подпись: HMAC-SHA256 от сырых байтов тела на вашем секрете, сравнивайте constant-time до разбора JSON. Отвечайте 2xx быстро, работу уносите в фон. При ошибке доставка повторяется с растущим интервалом (1, 2, 4 … 512 минут, всего 10 попыток), потом уходит в `dead`, и вы получаете сообщение от бота платформы. Одно и то же событие может прийти повторно: дедупите по `event_id`. Незнакомое событие игнорируйте, список пополняется. Источник истины остаётся за `GET /subscriptions/{id}`. ## Депозит через API `POST /deposit` выставляет счёт на сумму в центах, ответ содержит `invoice_url`. Комиссия провайдера прибавляется сверху, на депозит зачислится ровно указанная сумма. Одновременно открытых счетов не больше пяти. Зачисление приходит вебхуком `deposit.credited` и видно в `GET /deposit/{custom_id}`. ## Версионирование Все пути начинаются с `/api/v1/vpn`. Новые поля в ответах добавляются без смены версии: не ломайтесь на незнакомых ключах. Удаление или переименование поля выйдет только под `/v2` с предупреждением в кабинете. ## Эндпоинты ### GET /api/v1/vpn/plans Тарифы и цены Активные тарифы с ценой, которую спишем с вашего депозита за покупку или продление. Пробный тариф в `trial` (если включён в кабинете), пакеты трафика в `traffic_packs`. Цены в USD и меняются редко, но кэшировать их дольше часа не стоит. Форма ответа (значения примерные): ```json { "success": true, "currency": "USD", "plans": [ { "plan_uuid": "550e8400-e29b-41d4-a716-446655440000", "name": "Standart 30d", "days": 30, "devices": 2, "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" } ] } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 403: `api_disabled`: Доступ к API для этого аккаунта закрыт; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ### GET /api/v1/vpn/balance Баланс депозита Сколько можно потратить прямо сейчас. `spendable` = `paid` (внесено пополнениями, не выводится) + `earned` (заработано на продажах в ботах, выводится). Покупки через API списываются сначала с `paid`, потом с `earned`. Форма ответа (значения примерные): ```json { "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: `unauthorized`: Неверный или отсутствующий API-ключ; 403: `api_disabled`: Доступ к API для этого аккаунта закрыт; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ### GET /api/v1/vpn/subscriptions Список подписок Все подписки аккаунта, включая проданные через ботов. Без живого трафика: он есть только в карточке одной подписки. Query-параметры: - `external_user_id` (необязательный): Только подписки этого пользователя. - `status` (необязательный): Фильтр по статусу. Допустимые значения: active, frozen, expired. - `offset` (необязательный): Сколько пропустить. - `limit` (необязательный): Размер страницы, до 100. Форма ответа (значения примерные): ```json { "success": true, "total": 1, "offset": 0, "limit": 20, "items": [ { "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c", "external_user_id": "user-42", "plan_uuid": "550e8400-e29b-41d4-a716-446655440000", "plan_name": "Standart 30d", "is_trial": false, "status": "active", "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K", "days": 30, "days_left": 30, "devices": 2, "auto_renew": false, "renewal_count": 0, "created_at": "2026-09-02T12:00:00Z", "end_date": "2026-10-02T12:00:00Z", "price_minor": 450, "price_usd": "4.50" } ] } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 403: `api_disabled`: Доступ к API для этого аккаунта закрыт; 422: `validation_error`: Неверные параметры запроса; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ### POST /api/v1/vpn/subscriptions Купить подписку Списывает цену тарифа с депозита и выдаёт подписку. В ответе `subscription_url`: её вы отдаёте пользователю, он вставляет ссылку в VPN-клиент. Один `external_user_id` может иметь несколько подписок. Повтор с тем же `custom_id` вернёт ту же подписку с `idempotent_replay: true`. Поля тела запроса: - `external_user_id`: string (обязательное): Идентификатор пользователя в вашей системе. Один человек = один id. - `telegram_id`: integer | null (необязательное): Telegram id пользователя. Если задан, подписка появится у него в мини-аппе вашего бота (укажите bot_id) и он получит уведомления бота. - `bot_id`: uuid | null (необязательное): Ваш бот из кабинета, к которому привязать пользователя. Только вместе с telegram_id. - `custom_id`: string (обязательное): Ваш идентификатор операции, уникальный в рамках аккаунта. Повтор запроса с тем же custom_id вернёт результат первой попытки без повторного списания. - `plan_uuid`: uuid (обязательное): Тариф из GET /plans. Пример тела запроса: ```json { "external_user_id": "user-42", "custom_id": "11111111-1111-4111-8111-111111111111", "plan_uuid": "550e8400-e29b-41d4-a716-446655440000" } ``` Форма ответа (значения примерные): ```json { "success": true, "custom_id": "11111111-1111-4111-8111-111111111111", "subscription": { "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c", "external_user_id": "user-42", "plan_uuid": "550e8400-e29b-41d4-a716-446655440000", "plan_name": "Standart 30d", "is_trial": false, "status": "active", "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K", "days": 30, "days_left": 30, "devices": 2, "auto_renew": false, "renewal_count": 0, "created_at": "2026-09-02T12:00:00Z", "end_date": "2026-10-02T12:00:00Z", "price_minor": 450, "price_usd": "4.50" }, "charged_minor": 450, "charged_usd": "4.50", "balance_after_minor": 11600, "balance_after_usd": "116.00" } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 402: `insufficient_balance`: Недостаточно средств на депозите; 403: `forbidden`: Объект принадлежит другому аккаунту; `api_disabled`: Доступ к API для этого аккаунта закрыт; 404: `plan_not_found`: Тариф не найден или недоступен; 409: `custom_id_conflict`: Этот custom_id уже использован с другими параметрами; `operation_in_progress`: Операция с этим custom_id ещё выполняется; `external_user_conflict`: telegram_id уже привязан к другому external_user_id; 422: `validation_error`: Неверные параметры запроса; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ### POST /api/v1/vpn/subscriptions/trial Выдать пробный период Бесплатно, один раз на `external_user_id`. Работает, если пробный период включён в кабинете. Пробную подписку нельзя продлить и заморозить, только перевести на платный тариф апгрейдом. Поля тела запроса: - `external_user_id`: string (обязательное): Идентификатор пользователя в вашей системе. Один человек = один id. - `telegram_id`: integer | null (необязательное): Telegram id пользователя. Если задан, подписка появится у него в мини-аппе вашего бота (укажите bot_id) и он получит уведомления бота. - `bot_id`: uuid | null (необязательное): Ваш бот из кабинета, к которому привязать пользователя. Только вместе с telegram_id. - `custom_id`: string (обязательное): Ваш идентификатор операции, уникальный в рамках аккаунта. Повтор запроса с тем же custom_id вернёт результат первой попытки без повторного списания. Пример тела запроса: ```json { "external_user_id": "user-42", "custom_id": "11111111-1111-4111-8111-111111111111" } ``` Форма ответа (значения примерные): ```json { "success": true, "custom_id": "11111111-1111-4111-8111-111111111111", "subscription": { "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c", "external_user_id": "user-42", "plan_uuid": "550e8400-e29b-41d4-a716-446655440000", "plan_name": "Trial", "is_trial": true, "status": "active", "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K", "days": 3, "days_left": 3, "devices": 1, "auto_renew": false, "renewal_count": 0, "created_at": "2026-09-02T12:00:00Z", "end_date": "2026-10-02T12:00:00Z", "price_minor": 0, "price_usd": "0.00" }, "charged_minor": 0, "charged_usd": "0.00", "balance_after_minor": 12050, "balance_after_usd": "120.50" } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 403: `trial_disabled`: Пробный период выключен в настройках партнёра; `api_disabled`: Доступ к API для этого аккаунта закрыт; 404: `trial_plan_missing`: Пробный тариф временно недоступен; 409: `trial_already_used`: Этот пользователь уже получал пробный период; `custom_id_conflict`: Этот custom_id уже использован с другими параметрами; `external_user_conflict`: telegram_id уже привязан к другому external_user_id; 422: `validation_error`: Неверные параметры запроса; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ### GET /api/v1/vpn/subscriptions/{subscription_id} Статус подписки Карточка с остатком дней и израсходованным трафиком (`traffic_used_bytes`, `null` если поставщик не ответил). Форма ответа (значения примерные): ```json { "success": true, "subscription": { "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c", "external_user_id": "user-42", "plan_uuid": "550e8400-e29b-41d4-a716-446655440000", "plan_name": "Standart 30d", "is_trial": false, "status": "active", "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K", "days": 30, "days_left": 30, "devices": 2, "auto_renew": false, "renewal_count": 0, "created_at": "2026-09-02T12:00:00Z", "end_date": "2026-10-02T12:00:00Z", "price_minor": 450, "price_usd": "4.50", "traffic_used_bytes": 5368709120 } } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 403: `api_disabled`: Доступ к API для этого аккаунта закрыт; 404: `subscription_not_found`: Подписка не найдена; 422: Validation Error; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ### POST /api/v1/vpn/subscriptions/{subscription_id}/renew Продлить на период тарифа Добавляет полный период тарифа по текущей цене из GET /plans. Истёкшая подписка возобновляется с текущего момента, действующая получает дни к концу срока. Замороженную сначала разморозьте. Поля тела запроса: - `custom_id`: string (обязательное): Ваш идентификатор операции, уникальный в рамках аккаунта. Повтор запроса с тем же custom_id вернёт результат первой попытки без повторного списания. Пример тела запроса: ```json { "custom_id": "11111111-1111-4111-8111-111111111111" } ``` Форма ответа (значения примерные): ```json { "success": true, "custom_id": "11111111-1111-4111-8111-111111111111", "subscription": { "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c", "external_user_id": "user-42", "plan_uuid": "550e8400-e29b-41d4-a716-446655440000", "plan_name": "Standart 30d", "is_trial": false, "status": "active", "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K", "days": 30, "days_left": 60, "devices": 2, "auto_renew": false, "renewal_count": 1, "created_at": "2026-09-02T12:00:00Z", "end_date": "2026-11-01T12:00:00Z", "price_minor": 450, "price_usd": "4.50" }, "charged_minor": 450, "charged_usd": "4.50", "balance_after_minor": 11600, "balance_after_usd": "116.00" } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 402: `insufficient_balance`: Недостаточно средств на депозите; 403: `api_disabled`: Доступ к API для этого аккаунта закрыт; 404: `subscription_not_found`: Подписка не найдена; `plan_not_found`: Тариф не найден или недоступен; 409: `subscription_frozen`: Подписка заморожена; `trial_not_renewable`: Пробную подписку нельзя продлить; `custom_id_conflict`: Этот custom_id уже использован с другими параметрами; `operation_in_progress`: Операция с этим custom_id ещё выполняется; 422: Validation Error; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ### POST /api/v1/vpn/subscriptions/{subscription_id}/renew-custom Продлить на N дней Цена и трафик считаются пропорционально тарифу: `price / plan_days × days`, округление вверх до цента. Поля тела запроса: - `custom_id`: string (обязательное): Ваш идентификатор операции, уникальный в рамках аккаунта. Повтор запроса с тем же custom_id вернёт результат первой попытки без повторного списания. - `days`: integer (обязательное): Сколько дней добавить: от 3 до 1095. Пример тела запроса: ```json { "custom_id": "11111111-1111-4111-8111-111111111111", "days": 45 } ``` Форма ответа (значения примерные): ```json { "success": true, "custom_id": "11111111-1111-4111-8111-111111111111", "subscription": { "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c", "external_user_id": "user-42", "plan_uuid": "550e8400-e29b-41d4-a716-446655440000", "plan_name": "Standart 30d", "is_trial": false, "status": "active", "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K", "days": 30, "days_left": 75, "devices": 2, "auto_renew": false, "renewal_count": 1, "created_at": "2026-09-02T12:00:00Z", "end_date": "2026-11-16T12:00:00Z", "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 } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 402: `insufficient_balance`: Недостаточно средств на депозите; 403: `api_disabled`: Доступ к API для этого аккаунта закрыт; 404: `subscription_not_found`: Подписка не найдена; `plan_not_found`: Тариф не найден или недоступен; 409: `subscription_frozen`: Подписка заморожена; `trial_not_renewable`: Пробную подписку нельзя продлить; `custom_id_conflict`: Этот custom_id уже использован с другими параметрами; `operation_in_progress`: Операция с этим custom_id ещё выполняется; 422: `validation_error`: Неверные параметры запроса; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ### GET /api/v1/vpn/subscriptions/{subscription_id}/upgrade-quote Сколько стоит апгрейд Доплата за переход: полная цена нового тарифа минус неиспользованный остаток текущего. Апгрейд перезапускает срок с текущего момента. Query-параметры: - `new_plan_uuid` (обязательный): Тариф из GET /plans. Форма ответа (значения примерные): ```json { "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" } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 403: `api_disabled`: Доступ к API для этого аккаунта закрыт; 404: `subscription_not_found`: Подписка не найдена; `plan_not_found`: Тариф не найден или недоступен; 409: `subscription_frozen`: Подписка заморожена; `subscription_expired`: Подписка истекла; `subscription_state`: Операция не подходит к текущему состоянию подписки; 422: Validation Error; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ### POST /api/v1/vpn/subscriptions/{subscription_id}/upgrade Сменить тариф Списывает доплату из upgrade-quote и переводит подписку на новый тариф. Поля тела запроса: - `custom_id`: string (обязательное): Ваш идентификатор операции, уникальный в рамках аккаунта. Повтор запроса с тем же custom_id вернёт результат первой попытки без повторного списания. - `new_plan_uuid`: uuid (обязательное): Тариф из GET /plans, на который переходим. Пример тела запроса: ```json { "custom_id": "11111111-1111-4111-8111-111111111111", "new_plan_uuid": "6ba7b810-9dad-11d1-80b4-00c04fd430c8" } ``` Форма ответа (значения примерные): ```json { "success": true, "custom_id": "11111111-1111-4111-8111-111111111111", "subscription": { "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c", "external_user_id": "user-42", "plan_uuid": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "plan_name": "Pro 30d", "is_trial": false, "status": "active", "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K", "days": 30, "days_left": 30, "devices": 5, "auto_renew": false, "renewal_count": 0, "created_at": "2026-09-02T12:00:00Z", "end_date": "2026-10-02T12:00:00Z", "price_minor": 750, "price_usd": "7.50" }, "charged_minor": 300, "charged_usd": "3.00", "balance_after_minor": 11600, "balance_after_usd": "116.00" } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 402: `insufficient_balance`: Недостаточно средств на депозите; 403: `api_disabled`: Доступ к API для этого аккаунта закрыт; 404: `subscription_not_found`: Подписка не найдена; `plan_not_found`: Тариф не найден или недоступен; 409: `subscription_frozen`: Подписка заморожена; `subscription_expired`: Подписка истекла; `subscription_state`: Операция не подходит к текущему состоянию подписки; `custom_id_conflict`: Этот custom_id уже использован с другими параметрами; `operation_in_progress`: Операция с этим custom_id ещё выполняется; 422: Validation Error; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ### POST /api/v1/vpn/subscriptions/{subscription_id}/traffic Докупить трафик Пакет из `traffic_packs` (GET /plans). Лимит подписки увеличивается сразу. Поля тела запроса: - `custom_id`: string (обязательное): Ваш идентификатор операции, уникальный в рамках аккаунта. Повтор запроса с тем же custom_id вернёт результат первой попытки без повторного списания. - `gb`: integer (обязательное): Пакет из traffic_packs в GET /plans. Пример тела запроса: ```json { "custom_id": "11111111-1111-4111-8111-111111111111", "gb": 50 } ``` Форма ответа (значения примерные): ```json { "success": true, "custom_id": "11111111-1111-4111-8111-111111111111", "subscription": { "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c", "external_user_id": "user-42", "plan_uuid": "550e8400-e29b-41d4-a716-446655440000", "plan_name": "Standart 30d", "is_trial": 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, "created_at": "2026-09-02T12:00:00Z", "end_date": "2026-10-02T12:00:00Z", "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 } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 402: `insufficient_balance`: Недостаточно средств на депозите; 403: `api_disabled`: Доступ к API для этого аккаунта закрыт; 404: `subscription_not_found`: Подписка не найдена; 409: `subscription_frozen`: Подписка заморожена; `subscription_expired`: Подписка истекла; `custom_id_conflict`: Этот custom_id уже использован с другими параметрами; `operation_in_progress`: Операция с этим custom_id ещё выполняется; 422: `validation_error`: Неверные параметры запроса; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ### POST /api/v1/vpn/subscriptions/{subscription_id}/freeze Заморозить Срок останавливается, конфиг не отдаётся. Повтор по уже замороженной вернёт 409 `subscription_state`. Форма ответа (значения примерные): ```json { "success": true, "subscription": { "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c", "external_user_id": "user-42", "plan_uuid": "550e8400-e29b-41d4-a716-446655440000", "plan_name": "Standart 30d", "is_trial": false, "status": "frozen", "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K", "days": 30, "days_left": 30, "devices": 2, "auto_renew": false, "renewal_count": 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" } } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 403: `api_disabled`: Доступ к API для этого аккаунта закрыт; 404: `subscription_not_found`: Подписка не найдена; 409: `subscription_state`: Операция не подходит к текущему состоянию подписки; `trial_not_freezable`: Пробную подписку нельзя заморозить; 422: Validation Error; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ### POST /api/v1/vpn/subscriptions/{subscription_id}/unfreeze Разморозить Срок продолжается с того места, где был остановлен. Новый `end_date` в ответе. Форма ответа (значения примерные): ```json { "success": true, "subscription": { "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c", "external_user_id": "user-42", "plan_uuid": "550e8400-e29b-41d4-a716-446655440000", "plan_name": "Standart 30d", "is_trial": false, "status": "active", "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K", "days": 30, "days_left": 30, "devices": 2, "auto_renew": false, "renewal_count": 0, "created_at": "2026-09-02T12:00:00Z", "end_date": "2026-10-02T12:00:00Z", "price_minor": 450, "price_usd": "4.50" } } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 403: `api_disabled`: Доступ к API для этого аккаунта закрыт; 404: `subscription_not_found`: Подписка не найдена; 409: `subscription_state`: Операция не подходит к текущему состоянию подписки; 422: Validation Error; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ### GET /api/v1/vpn/subscriptions/{subscription_id}/devices Устройства подписки Устройства, подключавшие конфиг. Лимит задаёт тариф (`devices` в подписке). Форма ответа (значения примерные): ```json { "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" } ] } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 403: `api_disabled`: Доступ к API для этого аккаунта закрыт; 404: `subscription_not_found`: Подписка не найдена; 422: Validation Error; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ### DELETE /api/v1/vpn/subscriptions/{subscription_id}/devices/{device_id} Удалить устройство Освобождает слот и блокирует устройство: тот же телефон больше не получит конфиг по этой ссылке, пока пользователь не переустановит клиент. Форма ответа (значения примерные): ```json { "success": true, "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c", "device_id": 7, "blocked": true } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 403: `api_disabled`: Доступ к API для этого аккаунта закрыт; 404: `subscription_not_found`: Подписка не найдена; `device_not_found`: Устройство не найдено; 422: Validation Error; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ### GET /api/v1/vpn/deposit/providers Способы пополнения Провайдеры, доступные прямо сейчас. Набор зависит от настроек площадки. Форма ответа (значения примерные): ```json { "success": true, "providers": [ "cryptobot", "heleket", "lava", "platega" ] } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 403: `api_disabled`: Доступ к API для этого аккаунта закрыт; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ### POST /api/v1/vpn/deposit Выставить счёт на пополнение Возвращает `invoice_url`: откройте его сами или отдайте бухгалтеру. Зачисление приходит вебхуком `deposit.credited` и видно в GET /deposit/{custom_id}. Повтор с тем же `custom_id` и другой суммой или провайдером отбивается 409: для денег тихий повтор со старой суммой опаснее ошибки. Поля тела запроса: - `custom_id`: string (обязательное): Ваш идентификатор операции, уникальный в рамках аккаунта. Повтор запроса с тем же custom_id вернёт результат первой попытки без повторного списания. - `provider`: string (обязательное): Способ оплаты из GET /deposit/providers. - `amount_minor`: integer (обязательное): Сумма пополнения в центах USD. Зачислится ровно она; комиссия провайдера сверху. Пример тела запроса: ```json { "custom_id": "11111111-1111-4111-8111-111111111111", "provider": "cryptobot", "amount_minor": 1000 } ``` Форма ответа (значения примерные): ```json { "success": true, "deposit": { "custom_id": "dep-2026-09-02-1", "deposit_id": "01a06320-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "status": "pending", "provider": "cryptobot", "amount_minor": 5000, "amount_usd": "50.00", "amount_display_minor": 462500, "gross_display_minor": 476375, "display_currency": "RUB", "invoice_url": "https://t.me/CryptoBot?start=IVxxxxxxxx", "created_at": "2026-09-02T12:00:00Z", "expires_at": "2026-09-02T13:00:00Z" } } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 403: `api_disabled`: Доступ к API для этого аккаунта закрыт; 409: `custom_id_conflict`: Этот custom_id уже использован с другими параметрами; `too_many_open_deposits`: Слишком много неоплаченных пополнений; 422: `validation_error`: Неверные параметры запроса; 429: `rate_limited`: Слишком много запросов; 503: `provider_unavailable`: Платёжный способ временно недоступен; `upstream_unavailable`: Поставщик VPN временно недоступен ### GET /api/v1/vpn/deposit/{custom_id} Статус пополнения Форма ответа (значения примерные): ```json { "success": true, "deposit": { "custom_id": "dep-2026-09-02-1", "deposit_id": "01a06320-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "status": "credited", "provider": "cryptobot", "amount_minor": 5000, "amount_usd": "50.00", "amount_display_minor": 462500, "gross_display_minor": 476375, "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" } } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 403: `api_disabled`: Доступ к API для этого аккаунта закрыт; 404: `deposit_not_found`: Пополнение не найдено; 422: Validation Error; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ### GET /api/v1/vpn/deposits История пополнений Query-параметры: - `offset` (необязательный): Сколько пропустить. - `limit` (необязательный): Размер страницы, до 100. Форма ответа (значения примерные): ```json { "success": true, "total": 1, "offset": 0, "limit": 20, "items": [ { "custom_id": "dep-2026-09-02-1", "deposit_id": "01a06320-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "status": "pending", "provider": "cryptobot", "amount_minor": 5000, "amount_usd": "50.00", "amount_display_minor": 462500, "gross_display_minor": 476375, "display_currency": "RUB", "invoice_url": "https://t.me/CryptoBot?start=IVxxxxxxxx", "created_at": "2026-09-02T12:00:00Z", "expires_at": "2026-09-02T13:00:00Z" } ] } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 403: `api_disabled`: Доступ к API для этого аккаунта закрыт; 422: Validation Error; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ### GET /api/v1/vpn/webhook Настройки вебхука Форма ответа (значения примерные): ```json { "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", "consecutive_failures": 0 } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 403: `api_disabled`: Доступ к API для этого аккаунта закрыт; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ### PUT /api/v1/vpn/webhook Задать адрес и события Секрет подписи возьмите в кабинете (раздел «API»). Проверяйте подпись: `X-Signature: sha256=hex(HMAC-SHA256(secret, raw_body))`, сравнение constant-time по сырым байтам тела до разбора JSON. Поля тела запроса: - `url`: string | null (необязательное): https-адрес. null выключает доставку, секрет сохраняется. - `events`: string[] | null (необязательное): Подмножество событий. null или пустой список = все, включая будущие. Форма ответа (значения примерные): ```json { "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", "consecutive_failures": 0 } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 403: `api_disabled`: Доступ к API для этого аккаунта закрыт; 422: `validation_error`: Неверные параметры запроса; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ### POST /api/v1/vpn/webhook/test Тестовая отправка Шлёт событие `webhook.test` на ваш адрес прямо сейчас и возвращает, что ответил сервер. Одна попытка, без ретраев. Форма ответа (значения примерные): ```json { "success": true, "delivered": true, "status": "delivered", "http_status": 200, "duration_ms": 143, "delivery_id": "01a06321-0000-7000-8000-000000000001" } ``` Коды неуспеха: 401: `unauthorized`: Неверный или отсутствующий API-ключ; 403: `api_disabled`: Доступ к API для этого аккаунта закрыт; 409: `webhook_not_configured`: Адрес вебхука не задан; 429: `rate_limited`: Слишком много запросов; 503: `upstream_unavailable`: Поставщик VPN временно недоступен ## Статусы подписки - `active`: Подписка действует, конфиг отдаётся. - `frozen`: Заморожена: срок не идёт, конфиг не отдаётся. Терминальным не является. - `expired`: Срок вышел. Продление возобновляет подписку с текущего момента. ## Статусы пополнения - `pending`: Счёт выставлен, ждём оплату. Ссылка живёт до expires_at. - `credited`: Оплачен, деньги на депозите. Терминальный. - `expired`: Не оплачен вовремя. Терминальный, выставьте новый счёт. - `cancelled`: Отменён. Терминальный. - `failed`: Провайдер отклонил. Терминальный. - `refunded`: Возврат. Терминальный. Терминальные статусы пополнения: `credited`, `expired`, `cancelled`, `failed`, `refunded`. ## Коды ошибок (`error_code`) - `unauthorized` (HTTP 401): Неверный или отсутствующий API-ключ Что делать: Проверьте заголовок Authorization: Bearer <ключ>. Ключ выпускается в кабинете. Повтор того же запроса: нет, нужны изменения. - `api_disabled` (HTTP 403): Доступ к API для этого аккаунта закрыт Что делать: Напишите в поддержку площадки. Повтор того же запроса: нет, нужны изменения. - `partner_blocked` (HTTP 403): Аккаунт партнёра заблокирован Что делать: Напишите в поддержку площадки. Повтор того же запроса: нет, нужны изменения. - `forbidden` (HTTP 403): Объект принадлежит другому аккаунту Что делать: Проверьте идентификатор: бот или подписка не ваши. Повтор того же запроса: нет, нужны изменения. - `trial_disabled` (HTTP 403): Пробный период выключен в настройках партнёра Что делать: Включите пробный период в кабинете, раздел «Маркетинг». Повтор того же запроса: нет, нужны изменения. - `not_found` (HTTP 404): Объект не найден Что делать: Проверьте идентификатор. Повтор того же запроса: нет, нужны изменения. - `plan_not_found` (HTTP 404): Тариф не найден или недоступен Что делать: Возьмите plan_uuid из GET /plans. Повтор того же запроса: нет, нужны изменения. - `subscription_not_found` (HTTP 404): Подписка не найдена Что делать: Проверьте subscription_id. Чужие подписки тоже отвечают этим кодом. Повтор того же запроса: нет, нужны изменения. - `deposit_not_found` (HTTP 404): Пополнение не найдено Что делать: Проверьте custom_id пополнения. Повтор того же запроса: нет, нужны изменения. - `device_not_found` (HTTP 404): Устройство не найдено Что делать: Список устройств: GET /subscriptions/{id}/devices. Повтор того же запроса: нет, нужны изменения. - `trial_plan_missing` (HTTP 404): Пробный тариф временно недоступен Что делать: Повторите позже или продайте платный тариф. Повтор того же запроса: да, тот же запрос. - `insufficient_balance` (HTTP 402): Недостаточно средств на депозите Что делать: Пополните депозит: POST /deposit или кабинет. В ответе есть required_minor и balance_minor. Повтор того же запроса: нет, нужны изменения. - `custom_id_conflict` (HTTP 409): Этот custom_id уже использован с другими параметрами Что делать: Для новой операции возьмите новый custom_id. Повтор того же запроса: нет, нужны изменения. - `operation_in_progress` (HTTP 409): Операция с этим custom_id ещё выполняется Что делать: Повторите тот же запрос через секунду: вернётся результат первой попытки. Повтор того же запроса: да, тот же запрос. - `subscription_state` (HTTP 409): Операция не подходит к текущему состоянию подписки Что делать: Проверьте статус: GET /subscriptions/{id}. Повтор того же запроса: нет, нужны изменения. - `subscription_frozen` (HTTP 409): Подписка заморожена Что делать: Сначала разморозьте: POST /subscriptions/{id}/unfreeze. Повтор того же запроса: нет, нужны изменения. - `subscription_expired` (HTTP 409): Подписка истекла Что делать: Продлите подписку, после этого операция станет доступна. Повтор того же запроса: нет, нужны изменения. - `trial_already_used` (HTTP 409): Этот пользователь уже получал пробный период Что делать: Пробный период выдаётся один раз на external_user_id. Повтор того же запроса: нет, нужны изменения. - `trial_not_renewable` (HTTP 409): Пробную подписку нельзя продлить Что делать: Купите платный тариф: POST /subscriptions или апгрейд. Повтор того же запроса: нет, нужны изменения. - `trial_not_freezable` (HTTP 409): Пробную подписку нельзя заморозить Что делать: Заморозка доступна только платным подпискам. Повтор того же запроса: нет, нужны изменения. - `special_plan_used` (HTTP 409): Спецтариф этому пользователю уже выдавался Что делать: Выберите обычный тариф. Повтор того же запроса: нет, нужны изменения. - `external_user_conflict` (HTTP 409): telegram_id уже привязан к другому external_user_id Что делать: Используйте тот external_user_id, под которым этот Telegram уже заведён. Повтор того же запроса: нет, нужны изменения. - `webhook_not_configured` (HTTP 409): Адрес вебхука не задан Что делать: Сохраните URL: PUT /webhook. Повтор того же запроса: нет, нужны изменения. - `too_many_open_deposits` (HTTP 409): Слишком много неоплаченных пополнений Что делать: Оплатите или дождитесь истечения открытых счетов. Повтор того же запроса: да, тот же запрос. - `validation_error` (HTTP 422): Неверные параметры запроса Что делать: Смотрите details: там поле и причина. Повтор того же запроса: нет, нужны изменения. - `upstream_rejected` (HTTP 422): Поставщик VPN отклонил запрос Что делать: Проверьте параметры. Если всё верно, напишите в поддержку. Повтор того же запроса: нет, нужны изменения. - `rate_limited` (HTTP 429): Слишком много запросов Что делать: Подождите Retry-After секунд. Лимит: 60 запросов в минуту на ключ. Повтор того же запроса: да, тот же запрос. - `upstream_unavailable` (HTTP 503): Поставщик VPN временно недоступен Что делать: Повторите через минуту с тем же custom_id: двойного списания не будет. Повтор того же запроса: да, тот же запрос. - `provider_unavailable` (HTTP 503): Платёжный способ временно недоступен Что делать: Выберите другой способ из GET /deposit/providers. Повтор того же запроса: да, тот же запрос. - `maintenance` (HTTP 503): API на обслуживании Что делать: Повторите через несколько минут. Повтор того же запроса: да, тот же запрос. - `internal_error` (HTTP 500): Внутренняя ошибка Что делать: Повторите с тем же custom_id. Если повторяется, напишите в поддержку. Повтор того же запроса: да, тот же запрос. ## События вебхуков - `subscription.created`: Подписка выдана (покупка или пробный период). - `subscription.renewed`: Подписка продлена: по тарифу, на N дней или автопродлением. - `subscription.upgraded`: Тариф изменён. - `subscription.traffic_purchased`: Докуплен трафик. - `subscription.frozen`: Заморожена. - `subscription.unfrozen`: Разморожена, новый end_date. - `subscription.expires_in_72h`: До конца срока трое суток. - `subscription.expires_in_48h`: До конца срока двое суток. - `subscription.expires_in_24h`: До конца срока сутки. - `subscription.expired`: Срок вышел, конфиг больше не отдаётся. - `subscription.traffic_threshold`: Израсходовано 80% трафика на лимитных серверах. - `deposit.credited`: Пополнение зачислено на депозит. Пример тела события: ```json { "event": "subscription.expires_in_24h", "event_id": "01a06321-0000-7000-8000-000000000002", "occurred_at": "2026-10-01T12:00:00Z", "source": "network", "subscription": { "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c", "external_user_id": "user-42", "telegram_id": null, "bot_id": null, "plan_uuid": "550e8400-e29b-41d4-a716-446655440000", "plan_name": "Standart 30d", "is_trial": 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, "created_at": "2026-09-02T12:00:00Z", "end_date": "2026-10-02T12:00:00Z", "frozen_at": null, "price_minor": 450, "price_usd": "4.50" } } ``` Проверка подписи обязательна. Псевдокод: ``` raw = точные байты тела запроса (НЕ перекодированный JSON) expected = 'sha256=' + hmac_sha256(webhook_secret, raw).hexdigest() если не constant_time_equals(expected, header['X-Signature']): отклонить 401 ``` - Отвечай 2xx быстро, работу уноси в фон: иначе получишь повторы. - Дедупь по `event_id`: одно событие может прийти дважды. - Незнакомое событие игнорируй. Источник истины: `GET /subscriptions/{id}`. ## Грабли (частые ошибки интеграции) - **custom_id это ваш ключ идемпотентности.** Придумываете его вы. Ретрай упавшего запроса с тем же custom_id безопасен, новая операция требует новый custom_id. Один custom_id на все типы операций: повторно использовать его для другого действия нельзя. - **Проверяйте депозит до продажи.** GET /balance перед оформлением у себя: 402 после того, как покупатель у вас уже заплатил, оставляет его без подписки. Держите запас и подписку на deposit.credited. - **Ссылка подписки не меняется.** Продление и апгрейд не выдают новую subscription_url. Отдайте её один раз и не просите пользователя переустанавливать конфиг. - **Не продлевайте замороженную.** Срок замороженной подписки стоит на месте, купленные дни сгорели бы незаметно. API отвечает 409 subscription_frozen: сначала unfreeze. - **Считайте в центах.** Складывайте *_minor, показывайте *_usd. Арифметика во float даёт 0.30000000000000004 и расхождение в цент с вашим биллингом. - **Вебхук не единственный источник.** Доставка может опоздать или не дойти. Перед действием по событию перечитайте GET /subscriptions/{id}: там актуальный статус и end_date. - **Не показывайте пользователю ошибки API дословно.** Поле error написано для интегратора. Переведите error_code в своё сообщение и решите сами, повторять ли запрос (столбец «повтор» в таблице кодов). ## Пример полного сценария 1. Покупатель у вас выбрал тариф. Проверьте депозит: `GET /balance`, `spendable_minor` должен покрывать `price_minor` тарифа из `GET /plans`. 2. Выдайте подписку (custom_id придумайте сами, UUID): ```json { "external_user_id": "user-42", "custom_id": "11111111-1111-4111-8111-111111111111", "plan_uuid": "550e8400-e29b-41d4-a716-446655440000" } ``` Ответ: ```json { "success": true, "custom_id": "11111111-1111-4111-8111-111111111111", "subscription": { "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c", "external_user_id": "user-42", "plan_uuid": "550e8400-e29b-41d4-a716-446655440000", "plan_name": "Standart 30d", "is_trial": false, "status": "active", "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K", "days": 30, "days_left": 30, "devices": 2, "auto_renew": false, "renewal_count": 0, "created_at": "2026-09-02T12:00:00Z", "end_date": "2026-10-02T12:00:00Z", "price_minor": 450, "price_usd": "4.50" }, "charged_minor": 450, "charged_usd": "4.50", "balance_after_minor": 11600, "balance_after_usd": "116.00" } ``` 3. Отдайте покупателю `subscription.subscription_url`: он вставляет её в VPN-клиент. 4. За сутки до конца придёт вебхук `subscription.expires_in_24h`; предложите продление и вызовите `POST /subscriptions/{id}/renew` с новым custom_id. 5. Если запрос упал по сети или ответил 503, повторите его с ТЕМ ЖЕ custom_id: получите тот же результат без второго списания. ## Что от тебя нужно 1. Клиент к API с ретраями, backoff по 429 и таймаутами. 2. Выдача подписки с идемпотентным custom_id и проверкой депозита до продажи. 3. Приём вебхука с проверкой подписи, дедупом по event_id и перечитыванием статуса. 4. Разбор `error_code` по таблице выше с понятным сообщением покупателю. 5. Продление и апгрейд по событиям об окончании срока. Начни с уточняющих вопросов, если чего-то не хватает: язык, фреймворк, где хранятся пользователи и подписки.