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>
23 lines
646 B
Python
23 lines
646 B
Python
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
|