用第三方 Webhook 接收 AKStream.Next 重要事件
第三方 Webhook 把设备注册、RTC 会议、MediaServer 异常、流状态和录像完成等重要事实主动推送给业务系统。一个接收地址对应一个独立推送方案;多个方案拥有各自的密钥、事件范围、超时、重试和投递历史。
**第三方先做什么:**在自己的服务中开放一个可被 AKStream.Next 所在节点访问的 HTTPS 地址,例如 https://events.example.com/akstream/webhook。这个路径由第三方决定;接口只需接收 POST,读取 application/json; charset=utf-8 正文,校验签名,按 eventName 处理 data,成功后返回任意 2xx(通常是 204 No Content)。把完整地址和双方约定的共享密钥填进 AKStream.Next 的推送方案。这里是 AKStream.Next 主动调用第三方,不是第三方调用 /api/v2/system/webhooks/... 管理接口;后者用于配置和查看投递。
如果服务部署在反向代理后,代理必须保留请求正文和 X-AKStream-* 请求头。生产环境建议 HTTPS;受控内网 HTTP 需要在方案中显式启用。下面的接收协议和逐类字段给出可直接对照的请求格式。
上线前必须完成真实测试。 页面显示“配置正确”只证明地址和字段通过校验。仍需选择每个推送方案发送测试事件,并在第三方接收端验证 HTTPS、原始正文 HMAC、幂等和 2xx 响应。
不要把共享密钥放进 URL。 接收地址拒绝账号、密码、查询参数和片段。共享密钥只保存在受保护密文中,页面不会回显现值。

快速开始
- 使用系统管理员账号进入 系统管理 → 第三方 Webhook。该入口位于 RTC / SFU 后、运行环境 前。
- 点击 新建推送方案。填写方案名称、用途和一个 HTTPS 接收地址。
- 点击 生成 创建该方案独立的 HMAC 共享密钥,立即复制到第三方接收程序的秘密存储中。
- 按“媒体与录像、GB28181 设备、RTC、原始 Hook”分组勾选事件。首次接入保持推荐选项即可。
- 保存方案后,填写测试说明并点击 向当前方案发送测试。
- 在 投递记录与失败恢复 中确认状态变为“已完成”,HTTP 为第三方真实返回的 2xx。

