feat: outbound webhooks for payment/subscription events

Per the docs.rw comparison researched earlier tonight, Remnawave
fires webhooks for users+nodes and Marzban for users — this panel
had neither, only received inbound webhooks from payment providers.

New webhooks.py, fired on payment.paid (both webhook-driven and
reconciler-driven grant paths, so it fires regardless of which one
actually processes a given payment) and
subscription.granted_by_admin (kept as a distinct event name rather
than reusing payment.paid, since no money necessarily changed hands
there). Settings tab gets a URL field; a secret is generated once on
first save via secrets.token_hex and never regenerated on later URL
edits, so a receiver's signature verification doesn't silently break
when the admin just updates the endpoint. Every delivery is
HMAC-SHA256 signed over the raw JSON body via X-Signature, same
verification shape Platega already uses for its inbound webhooks.

Delivery is fire-and-forget (10s timeout, swallows all exceptions) —
a receiver being down must never block or fail a payment grant.
Reads WEBHOOK_URL/WEBHOOK_SECRET fresh from .env via legal.py's
existing reader instead of adding a third copy of that logic.

Verified with a real local HTTP server: actual delivery, payload
shape, and that the received X-Signature verifies against the
configured secret using the receiver's own side of the HMAC — not
just asserting the sender computed *something*. Also verified the
no-URL-configured no-op path and that changing the URL later does not
rotate the secret.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Savsis? 2026-09-13 21:15:13 +05:00
parent 24a1b26df2
commit 4b86406041
4 changed files with 100 additions and 1 deletions

View file

@ -656,6 +656,20 @@
</div> </div>
<div id="backup-result"></div> <div id="backup-result"></div>
</div> </div>
<div class="section" style="margin-top:20px">
<div class="section-head"><h2>Webhook на события</h2></div>
<p class="page-sub" style="margin-bottom:16px">Панель сама постучится на твой URL при оплате или ручной выдаче подписки — для своих интеграций (CRM, аналитика, что угодно), без опроса API.</p>
<div class="form-row">
<div><label class="f">URL</label><input type="text" id="webhook-url" placeholder="https://example.com/hook"></div>
<div style="flex:0"><label class="f">&nbsp;</label><button class="btn" onclick="saveWebhookSettings()">Сохранить</button></div>
</div>
<p class="check-hint">
События: <code>payment.paid</code>, <code>subscription.granted_by_admin</code>. Тело — JSON <code>{"event": "...", "data": {...}}</code>, подписано заголовком <code>X-Signature</code> (HMAC-SHA256 от тела запроса на секрете ниже) — так получатель проверяет, что запрос реально от панели.
Секрет для проверки: <code id="webhook-secret-display">—</code>
</p>
<div id="webhook-result"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -740,7 +754,7 @@ function showView(name) {
if (name === "nodes") loadNodes(); if (name === "nodes") loadNodes();
if (name === "traffic") loadTraffic(); if (name === "traffic") loadTraffic();
if (name === "payments") { loadPayments(); loadPaymentsSettings(); } if (name === "payments") { loadPayments(); loadPaymentsSettings(); }
if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); } if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); loadWebhookSettings(); }
} }
const COUNTRIES = [ const COUNTRIES = [
@ -1199,6 +1213,24 @@ async function confirmDisableTotp() {
} }
} }
async function loadWebhookSettings() {
const res = await api("/admin/api/webhook-settings");
document.getElementById("webhook-url").value = res.url || "";
document.getElementById("webhook-secret-display").textContent = res.secret || "будет создан при сохранении URL";
}
async function saveWebhookSettings() {
const url = document.getElementById("webhook-url").value.trim();
const result = document.getElementById("webhook-result");
try {
await api("/admin/api/webhook-settings", { method: "POST", body: JSON.stringify({ url }) });
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--green)">Сохранено</p>';
loadWebhookSettings();
} catch (e) {
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
}
}
function downloadBackup() { function downloadBackup() {
window.location.href = "/admin/api/backup"; window.location.href = "/admin/api/backup";
} }

34
api.py
View file

