回调接入与签名校验
事件信封与签名校验、重试计划、双通道对账、投递日志与重放、接收方实现清单。
回调(webhook)把频道与任务的状态变化推给你的业务后端:入会/离会、角色切换、录制/推流状态、Token 即将过期。投递是签名 + 重试 + 可审计的,并且与定时对账共享同一条幂等写入路径。
前置条件
- 一个公网可达的 HTTPS 接收地址,能处理 POST 请求并在 5 秒内返回 2xx。
- 在控制台配置 webhook 地址并保存
secret;用它对原始请求体校验Agora-Signature/Agora-Signature-V2。 - 接收端按
noticeId做幂等;失败时返回 5xx 让控制面重试。
事件信封
所有回调共用同一信封(接口定义):
| 字段 | 类型 | 说明 |
|---|---|---|
noticeId | string | 通知 id,每次事件唯一;可用于幂等去重 |
productId | number | 固定 1(RTC) |
eventType | number | 事件码,见下表 |
notifyMs | number | 事件产生时间(epoch 毫秒) |
sid | string | 事件会话 id |
payload | object | 事件专属字段 |
{
"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-Type | application/json |
Agora-Signature | HMAC-SHA1(endpointSecret, rawBody) 的十六进制 |
Agora-Signature-V2 | HMAC-SHA256(endpointSecret, rawBody) 的十六进制 |
X-Request-Id | 投递关联 id(与服务端日志一致) |
x-notice-id | 与信封里的 noticeId 相同,便于网关层去重 |
Node 校验(可直接放进你的 webhook handler):
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):
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 字段 | 触发时机 |
|---|---|---|---|
101 | channel_create | channelName、profile、ts | 频道创建(首个参与者加入或房间创建) |
102 | channel_destroy | channelName、ts | 频道销毁 |
103 | broadcaster_join | channelName、platform、ts、uid | 主播加入 live 频道 |
104 | broadcaster_leave | channelName、reason、ts、uid | 主播离开(reason 为声网离开原因) |
105 | audience_join | channelName、platform、ts、uid | 观众加入 live 频道 |
106 | audience_leave | channelName、reason、ts、uid | 观众离开 |
107 | user_join | channelName、platform、ts、uid | 用户加入 communication 频道 |
108 | user_leave | channelName、reason、ts、uid | 用户离开 communication 频道 |
111 | role_to_broadcaster | channelName、ts、uid | 被提升为主播(POST .../role 为 host) |
112 | role_to_audience | channelName、ts、uid | 被降级为观众 |
201 | recording_state_changed | taskId、channelName、mode、state、files[]、ts、error? | 录制任务状态变化 |
202 | streaming_state_changed | taskId、channelName、entryPoint、streamName、rtmpUrl、hlsUrl、state、ts、error? | 推流任务状态变化(含 idle 回收) |
203 | token_expiring | channelName、privilegeExpireAt、ts、uid | Token 权限即将过期(提前 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)
状态事实只有一份,但有两条写入通道:
- 事件通道:媒体面(SFU/Egress)通过签名 webhook 推事件 → 控制面校验签名 → 规范化 → 写入频道/任务状态;
- 对账通道:控制面定时向媒体面拉取房间与参与者状态,与本地记录比对后补齐差异。
两条通道都走同一条幂等写入路径(幂等的 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 归零并立即排期。
#!/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"接收方实现清单
- 用 raw body 校验签名(V2 优先,兼容 V1),常量时间比较;
- 按
noticeId做幂等(建议数据库唯一键),重复投递直接 200; - 快速返回 2xx:业务处理放队列,避免 10 秒超时触发重试放大;
- 处理顺序不敏感:回调可能乱序到达,状态类事件请以
ts与查询接口为准做收敛; - 未知
eventType记录后忽略,不要 500(否则会持续重试); - 用
X-Request-Id与服务端日志对齐排障。
下一步
- REST API 参考:完整端点、参数与 curl。
- 错误码:失败时按
code/ HTTP 状态定位。 - 常见问题:配额、限制与排障。