Callbacks and Signature Verification
Event envelope and signatures, retry schedule, dual-channel reconciliation, delivery logs and receiver checklist.
Callbacks (webhooks) push channel and task state changes to your business backend: joins and leaves, role switches, recording/media push state, and tokens that are about to expire. Delivery is signed, retried and auditable, and it shares one idempotent write path with periodic reconciliation.
Prerequisites
- A publicly reachable HTTPS endpoint that handles POST requests and returns 2xx within 5 seconds.
- A webhook URL configured in the console with a saved
secret; use it to verifyAgora-Signature/Agora-Signature-V2over the raw request body. - Make the receiver idempotent by
noticeId; return 5xx on failure so the control plane retries.
Event envelope
All callbacks share the same envelope (defined by the callbacks interface definition):
| Field | Type | Description |
|---|---|---|
noticeId | string | Notification id, unique per event; usable for idempotent deduplication |
productId | number | Always 1 (RTC) |
eventType | number | Event code, see the table below |
notifyMs | number | When the event happened (epoch milliseconds) |
sid | string | Event session id |
payload | object | Event-specific fields |
{
"noticeId": "ntc_1f4d9a3c2b7e",
"productId": 1,
"eventType": 107,
"notifyMs": 1793000000123,
"sid": "prj_3f2a:channel_create",
"payload": {
"channelName": "weekly-sync",
"platform": 7,
"ts": 1793000000,
"uid": 10001
}
}Headers and signatures
| Header | Value |
|---|---|
Content-Type | application/json |
Agora-Signature | Hex digest of HMAC-SHA1(endpointSecret, rawBody) |
Agora-Signature-V2 | Hex digest of HMAC-SHA256(endpointSecret, rawBody) |
X-Request-Id | Delivery correlation id (matches the server logs) |
x-notice-id | Same value as noticeId in the envelope, so gateways can deduplicate |
Node verification (drop it straight into your 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 the callback secret configured in the console
* @param {string} rawBody the raw request body string (not run through 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 example: keep the 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(); // idempotent
// // ... handle envelope
// res.status(200).end();
// });Python verification (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) # raw bytes; do not use 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: # idempotent: keep a unique key on noticeId
return ("ok", 200)
handle(envelope)
return ("ok", 200)Event table
| Event code | Name | payload fields | When it fires |
|---|---|---|---|
101 | channel_create | channelName, profile, ts | Channel created (first participant joins or the room is created) |
102 | channel_destroy | channelName, ts | Channel destroyed |
103 | broadcaster_join | channelName, platform, ts, uid | A broadcaster joins a live channel |
104 | broadcaster_leave | channelName, reason, ts, uid | A broadcaster leaves (reason is the Agora leave reason) |
105 | audience_join | channelName, platform, ts, uid | An audience member joins a live channel |
106 | audience_leave | channelName, reason, ts, uid | An audience member leaves |
107 | user_join | channelName, platform, ts, uid | A user joins a communication channel |
108 | user_leave | channelName, reason, ts, uid | A user leaves a communication channel |
111 | role_to_broadcaster | channelName, ts, uid | Promoted to broadcaster (POST .../role with host) |
112 | role_to_audience | channelName, ts, uid | Demoted to audience |
201 | recording_state_changed | taskId, channelName, mode, state, files[], ts, error? | Recording task state change |
202 | streaming_state_changed | taskId, channelName, entryPoint, streamName, rtmpUrl, hlsUrl, state, ts, error? | Media push task state change (including idle reclamation) |
203 | token_expiring | channelName, privilegeExpireAt, ts, uid | Token privilege is about to expire (30 seconds ahead) |
Leave reasons (reason): 1 normal/other, 2 timeout, 3 kicked or banned, 5 duplicate login, 10 network disconnected.
Retry schedule
| Item | Value |
|---|---|
| Success criterion | HTTP 2xx |
| Maximum attempts | 9 (first delivery + 8 retries) |
| Backoff sequence (seconds) | 5, 15, 60, 300, 900, 3600, 7200, 21600 |
| Per-attempt timeout | 10 seconds |
| Delivery states | pending / delivering / delivered / failed / dropped (today's implementation uses three of the first four: pending, delivered, failed) |
After the retry budget is exhausted a delivery is marked failed and stops; dropped is reserved by the interface definition (for example when an endpoint is disabled). You can replay manually from the console or the API at any time.
Dual-channel reconciliation (Webhook + Reconciliation)
There is only one copy of the state facts, but two write channels:
- Event channel: the media plane (SFU/Egress) pushes events through signed webhooks → the control plane verifies the signature → normalizes → writes channel and task state;
- Reconciliation channel: a background job (or an operator action) actively pulls room and participant snapshots from the media plane and fills in any differences against local state.
Both channels go through the same idempotent write path (idempotent upserts plus the usage-event idempotencyKey), therefore:
- a lost webhook never leaves state permanently wrong, because reconciliation fills the gap;
- duplicate observations across both channels are never billed twice;
- a state change is persisted first, then enters the transactional Outbox, and only then is the callback delivered — so a failed callback never rolls back business state, it only redelivers.
Configuration, delivery log and replay
Configure callbacks with a console session (Bearer):
| Method | Path | Description |
|---|---|---|
GET | /console/projects/{projectId}/webhook | Read the configuration (secret masked) and the list of selectable events |
PUT | /console/projects/{projectId}/webhook | Set url, secret (≥8 characters), events[], enabled |
DELETE | /console/projects/{projectId}/webhook | Delete the configuration |
GET | /console/projects/{projectId}/webhook/deliveries?state=&limit= | Delivery log |
POST | /console/projects/{projectId}/webhook/deliveries/{deliveryId}/replay | Replay one delivery |
The project credential can also read the delivery log: GET /v1/webhook/deliveries?state=&limit=.
Delivery record fields: id, eventName, url, state, attempts, lastStatusCode, lastError, nextAttemptAt. Replay resets state to pending, zeroes attempts and schedules the delivery immediately.
#!/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"Receiver implementation checklist
- Verify the signature against the raw body (prefer V2, stay compatible with V1), with a constant-time comparison;
- Deduplicate on
noticeId(a database unique key is recommended) and return 200 for duplicate deliveries; - Return 2xx quickly: push business processing onto a queue so the 10-second timeout does not amplify retries;
- Be order-insensitive: callbacks may arrive out of order, so converge stateful events using
tstogether with the query endpoints; - Log unknown
eventTypevalues and ignore them — never return 500 (that triggers endless retries); - Correlate
X-Request-Idwith the server logs when troubleshooting.
Next steps
- REST API reference: complete endpoints, parameters and curl.
- Error codes: locate failures by
code/ HTTP status. - FAQ: quotas, limits and troubleshooting.