Сценарии
Купить, продлить, докупить белые списки, принять вебхук: вызовы по шагам.
Купить подписку
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:
{
"custom_id": "11111111-1111-4111-8111-111111111111",
"plan_uuid": "550e8400-e29b-41d4-a716-446655440000",
"external_user_id": "user-42"
}Ответ 201 (часть полей подписки опущена, полный состав на странице метода):
{
"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, иначе продление ответит 409subscription_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:
{
"custom_id": "11111111-1111-4111-8111-111111111111",
"days": 45
}Ответ (сокращён):
{
"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, одинаково для обоих видов подписки:
{
"custom_id": "11111111-1111-4111-8111-111111111111",
"gb": 10
}Ответ (сокращён):
{
"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:
{
"url": "https://example.com/vpn-webhook",
"events": [
"subscription.expires_in_24h",
"subscription.expired",
"deposit.credited"
]
}events: null подписывает на все события, включая будущие.
3. Напишите обработчик. Подпись считается от сырых байтов тела, до разбора JSON. Пример на Python (FastAPI):
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, то есть что ответил ваш сервер. Каждая доставка видна в кабинете, раздел «Доставки вебхуков».