理解多推送方案
Webhook 不直接在设备注册、RTC 请求或 MediaServer 回调线程里访问第三方。业务事件先进入主数据库 Outbox,再为每个匹配方案生成独立 Delivery。某个地址故障不会阻塞业务,也不会覆盖其他地址的成功状态。
flowchart LR
subgraph producers[AKStream.Next 重要事件]
gb[GB28181 设备]
rtc[RTC 会议与成员]
media[MediaServer / 流 / 录像]
end
outbox[(Outbox 业务事件)]
fanout[按方案事件范围扇出]
planA[方案 A<br/>告警中台]
planB[方案 B<br/>业务联动平台]
planC[方案 C<br/>数据仓库]
deliveryA[(Delivery A)]
deliveryB[(Delivery B)]
deliveryC[(Delivery C)]
gb --> outbox
rtc --> outbox
media --> outbox
outbox --> fanout
fanout --> deliveryA --> planA
fanout --> deliveryB --> planB
fanout --> deliveryC --> planC
同一个 EventId 会发送给所有匹配方案。每个方案使用不同共享密钥,因此签名不同;Idempotency-Key 仍等于 EventId,方便各接收端独立去重。
方案字段
| 字段 | 必填 | 规则 | 作用 |
|---|---|---|---|
| 方案名称 | 是 | 同一集群 1—128 字符且唯一 | 识别接收系统或业务用途 |
| 方案说明 | 否 | 最多 512 字符 | 记录负责人、工单或集成边界 |
| 接收地址 | 是 | 绝对 HTTP(S),无凭据、查询和片段 | 每个方案只对应一个地址 |
| HMAC 共享密钥 | 新建时是 | 16—256 字符,无控制字符 | 验证正文来自 AKStream.Next 且未被篡改 |
| 启用方案 | 是 | 独立启停 | 停用不影响其他方案 |
| 事件范围 | 是 | 至少一个精确事件或 .* 前缀 |
控制该地址能收到哪些数据 |
| 允许内网 HTTP | 否 | 默认关闭 | 只用于隔离可信网络,公网必须 HTTPS |
| 单次超时 | 是 | 2—30 秒 | 限制单次第三方等待时间 |
| 最大尝试次数 | 是 | 1—10 次 | 达到上限后进入人工处理 |
编辑方案时密钥输入框为空表示保留现有密钥。输入新值才会轮换该方案;其他方案不受影响。归档方案会停止新投递并取消未完成 Delivery,但历史证据继续保留。
选择需要通知的事件
标准事件全部使用中文勾选卡片,不需要手写内部名称。页面目录由后端统一下发,避免前后端事件名漂移。
媒体与录像
| 页面名称 | 事件名 | 何时触发 | 建议 |
|---|---|---|---|
| MediaServer 启动 | media.server.started |
生命周期状态从非在线进入在线 | 推荐 |
| MediaServer 停止 | media.server.stopped |
主动退出或生命周期转为离线 | 推荐 |
| MediaServer 异常退出 | media.server.crashed |
托管守护发现原 PID 非预期消失 | 推荐,接入告警 |
| MediaServer 启动失败 | media.server.start_failed |
自动拉起后未保持运行 | 推荐,接入告警 |
| MediaServer 恢复在线 | media.server.recovered |
先前离线实例重新在线 | 推荐,关闭故障告警 |
| 流上线 / 下线 | media.stream.online / media.stream.offline |
流注册或注销 | 推荐 |
| 录像文件完成 | recording.file.completed |
MP4 分片封口 | 推荐;只发文件名,不发服务器绝对路径 |
GB28181 设备
| 页面名称 | 事件名 | 何时触发 |
|---|---|---|
| 设备首次注册 | device.gb28181.registered |
第一次 REGISTER 成功并写入设备档案 |
| 设备主动注销 | device.gb28181.unregistered |
注销并完成通道/会话离线收敛 |
| 设备心跳超时离线 | device.gb28181.offline |
超过心跳失效窗口后从在线转离线 |
| 设备恢复在线 | device.gb28181.recovered |
离线设备重新 REGISTER 或 Keepalive |
| 设备注册鉴权失败 | device.gb28181.auth_failed |
Digest 鉴权失败并完成旧在线态治理 |
重复 Keepalive、重复 REGISTER 刷新或状态没有变化时不会重复制造恢复事件。
RTC
| 页面名称 | 事件名 | 何时触发 |
|---|---|---|
| 会议创建 / 结束 | rtc.room.created / rtc.room.closed |
房间写入成功;关闭与媒体释放完成 |
| 成员加入 / 离开 | rtc.participant.joined / rtc.participant.left |
安全入会完成;成员主动退出 |
| 成员超时离线 | rtc.participant.timed_out |
心跳超时治理完成 |
| 发布开始 / 停止 / 失败 | rtc.publication.started / .stopped / .failed |
WHIP、RTMP 发布状态实际变化 |
| 会议录制失败 | rtc.recording.failed |
房间录制启动或停止失败 |
聊天、白板笔迹、普通心跳和页面刷新不是推荐外发事件,避免泄露内容或制造通知风暴。
原始 MediaServer Hook
“接收全部 MediaServer 事件”使用 zlm.webhook.*,还会包含播放鉴权、推流鉴权、保活和流量报告等高频/敏感 Hook。只有接收端完成容量、限流和数据范围评估后才开启。标准业务联动优先选择上面的稳定事件。
旧版保存的 zlm.webhook.on_stream_changed 等 snake_case 规则仍兼容匹配,并在下一次保存时规范化。
发送测试并处理失败
测试事件只投向当前方案,不会同时打扰其他地址。它会进入真实 Outbox、生成 Delivery、计算 HMAC、执行超时和重试,因此可以发现 DNS、证书、反向代理、WAF、签名和接收程序问题。
测试请求使用事件名 system.webhook.test。该事件只由管理员点击测试按钮后产生,不需要加入普通事件勾选范围,也不会由后台任务自行触发。
sequenceDiagram
actor Admin as 管理员
participant Web as Webhook 页面
participant API as AKStream.Next API
participant DB as Outbox / Delivery
participant Worker as 投递器
participant Receiver as 第三方接收端
Admin->>Web: 选择方案并发送测试
Web->>API: POST /subscriptions/{id}/test
API->>DB: 写入指定方案测试事件
Worker->>DB: 领取该方案 Delivery
Worker->>Receiver: POST JSON + HMAC 请求头
alt 返回 2xx
Receiver-->>Worker: 204 No Content
Worker->>DB: Delivery = 已完成
else 超时或非 2xx
Receiver-->>Worker: 500 / timeout
Worker->>DB: Delivery = 等待重试
end
Web->>DB: 刷新逐方案投递记录

