Подписки и пользователи
Пользователь и Telegram, ссылка подписки, статусы, продление, трафик и устройства.
Пользователь и Telegram
external_user_id это идентификатор человека в вашей системе, один человек = один id. Подписок у него может быть несколько. Больше о пользователе знать не нужно: ни телефона, ни почты.
Если ваш пользователь есть в Telegram, приложите telegram_id и bot_id вашего бота: тогда он увидит подписку в мини-приложении бота и получит его уведомления. bot_id это UUID бота, его можно скопировать в кабинете: раздел «Ключ и вебхук», карточка «Ваши боты». Без Telegram хватает одного external_user_id, bot_id не нужен. Один Telegram нельзя привязать к двум разным external_user_id: второй получит 409 external_user_conflict.
Если у вашего бота включён «Пробник только подписчикам канала», POST /subscriptions/trial с telegram_id и bot_id выдаёт пробник только подписчику каналов бота, иначе 403 trial_channel_required с полем channels (ссылки и названия каналов). Без bot_id условие не действует: оно привязано к боту.
Переход с бота на API
Подписки, проданные в ваших ботах, уже видны в GET /subscriptions (поле source: bot, miniapp, web) и управляются по subscription_id: продление, заморозка, трафик, устройства, вебхуки по ним приходят как по своим. Чтобы находить их по своему external_user_id, привяжите людей из бота к своим id.
1. Посмотрите, кто есть. GET /clients?linked=false отдаёт людей с Telegram, но без external_user_id, у каждого до 20 последних подписок. GET /clients?telegram_id=... найдёт одного.
2. Привяжите. POST /clients/link с external_user_id и telegram_id, без покупки и без списаний. Повтор безопасен (already_linked). Перенести всю базу: POST /clients/link/batch, до 1000 строк за запрос, ответ по каждой строке.
3. Работайте как обычно. После привязки GET /subscriptions?external_user_id=... находит подписки, купленные в боте, а POST /subscriptions и POST /subscriptions/trial с тем же external_user_id выдают новые тому же человеку: он увидит их в мини-приложении бота.
Конфликты отвечают 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 не склеивает, такую пару разберёт поддержка.
Человеку, которого у вас ещё нет (result: created), бот не записывается, даже если вы передали bot_id: он появится у бота, когда человек сам его откроет. В песочнице людей из ботов нет, поэтому linked=false там всегда пустой, а привязка и конфликты работают как на боевом.
Название подписки в VPN-клиенте (Happ, v2rayTun) вы задаёте в кабинете, раздел «Ключ и вебхук», карточка «Название подписки». Оно действует на подписки без bot_id, подключать для этого бота не нужно. Если передать bot_id, бот подключается к платформе целиком: мы забираем его апдейты и снимаем вебхук, который стоял у вас, а название берётся из настроек бота.
Подписка и её ссылка
subscription_url это ссылка подписки для VPN-клиента. При продлении и апгрейде она не меняется; меняется только по вашему вызову rotate-link, после него старая ссылка перестаёт работать, а новую вы отдаёте пользователю заново. Статус подписки: active, frozen или expired. Истёкшая продлевается с текущего момента, действующая получает дни к концу срока. Замороженную сначала разморозьте: продлить её нельзя.
Пробный период бесплатный, один на external_user_id, доступен, если включён в кабинете. Пробную подписку нельзя продлить или заморозить, только перевести на платный тариф апгрейдом.
Продление на N дней
POST /subscriptions/{id}/renew-custom добавляет от 3 до 1095 дней. Цена пропорциональна тарифу: цена_тарифа / дней_в_тарифе × дней, округление вверх до цента. Трафик на лимитных серверах прибавляется в той же пропорции.
Белые списки: докупка трафика
Обход белых списков в нашем API это трафик на лимитных серверах. Он докупается пакетами, и вход один для подписки любого вида: POST /subscriptions/{id}/traffic с телом {"custom_id": "...", "gb": 50}.
Какие gb можно купить, видно в GET /plans: подписке на срок пакеты из traffic_packs, подписке «Навсегда» из lifetime.traffic_packs. Объём не из списка отвечает 422 со списком допустимых значений. Лимит traffic_limit_bytes вырастает сразу, деньги списываются с депозита. У «Навсегда» купленный трафик не сгорает и прибавляется к остатку.
Подписке на срок пакет продаётся, только пока она действует: замороженной или истёкшей ответ 409 subscription_frozen или subscription_expired. Пробной «Навсегда» докупки не продаются: 409 addon_unavailable.
Вебхук о покупке разный: у подписки на срок subscription.traffic_purchased, у «Навсегда» subscription.addon_purchased с addon_id (например gb_50). Подпишитесь на оба, если продаёте оба вида.
Пошаговый пример есть в сценарии «Докупить трафик (белые списки)».
Трафик и счётчик продлений
«Лимитные серверы» в этом API это серверы обхода белых списков: на обычных серверах трафик безлимитный, а на них действует лимит. Поэтому «купить белые списки» значит «докупить трафик».
traffic_limit_bytes это накопленный лимит на лимитных серверах, а не квота тарифа: полное продление прибавляет квоту тарифа к текущему лимиту, продление на N дней прибавляет её долю N / дней_в_тарифе, апгрейд прибавляет трафик за оставшиеся дни, пакет трафика прибавляет купленные гигабайты. GET /subscriptions/{id} отдаёт живое значение поставщика вместе с traffic_used_bytes, список и вебхуки отдают значение на момент последней операции. null значит, что лимита нет.
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.
Синхронизация подписок
Держать свою базу в актуальном виде можно без запроса на каждую подписку. GET /subscriptions?updated_since=<момент> отдаёт подписки, изменившиеся с этого момента, по возрастанию updated_at, страницы по next_cursor. Запомните updated_at последней подписки и в следующий раз начните с него. updated_at меняется при любом изменении подписки, в том числе когда VPN-клиент подключается (не чаще раза в час).
В списке есть фильтры telegram_id, plan_uuid, product (term или lifetime) и is_trial. Живого расхода трафика в списке нет: он в карточке GET /subscriptions/{id} вместе с usage_updated_at, моментом, на который он верен.
Отвечайте 2xx быстро, работу уносите в фон. При ошибке доставка повторяется с растущим интервалом (1, 2, 4 … 512 минут, всего 10 попыток), потом уходит в dead, и вы получаете сообщение от бота платформы. Каждую попытку с ответом вашего сервера видно в кабинете, раздел «Доставки вебхуков». Одно и то же событие может прийти повторно: дедупите по event_id. Незнакомое событие игнорируйте, список пополняется. Источник истины остаётся за GET /subscriptions/{id}.
Устройства
GET /subscriptions/{id}/devices показывает устройства, которые подключали конфиг, лимит задаёт тариф. Работает для подписок обоих видов: и на срок, и «Навсегда». У «Навсегда» last_seen значит последнее обновление конфига устройством, а отключить пользователя целиком поставщик не умеет, заморозки у такой подписки нет. DELETE /subscriptions/{id}/devices/{device_id} освобождает слот и по умолчанию отключает устройство: по этой ссылке оно больше не получит конфиг. Переустановка VPN-клиента тут не помогает, идентификатор устройства остаётся прежним. Если нужно только освободить место, чтобы подключить то же устройство заново, передайте ?block=false.
Отключённые устройства отдаёт GET /subscriptions/{id}/devices/blocked. Вернуть одно можно вызовом DELETE /subscriptions/{id}/devices/blocked/{block_id}: при следующем обновлении подписки в VPN-клиенте оно подключится снова и займёт слот, если он свободен. rotate-link тоже снимает все отключения, но меняет ссылку.
Бессрочная подписка «Навсегда»
Кроме подписок на срок продаётся бессрочная: 15 устройств, безлимитный трафик на обычных серверах и лимит на лимитных серверах, который не сгорает. В GET /plans она лежит отдельным блоком lifetime, а не в plans: available, plan_uuid, devices, limited_traffic_bytes, цена price_minor и рублёвая price_display_minor, докупки addons. available: false значит, что сейчас она не продаётся.
Покупается тем же POST /subscriptions с plan_uuid из блока lifetime. Сколько списано, смотрите в charged_minor ответа: доллары считаются по курсу рубля в момент покупки. У такой подписки product: "lifetime" и is_lifetime: true, end_date стоит в 2100 году ради совместимости, показывать его как дату не нужно. Продление, заморозка и апгрейд к ней не применяются: 409 unsupported_for_product. Трафик и устройства работают как у подписки на срок: /traffic и /devices.
Ещё 15 устройств добавляет POST /subscriptions/{id}/addons с addon_id: "devices_15" из lifetime.addons. Трафик тем же методом тоже покупается (gb_50, gb_200, gb_1000, gb_2000), но удобнее /traffic: он один для обоих видов подписки. Цена докупки это ваша закупка, без наценки.
Если ответ на покупку или докупку потерялся, повторите запрос с тем же custom_id: мы сначала выясняем у поставщика, прошла ли первая попытка, и повторно не покупаем, пока это не ясно. 503 upstream_unavailable на таком повторе значит, что результат ещё выясняется: повторите через несколько минут. Новый custom_id после потерянного ответа может купить вторую подписку, поэтому прежде спросите GET /operations/{custom_id}. Событие subscription.addon_purchased несёт рядом с subscription поле addon_id: что именно докуплено.
Статусы подписки
activefrozenexpired