Ваш сайт или программа сама выдаёт подписки из нашего VPN. Нужен разработчик.

Сценарии

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

Купить подписку

1. Покажите тарифы. GET /api/v1/vpn/plans вернёт plans: у каждого plan_uuid, days, devices и цена price_minor, которую спишем с депозита.

2. Проверьте депозит. GET /api/v1/vpn/balance: spendable_minor должен покрывать price_minor выбранного тарифа. Делайте это до того, как пользователь заплатит вам: 402 после его оплаты оставит человека без подписки.

3. Сохраните ключ операции. Придумайте custom_id (удобнее всего UUID) и запишите его к заказу у себя до отправки запроса.

4. Выдайте подписку. POST /api/v1/vpn/subscriptions:

JSON
{
  "custom_id": "11111111-1111-4111-8111-111111111111",
  "plan_uuid": "550e8400-e29b-41d4-a716-446655440000",
  "external_user_id": "user-42"
}

Ответ 201 (часть полей подписки опущена, полный состав на странице метода):

JSON
{
  "success": true,
  "custom_id": "11111111-1111-4111-8111-111111111111",
  "subscription": {
    "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
    "status": "active",
    "subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K",
    "end_date": "2026-10-02T12:00:00Z",
    "days_left": 30
  },
  "charged_minor": 450,
  "charged_usd": "4.50",
  "balance_after_minor": 11600,
  "balance_after_usd": "116.00"
}

5. Отдайте ссылку. subscription.subscription_url отдайте пользователю, а subscription.subscription_id сохраните: все дальнейшие вызовы идут по нему.

6. Если ответ не пришёл (таймаут, обрыв, 500 или 503 upstream_unavailable), повторите тот же запрос с тем же custom_id: получите ту же подписку с idempotent_replay: true без второго списания. 409 operation_in_progress значит, что первая попытка ещё идёт: подождите секунду и повторите. Если ваш сервер перезапустился и вы не знаете, дошёл ли запрос, спросите GET /api/v1/vpn/operations/{custom_id}: статус и готовый ответ без повторного вызова.

Чтобы не списать больше, чем показали покупателю, добавьте в тело max_charge_minor с ценой из шага 1: если цена успела вырасти, придёт 409 max_charge_exceeded, и деньги останутся на депозите.

Продлить подписку

1. Поймайте повод. Вебхук subscription.expires_in_24h (или _48h, _72h) несёт subscription.subscription_id; пользователь может нажать «Продлить» и сам.

2. Перечитайте подписку. GET /api/v1/vpn/subscriptions/{subscription_id}, смотрите status, is_trial и is_lifetime:

  • frozen: сначала POST /api/v1/vpn/subscriptions/{subscription_id}/unfreeze, иначе продление ответит 409 subscription_frozen;
  • is_trial: true: пробную не продлевают, её переводят на платный тариф. Цена перехода в GET /api/v1/vpn/subscriptions/{subscription_id}/upgrade-quote?new_plan_uuid=<тариф>, сам переход POST /api/v1/vpn/subscriptions/{subscription_id}/upgrade с custom_id и new_plan_uuid;
  • is_lifetime: true: подписка бессрочная, продлевать нечего;
  • expired: продлевать можно, срок пойдёт с текущего момента.

3. Продлите. На период тарифа: POST /api/v1/vpn/subscriptions/{subscription_id}/renew с телом {"custom_id": "<новый id>"}. На своё число дней, от 3 до 1095: POST /api/v1/vpn/subscriptions/{subscription_id}/renew-custom:

JSON
{
  "custom_id": "11111111-1111-4111-8111-111111111111",
  "days": 45
}

Ответ (сокращён):

JSON
{
  "success": true,
  "days_added": 45,
  "subscription": {
    "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
    "status": "active",
    "end_date": "2026-11-16T12:00:00Z",
    "days_left": 75
  },
  "charged_minor": 675,
  "balance_after_minor": 11375
}

