Вебхуки и повторы: как не потерять деньги на продлениях
Как продлевать подписки через VPN API и принимать события так, чтобы не списать депозит дважды и не оставить клиента без доступа.
Содержание 8 разделов
Подписка после выдачи продолжает жить: продлевается, замораживается, меняет тариф. Интеграция на VPN API теряет на этом деньги в трёх ситуациях, а события теряются, если вебхук принимают без проверки подписи и отсева повторов.
#Чем подписка отличается от заказа
Заказ заканчивается доставкой. Подписка после выдачи меняет состояние: срок идёт, клиент меняет тариф, докупает трафик, замораживает доступ. Часть этих изменений делает не ваш код: пользователь, привязанный к боту, может продлить подписку в мини-аппе.
Поэтому состояние подписки читайте у API, а не считайте от даты покупки. Своя арифметика сломается на первой заморозке и на первом продлении, которое прошло мимо вашего кода.
#Двойное продление
Клиент жмёт «Продлить», страница думает пару секунд, он жмёт ещё раз. Если у обоих запросов один custom_id, второй вернёт результат первого с idempotent_replay: true и денег не спишет. Если разный, подписка продлена дважды, а с депозита ушли две суммы.
custom_id создаётся один раз, когда человек нажал кнопку, и записывается к заказу до отправки запроса. Повтор после таймаута берёт тот же id. Если отправить тот же id с другим телом, API ответит кодом custom_id_conflict.
curl -X POST https://api.relayhub.surf/api/v1/vpn/subscriptions/$SUB_ID/renew \
-H "Authorization: Bearer $VPN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"custom_id": "33333333-3333-4333-8333-333333333333"}'
#Гонка с автопродлением
Автопродление включается в боте платформы, через API его не поставить. Если вы привязали пользователя к своему боту (telegram_id и bot_id), он может включить его в мини-аппе. Тогда поле auto_renew в подписке станет true, а о продлении вы узнаете из события subscription.renewed.
Если ваш код продлит такую подписку по своему расписанию, срок вырастет дважды, а депозит заплатит за период, который клиент уже оплатил в боте. Перед своим продлением перечитайте подписку и пропустите те, где auto_renew: true.
#Истёкший срок и заморозка
Действующая подписка получает дни к концу срока. Истёкшая продлевается с текущего момента, без пропущенных дней. Покажите новую дату до оплаты, иначе человек, продливший через неделю после окончания, будет ждать срок задним числом.
Пока подписка заморожена, дни не идут, и продление отвечает кодом subscription_frozen: сначала разморозка. Пробную подписку не продлевают, её переводят на платный тариф апгрейдом. Дату окончания всегда берите из end_date в ответе, а остаток дней из days_left: он уже учитывает заморозку.
#Приём события: три шага
Событие приходит POST-запросом с JSON-телом и заголовками X-Signature, X-Event и X-Delivery-Id. Подпись имеет вид sha256=<hex>: это HMAC-SHA256 от сырых байтов тела на секрете из кабинета. Секрет выдаётся только в кабинете, через API его не получить.
import hashlib
import hmac
import json
import os
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()
expected = "sha256=" + hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()
got = request.headers.get("X-Signature", "")
if not hmac.compare_digest(expected.encode(), got.encode()):
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}already_processed, handle_event и очередь пишутся у вас.Внутри обработки перечитайте подписку через GET /subscriptions/{id} и действуйте по ней: событие могло опоздать. Незнакомые события и webhook.test подтверждайте ответом 2xx без обработки.
#Когда вебхук молчит
Если ваш сервер не ответил 2xx, доставка повторяется с растущим интервалом: 1, 2, 4 и так до 256 минут, всего 10 попыток примерно за 8,5 часа. После этого событие уходит в статус dead, а вам пишет бот платформы. Каждая попытка с ответом вашего сервера видна в кабинете VPN API, раздел «Доставки вебхуков».

Вебхук ускоряет работу, но источником правды не является. Раз в сутки пройдите GET /subscriptions?status=active и сверьте end_date со своей базой. Это ловит случаи, когда клиент остался без доступа и пошёл жаловаться.
curl -X POST https://api.relayhub.surf/api/v1/vpn/webhook/test \
-H "Authorization: Bearer $VPN_API_KEY"#Коды отказов при продлении
Ветвитесь по error_code: он стабилен, а текст в поле error написан для разработчика и может меняться. Покупателю показывайте своё сообщение.
| Код | HTTP | Что значит | Повторять |
|---|---|---|---|
insufficient_balance | 402 | на депозите не хватает денег; в ответе могут быть required_minor и balance_minor | после пополнения |
subscription_not_found | 404 | подписка не найдена, в том числе чужая | нет |
plan_not_found | 404 | тариф подписки недоступен | нет |
subscription_frozen | 409 | подписка заморожена | после разморозки |
trial_not_renewable | 409 | пробную подписку продлить нельзя | нет, нужен апгрейд |
custom_id_conflict | 409 | custom_id уже использован с другим телом | нет, нужен новый custom_id |
operation_in_progress | 409 | первая попытка ещё выполняется | да, через секунду |
rate_limited | 429 | больше 60 запросов в минуту | да, через Retry-After |
upstream_unavailable | 503 | сеть VPN временно недоступна | да, с тем же custom_id |
Отдельную ветку стоит завести на insufficient_balance: покупателю текст «оплата не прошла, попробуйте позже», себе уведомление. Остаток проверяйте заранее через GET /balance, а не в момент отказа.
#Чек-лист
Проверьте приём событий до первого клиента
В кабинете VPN API есть тестовая отправка вебхука и журнал доставок: видно, что дошло и что ответил ваш сервер.
Читать дальше
Путь от ключа до первой подписки, если интеграции ещё нет.
Общие правила повторов и подписи для любого платного API.
Как поручить обработчик нейросети и проверить результат.