Вебхуки и повторы: как не потерять деньги на продлениях

Как продлевать подписки через VPN API и принимать события так, чтобы не списать депозит дважды и не оставить клиента без доступа.

Содержание 8 разделов
  1. Чем подписка отличается от заказа
  2. Двойное продление
  3. Гонка с автопродлением
  4. Истёкший срок и заморозка
  5. Приём события: три шага
  6. Когда вебхук молчит
  7. Коды отказов при продлении
  8. Чек-лист

Подписка после выдачи продолжает жить: продлевается, замораживается, меняет тариф. Интеграция на VPN API теряет на этом деньги в трёх ситуациях, а события теряются, если вебхук принимают без проверки подписи и отсева повторов.

#Чем подписка отличается от заказа

Заказ заканчивается доставкой. Подписка после выдачи меняет состояние: срок идёт, клиент меняет тариф, докупает трафик, замораживает доступ. Часть этих изменений делает не ваш код: пользователь, привязанный к боту, может продлить подписку в мини-аппе.

Поэтому состояние подписки читайте у API, а не считайте от даты покупки. Своя арифметика сломается на первой заморозке и на первом продлении, которое прошло мимо вашего кода.

#Двойное продление

Клиент жмёт «Продлить», страница думает пару секунд, он жмёт ещё раз. Если у обоих запросов один custom_id, второй вернёт результат первого с idempotent_replay: true и денег не спишет. Если разный, подписка продлена дважды, а с депозита ушли две суммы.

custom_id создаётся один раз, когда человек нажал кнопку, и записывается к заказу до отправки запроса. Повтор после таймаута берёт тот же id. Если отправить тот же id с другим телом, API ответит кодом custom_id_conflict.

POST /api/v1/vpn/subscriptions/{id}/renew
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"}'
Ответ сокращён, полный на странице метода POST /subscriptions/{id}/renew.
Журнал операций VPN API: время, операция, custom_id, результат и сумма
Кабинет VPN API, «Операции»: каждая покупка и продление со своим custom_id. Демо-данные.

#Гонка с автопродлением

Автопродление включается в боте платформы, через 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 его не получить.

событиеPOST на ваш адрестело, X-Signature, X-Event, X-Delivery-Id
1Проверить подписьпо сырым байтам, до разбора JSON
2Отсеять повторпо event_id
3Ответить 2xxработу унести в фон
Обработчик вебхука
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}
Python-вариант построен по главе «Вебхуки». already_processed, handle_event и очередь пишутся у вас.

Внутри обработки перечитайте подписку через GET /subscriptions/{id} и действуйте по ней: событие могло опоздать. Незнакомые события и webhook.test подтверждайте ответом 2xx без обработки.

#Когда вебхук молчит

Если ваш сервер не ответил 2xx, доставка повторяется с растущим интервалом: 1, 2, 4 и так до 256 минут, всего 10 попыток примерно за 8,5 часа. После этого событие уходит в статус dead, а вам пишет бот платформы. Каждая попытка с ответом вашего сервера видна в кабинете VPN API, раздел «Доставки вебхуков».

Журнал доставок вебхуков: событие, результат, HTTP-код, число попыток и время ответа
Кабинет VPN API, «Доставки вебхуков»: видно, что дошло и что ответил ваш сервер. Демо-данные.

Вебхук ускоряет работу, но источником правды не является. Раз в сутки пройдите GET /subscriptions?status=active и сверьте end_date со своей базой. Это ловит случаи, когда клиент остался без доступа и пошёл жаловаться.

POST /api/v1/vpn/webhook/test
curl -X POST https://api.relayhub.surf/api/v1/vpn/webhook/test \
  -H "Authorization: Bearer $VPN_API_KEY"
Одна попытка без повторов: сразу видно, что ответил ваш сервер. Метод: POST /webhook/test.

#Коды отказов при продлении

Ветвитесь по error_code: он стабилен, а текст в поле error написан для разработчика и может меняться. Покупателю показывайте своё сообщение.

Коды отказов при продлении
КодHTTPЧто значитПовторять
insufficient_balance402на депозите не хватает денег; в ответе могут быть required_minor и balance_minorпосле пополнения
subscription_not_found404подписка не найдена, в том числе чужаянет
plan_not_found404тариф подписки недоступеннет
subscription_frozen409подписка замороженапосле разморозки
trial_not_renewable409пробную подписку продлить нельзянет, нужен апгрейд
custom_id_conflict409custom_id уже использован с другим теломнет, нужен новый custom_id
operation_in_progress409первая попытка ещё выполняетсяда, через секунду
rate_limited429больше 60 запросов в минутуда, через Retry-After
upstream_unavailable503сеть VPN временно недоступнада, с тем же custom_id

Отдельную ветку стоит завести на insufficient_balance: покупателю текст «оплата не прошла, попробуйте позже», себе уведомление. Остаток проверяйте заранее через GET /balance, а не в момент отказа.

#Чек-лист

Следующий шаг

Проверьте приём событий до первого клиента

В кабинете VPN API есть тестовая отправка вебхука и журнал доставок: видно, что дошло и что ответил ваш сервер.

Читать дальше

Запуск продаж VPN через API: от ключа до первой подписки

Путь от ключа до первой подписки, если интеграции ещё нет.

VPN · APIИнтеграция, Запуск5 мин
Идемпотентность, вебхуки и ретраи: как не потерять деньги

Общие правила повторов и подписи для любого платного API.

API · DigitalИнтеграция5 мин
Интеграция платного API руками нейросети: что дать агенту и где он сожжёт депозит

Как поручить обработчик нейросети и проверить результат.

APIИнтеграция3 мин
Собираем анонимную аналитику посещений. Подробнее