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 id="backup-result"></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>
@ -740,7 +754,7 @@ function showView(name) {
if (name === "nodes") loadNodes();
if (name === "traffic") loadTraffic();
if (name === "payments") { loadPayments(); loadPaymentsSettings(); }
if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); }
if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); loadWebhookSettings(); }
}
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() {
window.location.href = "/admin/api/backup";
}