nimeet 文档中心
English

回调接入与签名校验

事件信封与签名校验、重试计划、双通道对账、投递日志与重放、接收方实现清单。

版本 v1.1.0 · 更新于 2026-10-26 · 修订 1

回调(webhook)把频道与任务的状态变化推给你的业务后端:入会/离会、角色切换、录制/推流状态、Token 即将过期。投递是签名 + 重试 + 可审计的,并且与定时对账共享同一条幂等写入路径。

前置条件

  • 一个公网可达的 HTTPS 接收地址,能处理 POST 请求并在 5 秒内返回 2xx。
  • 在控制台配置 webhook 地址并保存 secret;用它对原始请求体校验 Agora-Signature / Agora-Signature-V2。
  • 接收端按 noticeId 做幂等;失败时返回 5xx 让控制面重试。

事件信封

所有回调共用同一信封(接口定义):

字段类型说明
noticeIdstring通知 id,每次事件唯一;可用于幂等去重
productIdnumber固定 1(RTC)
eventTypenumber事件码,见下表
notifyMsnumber事件产生时间(epoch 毫秒)
sidstring事件会话 id
payloadobject事件专属字段
json
{
  "noticeId": "ntc_1f4d9a3c2b7e",
  "productId": 1,
  "eventType": 107,
  "notifyMs": 1793000000123,
  "sid": "prj_3f2a:channel_create",
  "payload": {
    "channelName": "weekly-sync",
    "platform": 7,
    "ts": 1793000000,
    "uid": 10001
  }
}

请求头与签名

请求头值
Content-Typeapplication/json
Agora-SignatureHMAC-SHA1(endpointSecret, rawBody) 的十六进制
Agora-Signature-V2HMAC-SHA256(endpointSecret, rawBody) 的十六进制
X-Request-Id投递关联 id(与服务端日志一致)
x-notice-id与信封里的 noticeId 相同,便于网关层去重

Node 校验(可直接放进你的 webhook handler):

javascript
import { createHmac, timingSafeEqual } from 'node:crypto';

function safeEqualHex(a, b) {
  const left = Buffer.from(String(a).toLowerCase(), 'utf8');
  const right = Buffer.from(String(b).toLowerCase(), 'utf8');
  return left.length === right.length && timingSafeEqual(left, right);
}

/**
 * @param {string} secret  控制台里配置的回调 secret
 * @param {string} rawBody 原始请求体字符串(未经 JSON.parse + stringify)
 * @param {Record<string,string|undefined>} headers
 */
export function verifyNimeetCallback(secret, rawBody, headers) {
  const hmac = (algorithm) => createHmac(algorithm, secret).update(rawBody, 'utf8').digest('hex');
  const v2 = headers['agora-signature-v2'];
  if (v2) return safeEqualHex(v2.replace(/^sha256=/, ''), hmac('sha256')) ? { ok: true, scheme: 'v2' } : { ok: false, reason: 'v2 mismatch' };
  const v1 = headers['agora-signature'];
  if (v1) return safeEqualHex(v1.replace(/^sha1=/, ''), hmac('sha1')) ? { ok: true, scheme: 'v1' } : { ok: false, reason: 'v1 mismatch' };
  return { ok: false, reason: 'missing signature headers' };
}

// Express 示例:保留 raw body
// app.post('/hooks/nimeet', express.raw({ type: 'application/json' }), (req, res) => {
//   const rawBody = req.body.toString('utf8');
//   const result = verifyNimeetCallback(process.env.NIMEET_WEBHOOK_SECRET, rawBody, req.headers);
//   if (!result.ok) return res.status(401).end();
//   const envelope = JSON.parse(rawBody);
//   if (seen.has(envelope.noticeId)) return res.status(200).end(); // 幂等
//   // ... handle envelope
//   res.status(200).end();
// });

Python 校验(Flask):

python
import hashlib
import hmac
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = b"replace-with-a-long-random-secret"

def _safe_equal(expected_hex: str, provided_hex: str) -> bool:
    return hmac.compare_digest(expected_hex.lower().encode(), provided_hex.lower().encode())

@app.post("/hooks/nimeet")
def nimeet_webhook():
    raw = request.get_data(cache=True)  # 原始字节,不要用 request.get_json()
    v2 = request.headers.get("Agora-Signature-V2")
    v1 = request.headers.get("Agora-Signature")
    if v2:
        expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
        if not _safe_equal(expected, v2.removeprefix("sha256=")):
            abort(401)
    elif v1:
        expected = hmac.new(SECRET, raw, hashlib.sha1).hexdigest()
        if not _safe_equal(expected, v1.removeprefix("sha1=")):
            abort(401)
    else:
        abort(401)

    envelope = request.get_json(silent=True) or {}
    if envelope.get("noticeId") in SEEN:      # 幂等,建议按 noticeId 落库唯一键
        return ("ok", 200)
    handle(envelope)
    return ("ok", 200)

事件表

事件码名称payload 字段触发时机
101channel_createchannelName、profile、ts频道创建(首个参与者加入或房间创建)
102channel_destroychannelName、ts频道销毁
103broadcaster_joinchannelName、platform、ts、uid主播加入 live 频道
104broadcaster_leavechannelName、reason、ts、uid主播离开(reason 为声网离开原因)
105audience_joinchannelName、platform、ts、uid观众加入 live 频道
106audience_leavechannelName、reason、ts、uid观众离开
107user_joinchannelName、platform、ts、uid用户加入 communication 频道
108user_leavechannelName、reason、ts、uid用户离开 communication 频道
111role_to_broadcasterchannelName、ts、uid被提升为主播(POST .../role 为 host)
112role_to_audiencechannelName、ts、uid被降级为观众
201recording_state_changedtaskId、channelName、mode、state、files[]、ts、error?录制任务状态变化
202streaming_state_changedtaskId、channelName、entryPoint、streamName、rtmpUrl、hlsUrl、state、ts、error?推流任务状态变化(含 idle 回收)
203token_expiringchannelName、privilegeExpireAt、ts、uidToken 权限即将过期(提前 30 秒)