@ -3,6 +3,7 @@ import datetime
import json import json
import os import os
import re import re
import secrets
import subprocess import subprocess
import urllib.request import urllib.request
from fastapi import FastAPI, HTTPException, Request, Response, File, UploadFile from fastapi import FastAPI, HTTPException, Request, Response, File, UploadFile
@ -17,6 +18,7 @@ import links
import nodeprov import nodeprov
import payments import payments
import totp import totp
import webhooks
import xray_manager import xray_manager
from config import ( from config import (
PLANS, PLANS_BY_CODE, SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN, PLANS, PLANS_BY_CODE, SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN,
@ -328,6 +330,15 @@ def _grant_paid_subscription(payment_id: str):
f"Срок: {plan['label']} — до {sub['expires_at'][:10]}\n\n" f"Срок: {plan['label']} — до {sub['expires_at'][:10]}\n\n"
f"Ссылка-подписка:\nhttps://{SUB_DOMAIN}/sub/{user['token']}", f"Ссылка-подписка:\nhttps://{SUB_DOMAIN}/sub/{user['token']}",
) )
webhooks.send("payment.paid", {
"tg_id": payment["tg_id"],
"amount": payment["amount"],
"provider": payment["provider"],
"node": payment["node"],
"plan": payment["plan"],
"subscription_uuid": sub["uuid"],
"expires_at": sub["expires_at"],
})
def _check_and_reconcile_payment(payment: dict) -> str: def _check_and_reconcile_payment(payment: dict) -> str:
@ -456,6 +467,25 @@ def admin_set_platega_settings(request: Request, body: dict = Body(...)):
return {"ok": True, "restarted_bot": restarted} return {"ok": True, "restarted_bot": restarted}
@app.get("/admin/api/webhook-settings")
def admin_get_webhook_settings(request: Request):
require_admin(request)
return {
"url": legal.read_env_var("WEBHOOK_URL", ""),
"secret": legal.read_env_var("WEBHOOK_SECRET", ""),
}
@app.post("/admin/api/webhook-settings")
def admin_set_webhook_settings(request: Request, body: dict = Body(...)):
require_admin(request)
url = (body.get("url") or "").strip()
_update_env_var("WEBHOOK_URL", url)
if url and not legal.read_env_var("WEBHOOK_SECRET", ""):
_update_env_var("WEBHOOK_SECRET", secrets.token_hex(24))
return {"url": legal.read_env_var("WEBHOOK_URL", ""), "secret": legal.read_env_var("WEBHOOK_SECRET", "")}
@app.post("/payments/webhook/yookassa") @app.post("/payments/webhook/yookassa")
async def yookassa_webhook(request: Request): async def yookassa_webhook(request: Request):
body = await request.json() body = await request.json()
@ -864,6 +894,10 @@ def admin_grant_subscription(tg_id: int, request: Request, body: dict = Body(...
db.get_or_create_user(tg_id, None) db.get_or_create_user(tg_id, None)
sub = db.create_subscription(tg_id, node_code, plan["days"], plan_code, source="admin") sub = db.create_subscription(tg_id, node_code, plan["days"], plan_code, source="admin")
xray_manager.add_client_to_node(node, sub["uuid"], email=sub["uuid"]) xray_manager.add_client_to_node(node, sub["uuid"], email=sub["uuid"])
webhooks.send("subscription.granted_by_admin", {
"tg_id": tg_id, "node": node_code, "plan": plan_code,
"subscription_uuid": sub["uuid"], "expires_at": sub["expires_at"],
})
return sub return sub

10
bot.py
View file

@ -10,6 +10,7 @@ from aiogram.enums import ParseMode
import db import db
import links import links
import payments import payments
import webhooks
import xray_manager import xray_manager
from config import BOT_TOKEN, ADMIN_IDS, PLANS, PLANS_BY_CODE, SUB_DOMAIN, SITE_DOMAIN, PAYMENTS_ENABLED from config import BOT_TOKEN, ADMIN_IDS, PLANS, PLANS_BY_CODE, SUB_DOMAIN, SITE_DOMAIN, PAYMENTS_ENABLED
@ -353,6 +354,15 @@ async def reconcile_pending_payments():
) )
except Exception: except Exception:
log.exception("failed to notify user about payment") log.exception("failed to notify user about payment")
await asyncio.to_thread(webhooks.send, "payment.paid", {
"tg_id": payment["tg_id"],
"amount": payment["amount"],
"provider": payment["provider"],
"node": payment["node"],
"plan": payment["plan"],
"subscription_uuid": sub["uuid"],
"expires_at": sub["expires_at"],
})
elif status in payments.FAILED_STATUSES: elif status in payments.FAILED_STATUSES:
db.mark_payment_failed(payment["id"]) db.mark_payment_failed(payment["id"])

23
webhooks.py Normal file
View file

@ -0,0 +1,23 @@
import hashlib
import hmac
import json
import urllib.request
from legal import read_env_var
def send(event: str, data: dict):
url = read_env_var("WEBHOOK_URL")
secret = read_env_var("WEBHOOK_SECRET")
if not url or not secret:
return
body = json.dumps({"event": event, "data": data}).encode()
signature = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
req = urllib.request.Request(
url, data=body, method="POST",
headers={"Content-Type": "application/json", "X-Signature": signature},
)
try:
urllib.request.urlopen(req, timeout=10)
except Exception:
pass