AKStream.Next · 文档中心

用第三方 Webhook 接收 AKStream.Next 重要事件

用第三方 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 推送方案列表、全局状态和独立接收端

快速开始

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

一个地址一个方案的名称、URL、密钥和独立策略

理解多推送方案

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: 刷新逐方案投递记录

逐方案投递状态、HTTP 结果、错误和失败恢复

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 和该方案是否勾选异常事件
页面不显示原始载荷 这是安全设计;使用服务器受控日志和业务实体接口排障

下一步:先为测试接收端创建一个独立方案,只勾选推荐事件,完成真实测试后再按业务系统拆分更多方案。