离开原因(reason):1 正常/其他、2 超时、3 被踢或封禁、5 重复登录、10 网络断开。

重试计划

项值
成功判定HTTP 2xx
最大尝试次数9(首投 + 8 次重试)
退避序列(秒)5, 15, 60, 300, 900, 3600, 7200, 21600
单次超时10 秒
投递状态pending / delivering / delivered / failed / dropped(当前实现使用前四种中的三类:pending/delivered/failed)

超过重试预算后置为 failed 并停止;dropped 为接口定义预留(例如 endpoint 被禁用时)。可以随时在控制台/API 手动重放。

双通道对账(Webhook + Reconciliation)

状态事实只有一份,但有两条写入通道:

  1. 事件通道:媒体面(SFU/Egress)通过签名 webhook 推事件 → 控制面校验签名 → 规范化 → 写入频道/任务状态;
  2. 对账通道:控制面定时向媒体面拉取房间与参与者状态,与本地记录比对后补齐差异。

两条通道都走同一条幂等写入路径(幂等的 upsert + 用量事件 idempotencyKey),所以:

  • webhook 丢失不会让状态永久错误,对账会补齐;
  • 双通道重复观测不会重复计费;
  • 状态变化先落库,再进事务性 Outbox,最后才投递回调——因此「回调失败」不会回滚业务状态,只会重投。

配置、投递日志与重放

控制台会话(Bearer)配置回调:

方法路径说明
GET/console/projects/{projectId}/webhook查看配置(secret 打码)与可选事件列表
PUT/console/projects/{projectId}/webhook设置 url、secret(≥8 位)、events[]、enabled
DELETE/console/projects/{projectId}/webhook删除配置
GET/console/projects/{projectId}/webhook/deliveries?state=&limit=投递日志
POST/console/projects/{projectId}/webhook/deliveries/{deliveryId}/replay重放一条投递

项目凭证也可以读取投递日志:GET /v1/webhook/deliveries?state=&limit=。

投递记录字段:id、eventName、url、state、attempts、lastStatusCode、lastError、nextAttemptAt。重放会把 state 重置为 pending、attempts 归零并立即排期。

bash
#!/usr/bin/env bash
# 回调配置与投递日志(控制台会话)——需要先登录拿 Bearer session。
# 演示账号:owner@ni-meet.local / ni-meet-demo(CONSOLE_SEED_EMAIL/PASSWORD 可覆盖)
set -euo pipefail

: "${NIMEET_API_BASE:=http://127.0.0.1:8787}"
: "${CONSOLE_EMAIL:=owner@ni-meet.local}"
: "${CONSOLE_PASSWORD:=ni-meet-demo}"

# 1) 登录
SESSION=$(curl -s -X POST "$NIMEET_API_BASE/console/auth/login" \
  -H 'Content-Type: application/json' \
  -d "{\"email\":\"$CONSOLE_EMAIL\",\"password\":\"$CONSOLE_PASSWORD\"}" \
  | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).token))")
echo "session acquired"

# 2) 项目列表(取第一个项目 id)
PROJECT_ID=$(curl -s "$NIMEET_API_BASE/console/projects" -H "Authorization: Bearer $SESSION" \
  | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).projects[0].id))")
echo "projectId=$PROJECT_ID"

# 3) 查看/配置回调地址(secret 用于签名,至少 8 位)
curl -s "$NIMEET_API_BASE/console/projects/$PROJECT_ID/webhook" -H "Authorization: Bearer $SESSION"
curl -s -X PUT "$NIMEET_API_BASE/console/projects/$PROJECT_ID/webhook" \
  -H "Authorization: Bearer $SESSION" -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com/hooks/nimeet","secret":"replace-with-a-long-random-secret","events":["channel_create","user_join","user_leave","recording_state_changed"],"enabled":true}'

# 4) 投递日志与重放
curl -s "$NIMEET_API_BASE/console/projects/$PROJECT_ID/webhook/deliveries?limit=20" -H "Authorization: Bearer $SESSION"
# curl -s -X POST "$NIMEET_API_BASE/console/projects/$PROJECT_ID/webhook/deliveries/<deliveryId>/replay" \
#   -H "Authorization: Bearer $SESSION"

接收方实现清单

  1. 用 raw body 校验签名(V2 优先,兼容 V1),常量时间比较;
  2. 按 noticeId 做幂等(建议数据库唯一键),重复投递直接 200;
  3. 快速返回 2xx:业务处理放队列,避免 10 秒超时触发重试放大;
  4. 处理顺序不敏感:回调可能乱序到达,状态类事件请以 ts 与查询接口为准做收敛;
  5. 未知 eventType 记录后忽略,不要 500(否则会持续重试);
  6. 用 X-Request-Id 与服务端日志对齐排障。

下一步

本页由文档构建流水线生成 · 2026-10-31 01:24:02 UTC · 文档中心 v1.1.0.

内容依据公开 API 定义.

ni-meet 是自建 RTC PaaS,媒体面基于 LiveKit(Apache-2.0),并与声网 API 兼容。