Flutter×Agoraのビデオ通話機能に時間制限をつける(Agora Webhook)

- 開発

こんにちは。エンスポーツでエンジニアをしている福島です。
今回は通話機能の開発を技術選定から実装まで担当しました。とくにユーザごとの通話時間に制限を持たせる仕様については検討余地が多く、情報も少なかったためナレッジとして共有させていただきます。
Flutter × Agoraのビデオ通話機能開発における、時間制限の実現方法に関する記事です。
Flutter×Agoraのビデオ通話機能に時間制限をつけることになった
エンスポーツは恋活・婚活マッチングアプリです。ユーザ同士のコミュニケーションを活発化させるため、そして外部SNSなどでコミュニケーションをとって無用なトラブルにあってしまうユーザを減らすために、通話機能の実装が決まりました。
そんな通話の仕組みには、US企業が提供しているAgoraを導入。通話システム自体を自前で実装することも検討したのですが、インフラ管理や安定性担保における工数やリスクを考えて、外部サービスに依存することに決めました。
そんな「通話機能」ですが、マッチングアプリではこの機能自体にお金を払っていただくわけではなく、あくまでサポート機能的な位置付けです。無制限に通話を許可していては採算があいません。
つまりなんらかの時間制限は必要になるということで、以下の考え方で実装方針を固めました。
- ユーザはあらかじめ3時間の制限時間をデータとして持つ
- 退出に応じて、データの制限時間から一回あたりの接続時間を差し引く
- 制限時間が0なら通話へ参加できないようにする。フロントでは操作できないのはもちろん、バックエンドでもエラーハンドリングする(Agoraの場合は、RTC通信用のトークンを生成からストップさせる)
- 1日3時間制限は、翌日午前3時にリセットする
Flutter×Agoraで時間制限を実現する方法
そんなAgoraですが、Flutterアプリ側のみでもユーザ退出イベントは取れるようになっており、入退室の時間は取れます。
_engine.registerEventHandler(RtcEngineEventHandler(
onLeaveChannel: (RtcConnection connection, RtcStats stats) {
// フロントでやる場合は本イベントで制限時間の更新処理を行う
// 正常に退出できない場合、ここでは検知されないリスクがある
},
));
簡易に実装するならば、この情報を元に1日の制限時間を更新してもいいのですが、アプリのクラッシュやネット接続がオフラインになった場合にフロント側ではイベントを正確に取れず、1日の制限時間に整合性を持たせることができないリスクがあります。
そこで今回はWebhook連携をするバックエンドサービスの構築が必要であると判断しました。
AgoraのWebHookで時間制限を実現する流れ
これから時間制限を実装する方の助けになると良いなと思い、テスト実装をした際の流れをメモしていきます。
Agora側でWebHookの設定をおこなう
AgoraダッシュボードにあるProject設定のNotification項目を、以下のように設定します。

