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

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