接收 Webhook 事件
Webhook 是一种基于 HTTP 协议的回调机制,允许服务端主动推送数据。声网的消息通知服务使用 Webhook 向你推送特定事件的通知,当你订阅的事件发生时,声网业务服务器会将事件消息发送给声网消息通知服务器,然后声网消息通知服务器会通过 HTTPS POST 请求将事件通知投递给你的服务器。
适用场景
声网消息通知服务具有低延时、高并发、稳定可靠的特点。对于实时音视频通话,在频道高并发的场景下,通过消息通知服务可以在业务层维护一个实时同步的频道状态机,实现以下功能:
- 实时监听频道创建/销毁事件,建立当前业务的频道列表。
- 实时监听远端用户加入或离开频道事件,建立频道内的用户列表。
- 实时监听直播场景下用户角色切换事件,建立具有发流权限的用户(主播)列表。
前提条件
开始之前,请确保你满足以下条件:
- 有效的声网开发者账号和声网控制台项目。详见开通服务。
- 满足以下条件的服务器:
- 支持 HTTPS 协议。为提高安全性,声网消息通知服务不再支持 HTTP 服务器地址。
- (推荐)支持 HTTPS 连接重用,即 keep-alive 模式,以降低消息投递延时。声网建议进行如下设置:
MaxKeepAliveRequests
:大于等于 100。KeepAliveTimeout
:大于等于 10 秒。
开通消息通知服务
使用消息通知服务前,你需要在声网控制台自助开通并填写配置信息。
1. 启用并配置服务
参考以下步骤开通并配置消息通知服务:
-
登录声网控制台,点击左侧导航栏的全部产品,选择实时互动 RTC。
-
在产品页面,点击进入功能配置子页面,再切换到 Webhook 页签。
-
在配置区域点击新增事件,然后在新建 Webhook 弹出框中填入以下信息,最后点击保存。
-
消息接收区域:你的消息通知接收服务器所在的区域。声网会根据你提供的区域就近接入声网节点服务器。
-
消息接收 URL:接收消息通知的 HTTPS 服务器地址。
-
订阅事件:需要订阅的频道事件。详见实时音视频频道事件类型。如需配置 QPS (每秒请求数) 较高的事件,如实时音视频中观众加入或离开直播频道,请确保你的服务器有足够的处理能力。建议一次选择所有需要订阅的事件,以避免多次配置和健康检查。
-
IP 白名单:如果你的消息接收服务器受防火墙限制,请勾选该选项,并按照以下步骤将声网消息通知服务器的 IP 地址全部添加到防火墙白名单。
获取 IP 地址并添加到防火墙白名单如果你的服务器受防火墙限制,你需要调用 RESTful API 查询消息通知服务器 IP,将获取到的所有 IP 地址都添加到防火墙白名单。
声网可能会调整消息通知服务器的 IP 地址,因此强烈推荐你至少每 24 小时进行一次查询并自动更新防火墙配置,否则可能会影响你接收通知。
Javaimport java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Base64;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class Base64Encoding {
public static void main(String[] args) throws IOException {
// 客户 ID
// 需要设置环境变量 AGORA_CUSTOMER_KEY
final String customerKey = System.getenv("AGORA_CUSTOMER_KEY");
// 客户密钥
// 需要设置环境变量 AGORA_CUSTOMER_SECRET
final String customerSecret = System.getenv("AGORA_CUSTOMER_SECRET");
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
// 拼接客户 ID 和客户密钥并使用 base64 编码
String plainCredentials = customerKey + ":" + customerSecret;
String base64Credentials = new String(Base64.getEncoder().encode(plainCredentials.getBytes()));
// 创建 authorization header
String authorizationHeader = "Basic " + base64Credentials;
// 创建 HTTP 请求对象
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.agora.io/v2/ncs/ip"))
.GET()
.header("Authorization", authorizationHeader)
.header("Content-Type", "application/json")
.build();
// 使用 executor 安排任务,每 24 小时运行一次
executor.scheduleAtFixedRate(() -> {
try {
HttpClient client = HttpClient.newHttpClient();
// 发送 HTTP 请求
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}, 0, 24, TimeUnit.HOURS);
}
}
-
完成功能配置后,声网会对你的配置进行健康检查。
2. 完成健康检查
健康检查的步骤如下:
-
声网会根据你订阅的事件生成对应的测试事件,并向你的服务器发送事件回调。在测试事件回调中,
channelName
为test_webhook
,uid
为12121212
。 -
接收到每个测试事件回调后,你的服务器需要在 10 秒内对声网消息服务器作出响应,响应状态码必须为 200,响应包体格式为 JSON,包体内容不作要求。示例代码如下:
JavaScriptconst express = require('express');
const app = express();
const bodyParser = require('body-parser');
// 使用 body-parser 中间件解析请求体
app.use(bodyParser.json());
// 处理接收到的 POST 请求
app.post('/', express.json({ type: 'application/json' }), (req, res) => {
const event = request.body;
// 5 秒后发送响应
setTimeout(() => {
res.status(200).json({message: 'Success'});
}, 5000);
// 发送响应后,再执行耗时较长的任务
}); -
如果健康检查失败,请根据声网控制台的提示进行错误排查。常见的错误包括:
- 请求超时:你的服务器没有在 10 秒内返回 200。请检查你的服务器是否及时对事件回调作出正确的响应。如果响应正确,请联系技术支持确认声网消息通知服务器到你的服务器之间的网络连接是否正常。
- 证书错误:HTTPS 证书错误。请检查证书是否正确。如果你的服务器受防火墙限制,请检查是否已将声网消息通知服务器的 IP 地址全部添加到防火墙白名单。
- 域名不可达:域名不合法,无法解析到目标 IP 地址。请检查你的服务器部署是否正确。
- 响应错误:你的服务器返回的响应状态码不为 200,具体的状态码和描述详见控制台提示。
-
健康检查成功后,点击保存配置。
配置通过审核后,消息通知服务的状态会显示已启用。
接收消息通知回调
成功开通声网消息通知服务后,当订阅的频道事件发生时,声网消息服务器会以 HTTPS POST 请求的形式向你的服务器发送消息通知回调。详见实时音视频频道事件类型。
响应要求
接收到消息通知回调后,你的服务器需要在 10 秒内对声网消息服务器作出响应。响应包体格式为 JSON,包体内容不作要求。
声网消息服务器发送通知后的 10 秒内,如果没有收到你的服务器的响应或响应状态码不是 200,会认为消息通知失败。失败后,声网消息通知服务器会立即重新发送消息通知,投递间隔随着重试次数的上升逐渐增加,直到重试三次后停止投递。
验证签名
为提高声网消息服务器和你的服务器之间的通信安全,你可以通过签名机制进行身份验证,确保接收到的请求来自声网。
当声网向你的服务器发送消息通知回调时,会使用密钥通过 HMAC/SHA1 和 HMAC/SHA256 算法生成签名值,并分别添加在 HTTPS 请求 Header 的 Agora-Signature
和 Agora-Signature-V2
字段中。
参考以下步骤进行签名验证:
-
获取密钥:配置声网消息通知服务时,声网会生成一个密钥。在控制台左侧导航栏的全部产品下选择实时互动 RTC,进入功能配置子页面,然后在 Webhook 页签点击复制按钮即可获取该密钥。
-
收到回调后,使用密钥和请求包体里的参数,选用 HMAC/SHA1 或 HMAC/SHA256 算法计算签名值。
-
将你计算出的签名与请求 Header 中对应的字段进行对比:
- 如果你选用 HMAC/SHA1 算法:将计算值与
Agora-Signature
字段对比。二者完全相同则说明该请求是由声网发送的。 - 如果你选用 HMAC/SHA256 算法:将计算值与
Agora-Signature-V2
字段对比。二者完全相同则说明该请求是由声网发送的。
- 如果你选用 HMAC/SHA1 算法:将计算值与
声网提供多种语言的验证签名示例代码供你参考。
- HMAC/SHA256
- HMAC/SHA1
#!/usr/bin/env python2
# !-*- coding: utf-8 -*-
import hashlib
import hmac
# 拿到消息通知的 raw request body 并对其计算签名,也就是说下面代码中的 request_body 是反序列化之前的 binary byte array,不是反序列化之后的 dictionary
request_body = '{"eventType":10,"noticeId":"4eb720f0-8da7-11e9-a43e-53f411c2761f","notifyMs":1560408533119,"payload":{"a":"1","b":2},"productId":1}'
secret = 'secret'
signature2 = hmac.new(secret, request_body, hashlib.sha256).hexdigest()
print(signature2) # de96da5acf03b0021ac3b4fa2225e7ae6f3533a30d50bb02c08ea4fa748bda24
#!/usr/bin/env python2
# !-*- coding: utf-8 -*-
import hashlib
import hmac
# 拿到消息通知的 raw request body 并对其计算签名,也就是说下面代码中的 request_body 是反序列化之前的 binary byte array,不是反序列化之后的 dictionary
request_body = '{"eventType":10,"noticeId":"4eb720f0-8da7-11e9-a43e-53f411c2761f","notifyMs":1560408533119,"payload":{"a":"1","b":2},"productId":1}'
secret = 'secret'
signature = hmac.new(secret, request_body, hashlib.sha1).hexdigest()
print(signature) # 5a3bb6a6d9fad2ea9ae3fb707a14c9d7f3136df1
- HMAC/SHA256
- HMAC/SHA1
const crypto = require('crypto')
// 拿到消息通知的 raw request body 并对其计算签名,也就是说下面代码中的 requestBody 是反序列化之前的 binary byte array,不是反序列化之后的 object
const requestBody = '{"eventType":10,"noticeId":"4eb720f0-8da7-11e9-a43e-53f411c2761f","notifyMs":1560408533119,"payload":{"a":"1","b":2},"productId":1}'
const secret = 'secret'
const signature2 = crypto.createHmac('sha256', secret).update(requestBody, 'utf8').digest('hex')
console.log(signature2) // de96da5acf03b0021ac3b4fa2225e7ae6f3533a30d50bb02c08ea4fa748bda24
const crypto = require('crypto')
// 拿到消息通知的 raw request body 并对其计算签名,也就是说下面代码中的 requestBody 是反序列化之前的 binary byte array,不是反序列化之后的 object
const requestBody = '{"eventType":10,"noticeId":"4eb720f0-8da7-11e9-a43e-53f411c2761f","notifyMs":1560408533119,"payload":{"a":"1","b":2},"productId":1}'
const secret = 'secret'
const signature = crypto.createHmac('sha1', secret).update(requestBody, 'utf8').digest('hex')
console.log(signature) // 5a3bb6a6d9fad2ea9ae3fb707a14c9d7f3136df1
- HMAC/SHA256
- HMAC/SHA1
import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
public class HmacSha {
// 将加密后的字节数组转换成字符串
public static String bytesToHex(byte[] bytes) {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < bytes.length; i++) {
String hex = Integer.toHexString(bytes[i] & 0xFF);
if (hex.length() < 2) {
sb.append(0);
}
sb.append(hex);
}
return sb.toString();
}
// HMAC/SHA256 加密,返回加密后的字符串
public static String hmacSha256(String message, String secret) {
try {
SecretKeySpec signingKey = new SecretKeySpec(secret.getBytes(
"utf-8"), "HmacSHA256");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(signingKey);
byte[] rawHmac = mac.doFinal(message.getBytes("utf-8"));
return bytesToHex(rawHmac);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) {
//拿到消息通知的 raw request body 并对其计算签名,也就是说下面代码中的 request_body 是反序列化之前的 binary byte array,不是反序列化之后的 object
String request_body = "{\"eventType\":10,\"noticeId\":\"4eb720f0-8da7-11e9-a43e-53f411c2761f\",\"notifyMs\":1560408533119,\"payload\":{\"a\":\"1\",\"b\":2},\"productId\":1}";
String secret = "secret";
System.out.println(hmacSha256(request_body, secret)); //de96da5acf03b0021ac3b4fa2225e7ae6f3533a30d50bb02c08ea4fa748bda24
}
}
import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
public class HmacSha {
// 将加密后的字节数组转换成字符串
public static String bytesToHex(byte[] bytes) {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < bytes.length; i++) {
String hex = Integer.toHexString(bytes[i] & 0xFF);
if (hex.length() < 2) {
sb.append(0);
}
sb.append(hex);
}
return sb.toString();
}
// HMAC/SHA1 加密,返回加密后的字符串
public static String hmacSha1(String message, String secret) {
try {
SecretKeySpec signingKey = new SecretKeySpec(secret.getBytes(
"utf-8"), "HmacSHA1");
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(signingKey);
byte[] rawHmac = mac.doFinal(message.getBytes("utf-8"));
return bytesToHex(rawHmac);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) {
//拿到消息通知的 raw request body 并对其计算签名,也就是说下面代码中的 request_body 是反序列化之前的 binary byte array,不是反序列化之后的 object
String request_body = "{\"eventType\":10,\"noticeId\":\"4eb720f0-8da7-11e9-a43e-53f411c2761f\",\"notifyMs\":1560408533119,\"payload\":{\"a\":\"1\",\"b\":2},\"productId\":1}";
String secret = "secret";
System.out.println(hmacSha1(request_body, secret)); //5a3bb6a6d9fad2ea9ae3fb707a14c9d7f3136df1
}
}
- HMAC/SHA256
- HMAC/SHA1
<?php
function assertEqual($expect, $actual)
{
if ($expect != $actual) {
echo("\n assert failed");
echo("\n expect:\n " . $expect);
echo("\n actual:\n " . $actual);
echo("\n");
} else {
echo("assert ok\n");
echo("\n");
}
}
// 拿到消息通知的 raw request body 并对其计算签名,也就是说下面代码中的 requestBody 是反序列化之前的 binary byte array,不是反序列化之后的 object
$request_body = '{"eventType":10,"noticeId":"4eb720f0-8da7-11e9-a43e-53f411c2761f","notifyMs":1560408533119,"payload":{"a":"1","b":2},"productId":1}';
$secret = 'secret';
// 请求 header 中 Agora-Signature-V2 的值
$sha256 = 'de96da5acf03b0021ac3b4fa2225e7ae6f3533a30d50bb02c08ea4fa748bda24';
$res2 = (hash_hmac('sha256', $request_body, $secret));
assertEqual($res2, $sha256);
?>
<?php
function assertEqual($expect, $actual)
{
if ($expect != $actual) {
echo("\n assert failed");
echo("\n expect:\n " . $expect);
echo("\n actual:\n " . $actual);
echo("\n");
} else {
echo("assert ok\n");
echo("\n");
}
}
// 拿到消息通知的 raw request body 并对其计算签名,也就是说下面代码中的 requestBody 是反序列化之前的 binary byte array,不是反序列化之后的 object
$request_body = '{"eventType":10,"noticeId":"4eb720f0-8da7-11e9-a43e-53f411c2761f","notifyMs":1560408533119,"payload":{"a":"1","b":2},"productId":1}';
$secret = 'secret';
// 请求 header 中 Agora-Signature 的值
$sha1 = '5a3bb6a6d9fad2ea9ae3fb707a14c9d7f3136df1';
$res1 = (hash_hmac('sha1', $request_body, $secret));
assertEqual($res1, $sha1);
?>
- HMAC/SHA256
- HMAC/SHA1
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
)
func main() {
// 拿到消息通知的 raw request body 并对其计算签名,也就是说下面代码中的 request_body 是反序列化之前的 binary byte array,不是反序列化之后的 dictionary
request_body := `{"eventType":10,"noticeId":"4eb720f0-8da7-11e9-a43e-53f411c2761f","notifyMs":1560408533119,"payload":{"a":"1","b":2},"productId":1}`
secret := "secret"
fmt.Println(calcSignatureV2(secret, request_body)) // de96da5acf03b0021ac3b4fa2225e7ae6f3533a30d50bb02c08ea4fa748bda24
}
func calcSignatureV2(secret, payload string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
return hex.EncodeToString(mac.Sum(nil))
}
package main
import (
"crypto/hmac"
"crypto/sha1"
"encoding/hex"
"fmt"
)
func main() {
// 拿到消息通知的 raw request body 并对其计算签名,也就是说下面代码中的 request_body 是反序列化之前的 binary byte array,不是反序列化之后的 dictionary
request_body := `{"eventType":10,"noticeId":"4eb720f0-8da7-11e9-a43e-53f411c2761f","notifyMs":1560408533119,"payload":{"a":"1","b":2},"productId":1}`
secret := "secret"
fmt.Println(calcSignatureV1(secret, request_body)) // 5a3bb6a6d9fad2ea9ae3fb707a14c9d7f3136df1
}
func calcSignatureV1(secret, payload string) string {
mac := hmac.New(sha1.New, []byte(secret))
mac.Write([]byte(payload))
return hex.EncodeToString(mac.Sum(nil))
}
开发注意事项
- 声网消息通知服务不保证消息通知完全按照事件发生的顺序到达,你的服务器需要能处理乱序消息。
- 为提高消息服务的可靠性,每次事件可能会有不止一次消息通知,你的服务器需要能处理重复消息。
更多功能
你可以根据业务需求,将频道管理 RESTful API 和消息通知服务结合使用,实现稳定可靠、实时有效的频道管理和状态同步。