stateDiagram-v2
[*] --> 待处理
待处理 --> 投递中: Worker 条件领取
投递中 --> 已完成: 第三方返回 2xx
投递中 --> 等待重试: 网络失败 / 超时 / 非 2xx
等待重试 --> 投递中: NextAttemptAt 到期
投递中 --> 失败: 达到最大尝试次数
失败 --> 待处理: 管理员人工重试
待处理 --> 已取消: 方案归档
等待重试 --> 已取消: 方案归档
页面不会显示 Outbox 原始业务载荷。它只显示事件名、EventId、状态、尝试次数、HTTP 状态、时间和截断错误,降低运维页面泄露敏感数据的风险。
实现第三方接收端
AKStream.Next 向方案填写的完整地址发送 POST,正文是 UTF-8 JSON。第三方应先保留原始正文用于验签,再解析 JSON。无需实现 GET,也无需由接收方主动轮询。下述字段和大小写依据 1.0.0.158 的实际发送代码;data 不是统一的驼峰命名,请按本节示例区分。
请求正文
{
"schemaVersion": 1,
"eventId": "e0b4e218-a6bb-487b-9af2-86e042a87c78",
"eventName": "device.gb28181.registered",
"occurredAtUtc": "2026-08-20T08:30:00Z",
"source": "gb28181",
"nodeId": "node-01",
"data": {
"DeviceId": "34020000001320000001",
"Name": "园区东门摄像机",
"Direction": "Server",
"Status": "Online",
"previousStatus": null,
"reason": "REGISTER 首次注册成功",
"occurredAtUtc": "2026-08-20T08:30:00Z"
}
}
顶层字段由投递器统一生成,data 来自实际事件生产者,大小写按各字段原样保留。时间示例用于说明格式,不表示一次真实投递的固定值。
| 顶层字段 | 类型 | 第三方如何使用 |
|---|---|---|
schemaVersion |
整数,目前为 1 |
解析版本;遇到不支持的版本不要猜测字段含义。 |
eventId |
UUID 字符串 | 业务事件唯一键;同一事件跨重试、跨方案保持不变,用于幂等。 |
eventName |
字符串 | 按下面事件表选择 data 解析逻辑;未知事件可记录并忽略。 |
occurredAtUtc |
UTC ISO 8601 时间字符串 | Outbox 事件创建时间,不是本次 HTTP 发送时间。 |
source |
字符串 | 来源模块,如 gb28181、rtc、media、recording、zlm、outbound-webhook-test。 |
nodeId |
字符串 | 发布事件的 AKStream.Next 节点 ID;与媒体服务器 ID 不是一回事。 |
data |
JSON 值,当前标准事件为对象 | 事件专属字段;不要把所有事件强制反序列化为同一个模型。 |
data:各类事件到底发送什么
以下字段表列的是当前生产者实际写入的字段。第三方应按 eventName 分发,只依赖本业务确实需要的字段;对额外字段保持兼容。表中“可空”表示字段仍会出现,但值可能是 JSON null。同一对象中大小写不同的键是不同字段。
| 事件 | data 字段、类型与含义 |
|---|---|
device.gb28181.registered / unregistered / offline / recovered / auth_failed |
DeviceId 字符串:国标设备 ID;Name 字符串/可空:设备名称;Direction:设备方向,按原值读取;Status 字符串:当前设备状态;previousStatus 字符串/可空:此前状态;reason 字符串:本次变化原因;occurredAtUtc UTC 时间:生产者生成时间。auth_failed 的原因可能包含诊断文字,勿直接公开。 |
media.stream.online / offline |
MediaServerId 字符串/可空:媒体服务器 ID;Vhost、App 字符串/可空:ZLM 虚拟主机和应用;streamId 字符串/可空:流 ID;schema 字符串/可空:协议,如 rtsp;online 布尔值:是否上线;occurredAtUtc UTC 时间。一个流的不同协议轨道可能分别发事件,第三方按需要组合 MediaServerId、Vhost、App、streamId、schema。 |
recording.file.completed |
MediaServerId、Vhost、App、streamId 同上;fileName 字符串/可空:完成的 MP4 文件名,没有服务器绝对路径;fileSize 字符串/可空:上游 file_size 原值;duration 字符串/可空:上游 time_len 原值;occurredAtUtc UTC 时间。不能把文件名直接当下载 URL;需要另走有权限的录像查询/下载接口。 |
media.server.started / stopped / recovered |
NodeId 字符串:控制节点;ZlmNodeId 字符串/可空:ZLM 节点;mediaServerId 字符串:媒体服务器;previousStatus、status 字符串:变更前后状态;hookName 字符串:触发该事件的 Hook;occurredAtUtc UTC 时间。 |
media.server.crashed |
NodeId、zlmNodeId:节点标识;previousPid 整数:异常退出前的进程号;LastAction 字符串/可空:最近托管操作;reason 固定原因码 managed-process-exited-unexpectedly;occurredAtUtc UTC 时间。 |
media.server.start_failed |
NodeId、zlmNodeId、LastAction 同上;reason 固定原因码 managed-start-failed;occurredAtUtc UTC 时间。此事件不包含完整失败日志。 |
rtc.room.created |
RoomId 字符串:会议 ID;Name、RoomType、MediaServerId、Status:房间属性;ScheduledStartAtUtc、ScheduledEndAtUtc:预约起止时间,可能为 null。房间创建事件以房间实体 ID 作为稳定 eventId。 |
rtc.room.closed、rtc.participant.joined / left / timed_out、rtc.publication.started / stopped / failed、rtc.recording.failed |
共享 RoomId 字符串:会议 ID;Sequence 整数:该会议的房间事件序号;EventType 字符串:具体内部事件类型;ParticipantId 字符串:发起/关联成员,系统事件可能为 system;TargetParticipantId 字符串/可空:目标成员;occurredAtUtc UTC 时间。不包含设备 ID、成员名称、聊天内容、白板内容、发布地址或录制文件地址;需要这些资料时用相应的授权业务查询接口,不能从此通知推断。 |
system.webhook.test |
actor 字符串:触发测试的人/标识;message 字符串:测试说明;testedAtUtc UTC 时间。只发给管理员选中的方案。 |
zlm.webhook.<HookName> |
HookName 字符串:规范化的 ZLM Hook 名称;MediaServerId、App、Stream 字符串/可空:Hook 识别出的媒体信息;RawJson 字符串:上游 Hook 的原始 JSON 文本,需要二次 JSON 解析;ReceivedAt 带时区时间。它不是稳定业务模型,字段由具体 ZLM Hook 决定。RawJson 是字符串,外层的敏感字段递归脱敏不会解析并清理字符串内部;谨慎订阅原始 Hook,尤其是鉴权和播放相关事件。 |
例如 RTC 成员入会,data 实际形状如下;EventType 用来区分该规范事件是由哪个内部状态转移产生:
{
"schemaVersion": 1,
"eventId": "96f8f29e-a6e2-46c4-bb32-99a643043f2a",
"eventName": "rtc.participant.joined",
"occurredAtUtc": "2026-09-28T02:30:00Z",
"source": "rtc",
"nodeId": "node-01",
"data": {
"RoomId": "room-demo-001",
"Sequence": 18,
"EventType": "participant-joined",
"ParticipantId": "p-demo-001",
"TargetParticipantId": null,
"occurredAtUtc": "2026-09-28T02:30:00Z"
}
}
原始 Hook 示例只说明包裹关系:"data":{"HookName":"OnPlay","RawJson":"{\"app\":\"live\"}"}。业务系统要先解析外层 data,只有主动订阅原始 Hook 时才对 RawJson 做第二次解析。普通设备、RTC、流和录像事件无需这一步。
投递器会递归把结构化字段中名称为 secret、password、passwd、token、accessToken、refreshToken、authorization、cookie、licenseCode、requestCode、apiKey、params 的值替换成 [REDACTED],但不要把这当成原始 Hook 字符串的隐私保证。
请求头
| 请求头 | 含义 |
|---|---|
X-AKStream-Event |
事件名 |
X-AKStream-Event-Id |
稳定 EventId;同一事件对多个方案一致 |
X-AKStream-Subscription-Id |
当前推送方案 ID |
X-AKStream-Timestamp |
本次签名使用的 Unix 秒 |
X-AKStream-Signature |
v1=<小写十六进制 HMAC-SHA256> |
Idempotency-Key |
与 EventId 相同,供当前接收端去重 |
实际请求示意(签名值为占位符,不能照抄):
POST /akstream/webhook HTTP/1.1
Host: events.example.com
Content-Type: application/json; charset=utf-8
X-AKStream-Event: rtc.participant.joined
X-AKStream-Event-Id: 96f8f29e-a6e2-46c4-bb32-99a643043f2a
X-AKStream-Subscription-Id: 538fb28e-7739-4e44-b747-df375e9228d7
X-AKStream-Timestamp: 1790562600
X-AKStream-Signature: v1=<64位小写十六进制摘要>
Idempotency-Key: 96f8f29e-a6e2-46c4-bb32-99a643043f2a
{"schemaVersion":1,"eventId":"96f8f29e-a6e2-46c4-bb32-99a643043f2a","eventName":"rtc.participant.joined","occurredAtUtc":"2026-09-28T02:30:00Z","source":"rtc","nodeId":"node-01","data":{"RoomId":"room-demo-001","Sequence":18,"EventType":"participant-joined","ParticipantId":"p-demo-001","TargetParticipantId":null,"occurredAtUtc":"2026-09-28T02:30:00Z"}}
签名原文固定为:
<X-AKStream-Timestamp>.<原始 HTTP 请求正文>
点号是字面量 .,不包含尖括号、换行或额外空格;以本方案密钥做 UTF-8 HMAC-SHA256,结果为小写十六进制并加 v1= 前缀。验签必须使用收到的原始正文,不能先解析再重新序列化。比较摘要时使用常量时间比较。接收端还应检查时间戳窗口(例如 ±300 秒)、schemaVersion、头部事件 ID 与正文 eventId 是否一致。服务器每次重试都重新生成时间戳和签名;eventId 不变。
第三方完成业务写入后返回 200、201、202 或 204 等任意 2xx;响应正文没有固定格式,也不会被平台用于下游业务解析。网络错误、超时或非 2xx 会按方案重试(单次超时 2—30 秒、最多 1—10 次,约从 5 秒开始退避,最长 300 秒);不要用 200 表示处理失败。若业务已经提交但响应丢失,仍可能收到同一 eventId,应在数据库中以 eventId 做唯一约束,并在同一事务里记录已处理事件和业务结果。多个方案进入同一个接收系统时,可用 (subscriptionId, eventId) 做去重键;只处理一个方案时 eventId 即可。验签失败返回 401/403,方法错误返回 405,临时处理失败返回 5xx。
可运行的 Node.js 接收示例
把共享密钥放入环境变量,不要写在源码或启动参数历史中:
export WEBHOOK_SECRET='从 AKStream.Next 页面生成并安全保存的密钥'
node receiver.mjs
receiver.mjs:
import crypto from 'node:crypto'
import http from 'node:http'
const secret = process.env.WEBHOOK_SECRET
if (!secret) throw new Error('WEBHOOK_SECRET is required')
const seen = new Set()
http.createServer((request, response) => {
if (request.method !== 'POST' || request.url !== '/akstream/webhook') {
response.writeHead(405).end()
return
}
const chunks = []
request.on('data', chunk => chunks.push(chunk))
request.on('end', () => {
try {
const rawBody = Buffer.concat(chunks)
const timestamp = String(request.headers['x-akstream-timestamp'] || '')
const supplied = String(request.headers['x-akstream-signature'] || '').replace(/^v1=/, '')
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
throw new Error('stale timestamp')
}
const expected = crypto.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody.toString('utf8')}`, 'utf8').digest('hex')
const left = Buffer.from(supplied, 'hex')
const right = Buffer.from(expected, 'hex')
if (left.length !== right.length || !crypto.timingSafeEqual(left, right)) {
throw new Error('invalid signature')
}
const event = JSON.parse(rawBody.toString('utf8'))
if (event.schemaVersion !== 1 || event.eventId !== request.headers['x-akstream-event-id']
|| event.eventName !== request.headers['x-akstream-event']) {
throw new Error('invalid event envelope')
}
if (!seen.has(event.eventId)) {
seen.add(event.eventId)
console.log(event.eventName, event.data)
}
response.writeHead(204).end()
} catch (error) {
console.error(error.message)
response.writeHead(401, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: 'invalid webhook' }))
}
})
}).listen(9080, '127.0.0.1', () => console.log('listening on 127.0.0.1:9080/akstream/webhook'))
示例中的 127.0.0.1 和内存 Set 只适合在同一台机器上联调。部署到另一台机器时,应把进程监听地址、反向代理路由和方案中的公网/内网 HTTPS 地址配成可达的同一路径;生产接收端还需限制正文大小,把幂等记录和业务处理放在数据库事务中。无须向这个接收接口附加 AKStream.Next 管理员 Token,鉴权凭据是该方案的 HMAC 密钥。
API 与权限
| 方法 | 路径 | 权限 | 用途 |
|---|---|---|---|
| GET | /api/v2/system/webhooks/status |
system.view |
全局方案和 Delivery 摘要 |
| GET | /api/v2/system/webhooks/catalog |
system.view |
分组事件勾选目录 |
| GET | /api/v2/system/webhooks/subscriptions |
system.view |
列出脱敏推送方案 |
| POST | /api/v2/system/webhooks/subscriptions |
system.config.manage |
创建方案 |
| PUT | /api/v2/system/webhooks/subscriptions/{id} |
system.config.manage |
修改方案、可选轮换密钥 |
| POST | /api/v2/system/webhooks/subscriptions/{id}/archive |
system.config.manage |
归档方案并保留历史 |
| POST | /api/v2/system/webhooks/subscriptions/{id}/test |
system.config.manage |
向指定方案发送测试 |
| GET | /api/v2/system/webhooks/subscriptions/{id}/deliveries |
system.view |
查看该方案投递历史 |
| POST | /api/v2/system/webhooks/deliveries/{id}/retry |
system.config.manage |
重试一条失败 Delivery |
升级与迁移
旧版 AkStream:OutboundWebhook 单地址配置在第一次启动新版本时迁移成“默认推送方案”。旧共享密钥读取后立即使用当前安装的安全数据密钥加密;迁移日志只记录启用状态和事件数量,不记录 URL 密钥或明文。
迁移只执行一次。新表中存在活动或已归档方案后,不会重复创建默认方案。旧配置在兼容窗口内保留但不再是多方案运行权威。回滚到旧版本前必须备份新表,因为旧版本无法无损表达多个方案。
排查问题
| 现象 | 检查顺序 |
|---|---|
| 测试按钮不可用 | 选择方案 → 方案已启用 → 密钥已配置 → 地址和事件范围有效 |
| 一直等待重试 | 最近 HTTP 状态/错误 → DNS → TLS 证书链 → WAF/代理 → 第三方日志 |
| 返回 401 | 接收端是否按原始正文验签 → 时间戳窗口 → 是否使用当前方案密钥 |
| 收到重复事件 | 必须按 EventId 幂等;网络成功但 2xx 响应丢失时平台会安全重试 |
| 方案 A 正常、B 失败 | 这是预期独立状态;只处理和重试 B,不要重放 A |
| GB28181 没有注册通知 | 设备是否发生真实状态转换;重复 REGISTER/Keepalive 不制造通知 |
| RTC 没有成员通知 | 是否通过安全入会/离开流程;普通心跳和页面刷新不会外发 |
| MediaServer 异常无通知 | 检查托管守护任务、生命周期 Hook、Outbox 和该方案是否勾选异常事件 |
| 页面不显示原始载荷 | 这是安全设计;使用服务器受控日志和业务实体接口排障 |
下一步:先为测试接收端创建一个独立方案,只勾选推荐事件,完成真实测试后再按业务系统拆分更多方案。