4. Покажите новый срок из поля subscription.end_date. Ссылка subscription_url прежняя: пользователю ничего переустанавливать не нужно.

Докупить трафик (белые списки)

1. Узнайте пакеты. GET /api/v1/vpn/plans вернёт пакеты: для подписки на срок в traffic_packs, для «Навсегда» в lifetime.traffic_packs. У каждого gb и price_minor. Купить можно только пакет из списка своего вида подписки (product в подписке: term или lifetime).

2. Посмотрите расход. GET /api/v1/vpn/subscriptions/{subscription_id} отдаёт traffic_used_bytes и traffic_limit_bytes. Лимит null значит, что ограничения нет и докупать нечего. Вовремя подскажет вебхук subscription.traffic_threshold: израсходовано 80 %.

3. Купите пакет. POST /api/v1/vpn/subscriptions/{subscription_id}/traffic, одинаково для обоих видов подписки:

JSON
{
  "custom_id": "11111111-1111-4111-8111-111111111111",
  "gb": 10
}

Ответ (сокращён):

JSON
{
  "success": true,
  "gb_added": 10,
  "subscription": {
    "subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
    "traffic_limit_bytes": 118111600640
  },
  "charged_minor": 120,
  "balance_after_minor": 11600
}

subscription.traffic_limit_bytes уже включает купленные гигабайты. Замороженной или истёкшей подписке пакет не продаётся: 409 subscription_frozen или subscription_expired.

Если у пользователя «Навсегда», ответ тот же, плюс addon_id (под капотом это докупка gb_50 и подобные). Купленный трафик не сгорает и прибавляется к остатку. Пробной «Навсегда» докупки не продаются: 409 addon_unavailable. Ещё 15 устройств к «Навсегда» добавляет POST /api/v1/vpn/subscriptions/{subscription_id}/addons с addon_id: "devices_15".

Принять вебхук и проверить подпись

1. Возьмите секрет подписи в кабинете, раздел «Ключ и вебхук». Через API секрет не выдаётся: ключ и секрет должны утекать по отдельности.

2. Задайте адрес. В кабинете или PUT /api/v1/vpn/webhook:

JSON
{
  "url": "https://example.com/vpn-webhook",
  "events": [
    "subscription.expires_in_24h",
    "subscription.expired",
    "deposit.credited"
  ]
}

events: null подписывает на все события, включая будущие.

3. Напишите обработчик. Подпись считается от сырых байтов тела, до разбора JSON. Пример на Python (FastAPI):

Python
import hashlib
import hmac
import json
import os
import time

from fastapi import BackgroundTasks, FastAPI, HTTPException, Request

app = FastAPI()
SECRET = os.environ["VPN_WEBHOOK_SECRET"]


@app.post("/vpn-webhook")
async def vpn_webhook(request: Request, background: BackgroundTasks) -> dict:
    raw = await request.body()
    ts = request.headers.get("X-Webhook-Timestamp", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        raise HTTPException(status_code=401)  # старая или подделанная метка
    signed = ts.encode() + b"." + raw
    expected = "sha256=" + hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Signature-V2", "")):
        raise HTTPException(status_code=401)
    event = json.loads(raw)
    if already_processed(event["event_id"]):  # храните event_id у себя
        return {"ok": True}
    background.add_task(handle_event, event)  # работа в фоне, ответ сразу
    return {"ok": True}

4. Обработайте событие. Внутри handle_event перечитайте подписку GET /api/v1/vpn/subscriptions/{subscription_id} и действуйте по ней, а не по телу события: событие могло опоздать. Незнакомые события и webhook.test просто подтверждайте.

5. Проверьте. POST /api/v1/vpn/webhook/test шлёт событие webhook.test прямо сейчас и возвращает delivered, http_status и error, то есть что ответил ваш сервер. Каждая доставка видна в кабинете, раздел «Доставки вебхуков».

Собираем анонимную аналитику посещений. Подробнее