nimeet Docs
中文

Callbacks and Signature Verification

Event envelope and signatures, retry schedule, dual-channel reconciliation, delivery logs and receiver checklist.

Version v1.1.0 · Updated 2026-10-26 · revision 1

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 verify Agora-Signature / Agora-Signature-V2 over 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):

FieldTypeDescription
noticeIdstringNotification id, unique per event; usable for idempotent deduplication
productIdnumberAlways 1 (RTC)
eventTypenumberEvent code, see the table below
notifyMsnumberWhen the event happened (epoch milliseconds)
sidstringEvent session id
payloadobjectEvent-specific fields
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
  }
}

Headers and signatures

HeaderValue
Content-Typeapplication/json
Agora-SignatureHex digest of HMAC-SHA1(endpointSecret, rawBody)
Agora-Signature-V2Hex digest of HMAC-SHA256(endpointSecret, rawBody)
X-Request-IdDelivery correlation id (matches the server logs)
x-notice-idSame value as noticeId in the envelope, so gateways can deduplicate

Node verification (drop it straight into your 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  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):

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)  # 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 codeNamepayload fieldsWhen it fires
101channel_createchannelName, profile, tsChannel created (first participant joins or the room is created)
102channel_destroychannelName, tsChannel destroyed
103broadcaster_joinchannelName, platform, ts, uidA broadcaster joins a live channel
104broadcaster_leavechannelName, reason, ts, uidA broadcaster leaves (reason is the Agora leave reason)
105audience_joinchannelName, platform, ts, uidAn audience member joins a live channel
106audience_leavechannelName, reason, ts, uidAn audience member leaves
107user_joinchannelName, platform, ts, uidA user joins a communication channel
108user_leavechannelName, reason, ts, uidA user leaves a communication channel
111role_to_broadcasterchannelName, ts, uidPromoted to broadcaster (POST .../role with host)
112role_to_audiencechannelName, ts, uidDemoted to audience
201recording_state_changedtaskId, channelName, mode, state, files[], ts, error?Recording task state change
202streaming_state_changedtaskId, channelName, entryPoint, streamName, rtmpUrl, hlsUrl, state, ts, error?Media push task state change (including idle reclamation)
203token_expiringchannelName, privilegeExpireAt, ts, uidToken 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

ItemValue
Success criterionHTTP 2xx
Maximum attempts9 (first delivery + 8 retries)
Backoff sequence (seconds)5, 15, 60, 300, 900, 3600, 7200, 21600
Per-attempt timeout10 seconds
Delivery statespending / 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:

  1. Event channel: the media plane (SFU/Egress) pushes events through signed webhooks → the control plane verifies the signature → normalizes → writes channel and task state;
  2. 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):

MethodPathDescription
GET/console/projects/{projectId}/webhookRead the configuration (secret masked) and the list of selectable events
PUT/console/projects/{projectId}/webhookSet url, secret (≥8 characters), events[], enabled
DELETE/console/projects/{projectId}/webhookDelete the configuration
GET/console/projects/{projectId}/webhook/deliveries?state=&limit=Delivery log
POST/console/projects/{projectId}/webhook/deliveries/{deliveryId}/replayReplay 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.

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"

Receiver implementation checklist

  1. Verify the signature against the raw body (prefer V2, stay compatible with V1), with a constant-time comparison;
  2. Deduplicate on noticeId (a database unique key is recommended) and return 200 for duplicate deliveries;
  3. Return 2xx quickly: push business processing onto a queue so the 10-second timeout does not amplify retries;
  4. Be order-insensitive: callbacks may arrive out of order, so converge stateful events using ts together with the query endpoints;
  5. Log unknown eventType values and ignore them — never return 500 (that triggers endless retries);
  6. Correlate X-Request-Id with the server logs when troubleshooting.

Next steps

Generated by the documentation build pipeline · 2026-10-31 01:24:02 UTC · Documentation center v1.1.0.

Content follows the public API definitions.

ni-meet is a self-hosted RTC PaaS with a LiveKit-based media plane (Apache-2.0) and an Agora-compatible API.