跳到主要内容

接收 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 对象。

示例 webhook 有效载荷
{
"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 - 用户未连接(仅当 .envNOT_CONNECTED_USERS_NOTIFICATIONS_ENABLED 为 true 时激活)
  • user.expiration - 用户到期通知(仅当 .envEXPIRATION_NOTIFICATIONS_ENABLED 为 true 时启用。)

Remnawave Typescript SDK 类型:

import { TRemnawaveWebhookUserEvent, RemnawaveWebhookUserEvents } from '@remnawave/backend-contract'

meta 对象

大多数用户事件的 metanull通知类事件会填充此字段,提供触发 webhook 的上下文。

字段类型适用事件说明
notConnectedAfterHoursnumber | nulluser.not_connected用户离线时长(小时)。与触发事件的 NOT_CONNECTED_USERS_NOTIFICATIONS_AFTER_HOURS 阈值相匹配。
expirationnumber | nulluser.expiration相对于用户到期时间(expireAt)的带符号小时偏移量。与 EXPIRATION_NOTIFICATIONS 中的某个值匹配。

expiration 字段的符号说明

expiration 的值带符号,编码相对于到期时刻的方向:

  • 负值 — 在到期触发:将在 |N| 小时后到期 (例如,-72 → 72 小时后到期)。
  • 正值 — 在到期触发:N 小时前已到期 (例如,24 → 24 小时前已到期)。
备注

user.not_connecteduser.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 有效载荷。

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)
}