接收 Webhooks
概述
Remnawave 可以为许多事件发送 webhooks。
配置
.env 配置
WEBHOOK_ENABLED=true
WEBHOOK_URL=https://your-server.com/webhook
WEBHOOK_SECRET_HEADER=your-secret-header
| 变量 | 描述 |
|---|---|
WEBHOOK_ENABLED | 启用 webhooks。 |
WEBHOOK_URL | 发送 webhook 的 URL。(必须以 https:// 或 http:// 开头)。可以指定多个以逗号分隔的 URL(不带空格) |
WEBHOOK_SECRET_HEADER | 此标头将用于签署 webhook 有效载荷。(只允许 aA-zZ、0-9) |
标头
Remnawave 将随 webhook 有效载荷发送以下标头:
X-Remnawave-Signature- webhook 有效载荷的签名。(使用 WEBHOOK_SECRET_HEADER 签署)X-Remnawave-Timestamp- webhook 有效载荷的时间戳。
有效载荷
有效载荷将是一个 JSON 对象。
{
"scope": "service",
"event": "service.panel_started",
"timestamp": "2026-01-07T11:57:29.426Z",
"data": {
"panelVersion": "2.5.0"
}
}
属性:
-
scope- webhook 有效载荷的范围。(自 v2.5.0 起)user- 用户事件user_hwid_devices- 用户 HWID 设备事件node- 节点事件service- 服务事件crm- 基础设施计费事件torrent_blocker- 种子封锁事件errors- 错误事件
-
event- 发生的事件。 -
timestamp- ISO 8601 格式的 webhook 有效载荷时间戳。 -
data- 与事件相关的数据。
每个范围的详细有效载荷模式可在 OpenAPI 文档中找到。
👉 参考下面每个范围部分的 Model Link。
范围:user
OpenAPI 模型:RemnawaveWebhookUserEventsDto
模型链接:https://rw.gy/api/#model/RemnawaveWebhookUserEventsDto
可用事件(event 属性):
user.created- 用户已创建user.modified- 用户已修改user.deleted- 用户已删除user.revoked- 用户已撤销user.disabled- 用户已禁用user.enabled- 用户已启用user.limited- 用户受限user.expired- 用户已过期user.traffic_reset- 用户流量已重置user.expires_in_72_hours- 用户将在 72 小时后过期 (在 v.2.8.0 中已移除,请改用user.expiration)user.expires_in_48_hours- 用户将在 48 小时后过期 (在 v.2.8.0 中已移除,请改用user.expiration)user.expires_in_24_hours- 用户将在 24 小时后过期 (在 v.2.8.0 中已移除,请改用user.expiration)user.expired_24_hours_ago- 用户 24 小时前已过期 (在 v.2.8.0 中已移除,请改用user.expiration)user.first_connected- 用户首次连接user.bandwidth_usage_threshold_reached- 用户带宽使用阈值已达到user.not_connected- 用户未连接(仅当.env中NOT_CONNECTED_USERS_NOTIFICATIONS_ENABLED为 true 时激活)user.expiration- 用户到期通知(仅当.env中EXPIRATION_NOTIFICATIONS_ENABLED为 true 时启用。)
Remnawave Typescript SDK 类型:
import { TRemnawaveWebhookUserEvent, RemnawaveWebhookUserEvents } from '@remnawave/backend-contract'
meta 对象
大多数用户事件的 meta 为 null。仅通知类事件会填充此字段,提供触发 webhook 的上下文。
| 字段 | 类型 | 适用事件 | 说明 |
|---|---|---|---|
notConnectedAfterHours | number | null | user.not_connected | 用户离线时长(小时)。与触发事件的 NOT_CONNECTED_USERS_NOTIFICATIONS_AFTER_HOURS 阈值相匹配。 |
expiration | number | null | user.expiration | 相对于用户到期时间(expireAt)的带符号小时偏移量。与 EXPIRATION_NOTIFICATIONS 中的某个值匹配。 |
expiration 字段的符号说明
expiration 的值带符号,编码相对于到期时刻的方向:
- 负值 — 在到期前触发:将在
|N|小时后到期 (例如,-72→ 72 小时后到期)。 - 正值 — 在到期后触发:
N小时前已到期 (例如,24→ 24 小时前已到期)。
除 user.not_connected 和 user.expiration 外的所有事件,meta 均为 null。当 meta 存在时,仅设置当前事件对应的字段,另一字段保持 null。
范围:user_hwid_devices
OpenAPI 模型:RemnawaveWebhookUserHwidDevicesEventsDto
模型链接:https://rw.gy/api/#model/RemnawaveWebhookUserHwidDevicesEventsDto
可用事件:
user_hwid_devices.added- 用户 HWID 设备已添加user_hwid_devices.deleted- 用户 HWID 设备已删除
import {
TRemnawaveWebhookUserHwidDevicesEvent,
RemnawaveWebhookUserHwidDevicesEvents
} from '@remnawave/backend-contract'
范围:node
OpenAPI 模型:RemnawaveWebhookNodeEventsDto
模型链接:https://rw.gy/api/#model/RemnawaveWebhookNodeEventsDto
可用事件:
node.created- 节点已创建node.modified- 节点已修改node.disabled- 节点已禁用node.enabled- 节点已启用node.deleted- 节点已删除node.connection_lost- 节点连接丢失node.connection_restored- 节点连接已恢复node.traffic_notify- 节点流量通知
import { TRemnawaveWebhookNodeEvent, RemnawaveWebhookNodeEvents } from '@remnawave/backend-contract'
范围:service
OpenAPI 模型:RemnawaveWebhookServiceEventsDto
模型链接:https://rw.gy/api/#model/RemnawaveWebhookServiceEventsDto
可用事件:
service.panel_started- 面板已启动service.login_attempt_failed- 登录尝试失败service.login_attempt_success- 登录尝试成功service.subpage_config_changed- 子页面配置已更改
import {
TRemnawaveWebhookServiceEvent,
RemnawaveWebhookServiceEvents
} from '@remnawave/backend-contract'
范围:crm
OpenAPI 模型:RemnawaveWebhookCrmEventsDto
模型链接:https://rw.gy/api/#model/RemnawaveWebhookCrmEventsDto
可用事件:
crm.infra_billing_node_payment_in_7_days- 基础设施计费节点付款 7 天后crm.infra_billing_node_payment_in_48hrs- 基础设施计费节点付款 48 小时后crm.infra_billing_node_payment_in_24hrs- 基础设施计费节点付款 24 小时后crm.infra_billing_node_payment_due_today- 基础设施计费节点今日到期crm.infra_billing_node_payment_overdue_24hrs- 基础设施计费节点逾期 24 小时crm.infra_billing_node_payment_overdue_48hrs- 基础设施计费节点逾期 48 小时crm.infra_billing_node_payment_overdue_7_days- 基础设施计费节点逾期 7 天
import { TRemnawaveWebhookCrmEvent, RemnawaveWebhookCrmEvents } from '@remnawave/backend-contract'
范围:torrent_blocker
来自 Remnawave Panel v2.7.0 及更高版本。
OpenAPI Model: RemnawaveWebhookTorrentBlockerEventsDto
Model Link: https://rw.gy/api/#model/RemnawaveWebhookTorrentBlockerEventsDto
可用事件:
torrent_blocker.report- 种子封锁报告
import {
TRemnawaveWebhookTorrentBlockerEvent,
RemnawaveWebhookTorrentBlockerEvents
} from '@remnawave/backend-contract'
范围:errors
OpenAPI Model: RemnawaveWebhookErrorsEventsDto
Model Link: https://rw.gy/api/#model/RemnawaveWebhookErrorsEventsDto
可用事件:
errors.bandwidth_usage_threshold_reached_max_notifications- 带宽使用阈值已达到最大通知次数
验证 Webhook
Remnawave 将使用 WEBHOOK_SECRET_HEADER 签署 webhook 有效载荷并发送到 WEBHOOK_URL。
您可以通过检查签名来验证 webhook 有效载荷。
export interface WebhookHeaders {
'x-remnawave-signature': string
'x-remnawave-timestamp': string
}
validateWebhook(data: {
body: unknown
headers: WebhookHeaders
}): boolean {
if (!this.webhookSecret) return false
const signature = createHmac('sha256', this.webhookSecret)
.update(JSON.stringify(data.body))
.digest('hex')
return signature === data.headers['x-remnawave-signature']
}
不同语言的示例
Python
Python 示例代码
def validate_webhook(body, signature):
webhook_secret_panel = "your_secret_token"
"""Validate webhook signature"""
if isinstance(body, str):
original_body = body
logging.warning("Body is string, parsing for logging...")
try:
parsed_body = json.loads(body)
except json.JSONDecodeError as e:
logging.warning("Failed to parse body: %s", e)
return False
else:
original_body = json.dumps(body, separators=(',', ':'))
parsed_body = body
computed_signature = hmac.new(
webhook_secret_panel.encode('utf-8'),
original_body.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(computed_signature, signature)
Go
Go 示例代码
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"
)
var webhookSecret = "your-secret-header"
type WebhookPayload struct {
Event string `json:"event"`
Data json.RawMessage `json:"data"`
Timestamp string `json:"timestamp"`
}
type UserData struct {
UUID string `json:"uuid"`
Username string `json:"username"`
Status string `json:"status"`
UsedTrafficBytes string `json:"usedTrafficBytes"`
}
type NodeData struct {
UUID string `json:"uuid"`
Name string `json:"name"`
IsConnected bool `json:"isConnected"`
}
func validateWebhook(body []byte, signature string) bool {
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write(body)
expectedMAC := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expectedMAC))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, err := ioutil.ReadAll(r.Body)
if err != nil {
http.Error(w, "Error reading request body", http.StatusBadRequest)
return
}
signature := r.Header.Get("X-Remnawave-Signature")
timestamp := r.Header.Get("X-Remnawave-Timestamp")
if !validateWebhook(body, signature) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
var payload WebhookPayload
if err := json.Unmarshal(body, &payload); err != nil {
http.Error(w, "Error parsing JSON", http.StatusBadRequest)
return
}
if strings.HasPrefix(payload.Event, "user.") {
var userData UserData
if err := json.Unmarshal(payload.Data, &userData); err != nil {
http.Error(w, "Error parsing user data", http.StatusBadRequest)
return
}
fmt.Printf("User event %s for %s\n", payload.Event, userData.Username)
switch payload.Event {
case "user.created":
case "user.expired":
}
} else if strings.HasPrefix(payload.Event, "node.") {
var nodeData NodeData
if err := json.Unmarshal(payload.Data, &nodeData); err != nil {
http.Error(w, "Error parsing node data", http.StatusBadRequest)
return
}
fmt.Printf("Node event %s for %s\n", payload.Event, nodeData.Name)
switch payload.Event {
case "node.connection_lost":
case "node.connection_restored":
}
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("Webhook received"))
}
func main() {
http.HandleFunc("/webhook", webhookHandler)
fmt.Println("Server running at http://localhost:3000")
http.ListenAndServe(":3000", nil)
}