「Url Endpoint」にはWebHookに使うAPIのURLを設定します。
例: https://api.xxxx.com/webhook
イベントをハンドルする仕組みを書く
ドキュメントを参考に、簡易的にExpress.jsのAPIを実装してみます。Webhookをとりあえずテストするのが目的なので、今回はインフラへの隠蔽等は行っていません。DDDでプロジェクトを構築する人が本実装をする場合は、実装を隠蔽してPortAdapterからサービスで利用する形になるのが理想でしょう。
今回想定しているユーザ同士の通話は、あくまで1-1になります。この場合は「CommunicationMode」のイベントコード「108」をハンドリングする必要があります。
※Flutterアプリ側で接続Profileの適切な設定が必要です。未設定もしくはProfileに違いがあると、イベントが飛びません。
// flutter app内でAgora Flutter SDKを初期化している場所
await rtcEngine.initialize(RtcEngineContext(
appId: 'AGORAのプロジェクトAPPID',
channelProfile: ChannelProfileType.channelProfileCommunication,
));
型を定義する
※バックエンドはTypescriptで書いています。
全てAnyのままでも扱えますが、一応可読性を上げたいので型も定義しました。フィールドは公式ドキュメントを参考に用意しています。
export enum EventType {
EventUserLeft = 108, // 退出だけ使いたいので108のみ
}
export interface WebhookRequest {
noticeId: string;
productId: number;
eventType: number;
payload: Payload;
}
export interface Payload {
// buildTokenWithUserAccountで生成した場合に文字列UserIdが使用可能
// この場合フロント側はjoinChannelWithUserAccountを使用します
account: string;
clientSeq: number;
uid: number;
channelName: string;
ts: number;
duration: number | null;
}
Agora Signatureを検証する
Agora Notificationと我々のAPI が安全に通信するには、署名検証が必要不可欠です。
HTTP コールバックが発生する際、Agora から送信されるリクエストには「Agora-Signature」というリクエストヘッダーが含まれています。この署名は、リクエストボディ(実データ)とsecret(Agora 側で事前に共有)を用いて作成されています。
受信側では同じ secret とリクエストボディを用いて署名を再計算し、リクエストヘッダーに含まれる「Agora-Signature」と比較することで、以下の点を検証します。
- リクエストが改竄されていないこと
- 送信元が正当なものであること
これにより、安全な通信が保証されます。
import crypto from 'crypto';
import { env } from './env';
export class SignatureVerifyService {
/**
* calcSignatureV1
* 与えられたペイロードとSecretを基にHMAC/SHA256署名を計算します。
* @param secret - WebhookのSecret
* @param payload - リクエストペイロード
* @returns - 計算された署名
*/
private calcSignatureV1(secret: string, payload: string): string {
const hmac = crypto.createHmac('sha1', secret);
hmac.update(payload, 'utf8');
return hmac.digest('hex');
}
/**
* verify
* リクエストボディのHMAC/SHA256署名が指定された署名と一致するかを検証します。
* @param requestBody - リクエストのペイロード(ボディ)
* @param signature - 検証対象の署名、リクエストヘッダーに存在
* @returns - 検証結果 (true: 一致, false: 不一致)
*/
verify(requestBody: string, signature: string): boolean {
// agoraのWebHook secretは適宣格納する(envファイルや、環境変数)
const calculatedSignature = this.calcSignatureV1(env.agora.webhook.secret, requestBody);
return calculatedSignature === signature;
}
}
イベントをハンドリングする
あとはイベントを確認するだけです。退出時に出力するように処理を書きました。
export class EventHandler {
private validEventCode(eventType: number): boolean {
return eventType == EventType.EventUserLeft;
}
async handleEvent(webhookRequest: WebhookRequest) {
const eventType = webhookRequest.eventType;
try{
if(!this.validEventCode(eventType)) {
console.log('Invalid event code for this server. join and leave is only supported', eventType);
return;
}
console.log('timestamp', webhookRequest.payload.ts);
const dateFromTimeStamp = new Date(webhookRequest.payload.ts * 1000);
const jstDateString = dateFromTimeStamp.toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" });
if(eventType == EventType.EventUserLeft) {
console.log('EventCode', eventType);
console.log('User Left at', jstDateString);
console.log('connectionduration', webhookRequest.payload.duration, 's');
console.log(webhookRequest)
}
} catch (error) {
console.error('Error handling event:', error);
}
}
}
APIとして利用できるようにする
今回はテストということで、仮置きで「/webhook」とします。
import express from 'express';
import http from 'http';
import cors from 'cors';
import { env } from './env';
import { WebhookRequest } from './event_handler';
import { SignatureVerifyService } from './signature_verify_service';
import { EventHandler } from './event_handler';
// 実際にCORSを設定する場合は、リクエストできるOriginの限定は必要です。
const app = express();
app.use(cors());
app.use(express.json());
const server = http.createServer(app);
const PORT = 3000; // Portも環境に応じて変更必要です。
app.get('/', (req, res) => {
res.send('This agora webhook test api');
});
app.post('/webhook', async (req, res) => {
const agoraSignature = req.header('Agora-Signature');
const body = JSON.stringify(req.body);
const signatureVerifyService = new SignatureVerifyService();
if (!signatureVerifyService.verify(body, agoraSignature || '')) {
console.log('Invalid signature');
res.status(401).send('Invalid signature');
return;
}
let webhookRequest: WebhookRequest;
try {
webhookRequest = req.body;
} catch (error) {
res.status(400).send('Invalid JSON');
return;
}
// debug code
console.log("Event code: %d Uid: %d Channel: %s ClientSeq: %d\n",
webhookRequest.eventType, webhookRequest.payload.uid, webhookRequest.payload.channelName, webhookRequest.payload.clientSeq)
const eventHandler = new EventHandler();
await eventHandler.handleEvent(webhookRequest);
res.status(200).send('Ok');
});
server.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
Ngrokでテスト公開します。先述したUrlEndPointの設定は忘れないでください。
<プロジェクトのroot>% npm start
<プロジェクトのroot>% ngrok http 3000
退出結果を確認する
通話を実装したアプリで切断処理を行った場合、API側で出力が確認できれば動作しています。
Event code: 108 Uid: 1000000002 Channel: testVC001 ClientSeq: 1739786463280
timestamp 1739786466
EventCode 108
User Left at 2025/2/17 19:01:06
connection duration 3 s
{
noticeId: '1413774833:662946:108',
notifyMs: 1739786467677,
eventType: 108,
sid: '3DBA8534B30F417C8BCE203BD62C9F27',
payload: {
account: 'test_user_001',
clientSeq: 1739786463280,
uid: 1000000002,
ts: 1739786466,
platform: 1,
reason: 1,
duration: 3,
channelName: 'testVC001'
},
productId: 1
}
これにてバックエンド側で、ユーザの退出イベントが検出できました。
また下記の場合でも退出イベントとなることを確認しました。
- Wifi、モバイルネットワークの切断
- アプリのタスクキル
実際のアーキテクチャ図
情報の取得を確認しましたので、こちらを利用してバックエンドを実装していきました。
DBにアクセスして更新できればいいので、こちらはマイクロサービスとして独立化することも可能です。ユーザー退出時のやり取りが以下のようになります。

今回はAgoraのWebhookを使用することで、整合性を保つ形で通話機能の時間制限を実装できました。
時間制御を考慮してアプリに通話機能を取り入れたい方、Webhookを使った連携処理が必要な人の目に留まれば幸いです。
参考文献
Agora Flutter SDK Quick Start
https://docs.agora.io/en/video-calling/get-started/get-started-sdk?platform=flutter
RtcEventHandler
Receive notifications about channel events
https://docs.agora.io/en/video-calling/advanced-features/receive-notifications
カジュアルにお話しませんか?
エンスポーツでは、一緒にはたらく仲間を募集しています。
アプリ開発に携わっていただけるエンジニアはもちろん、広告集客・ライティングが得意なディレクターやマーケターの方も歓迎しています。
ご興味をもっていただけましたら、ぜひ気軽にご連絡ください。

記事を書いた人
Ryoya Fukushima
Engineer