Skip to main content
RapidDev - Software Development Agency
flutterflow-tutorials

How to Implement a Video Call and Messaging System in FlutterFlow

Add video calling to FlutterFlow using Agora.io's Flutter SDK as a Custom Widget — Agora handles WebRTC signaling, TURN servers, and codec negotiation. Use Firestore subcollections for real-time chat messages. Generate Agora channel tokens server-side in a Cloud Function — never on the client, as this exposes your App Certificate. Display a split-view layout with video at the top and chat at the bottom using a Column widget.

What you'll learn

  • How to integrate Agora.io as a Custom Widget for WebRTC video calling in FlutterFlow
  • How to implement real-time chat using Firestore subcollections alongside video
  • How to generate Agora channel tokens securely using a Cloud Function
  • How to build a split-view layout combining video and chat in one screen
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner11 min read60-90 minFlutterFlow Pro+ (code export required for Agora SDK Custom Widget)March 2026RapidDev Engineering Team
TL;DR

Add video calling to FlutterFlow using Agora.io's Flutter SDK as a Custom Widget — Agora handles WebRTC signaling, TURN servers, and codec negotiation. Use Firestore subcollections for real-time chat messages. Generate Agora channel tokens server-side in a Cloud Function — never on the client, as this exposes your App Certificate. Display a split-view layout with video at the top and chat at the bottom using a Column widget.

Video Calling with Agora and Chat with Firestore

WebRTC video calling requires peer-to-peer connection negotiation (signaling), relay servers (TURN) for NAT traversal, and media encoding — none of which FlutterFlow's visual builder handles natively. Agora.io solves all of this with a managed SDK that takes a channel name and token, handles the rest, and renders video directly in a Flutter widget. Alongside video, real-time messaging uses Firestore's real-time capabilities — messages are written to a subcollection and immediately delivered to all participants via a Backend Query listener. This tutorial builds a working video call screen with in-call chat.

Prerequisites

  • A FlutterFlow Pro account with code export enabled
  • An Agora.io account with an App ID and App Certificate created
  • A Firebase project with Firestore, Authentication, and Cloud Functions configured
  • Basic familiarity with FlutterFlow Custom Widgets and Custom Actions

Step-by-step guide

1

Set up Agora project and add the SDK after code export

Sign up at agora.io and create a project. Note your App ID (shown in the project dashboard) and App Certificate (in Project Settings → Basic Info — keep this secret). Export your FlutterFlow project to a local Flutter codebase. In pubspec.yaml, add agora_rtc_engine (version ^6.3.2) and permission_handler (version ^11.3.1). Run flutter pub get. For iOS, open ios/Runner/Info.plist and add usage descriptions for NSCameraUsageDescription and NSMicrophoneUsageDescription. For Android, add CAMERA, RECORD_AUDIO, INTERNET, and MODIFY_AUDIO_SETTINGS permissions to AndroidManifest.xml. Store your Agora App ID in your app's constants file — never hardcode the App Certificate client-side.

pubspec.yaml
1# pubspec.yaml additions
2dependencies:
3 agora_rtc_engine: ^6.3.2
4 permission_handler: ^11.3.1
5
6# ios/Runner/Info.plist additions:
7# <key>NSCameraUsageDescription</key>
8# <string>Video calling requires camera access</string>
9# <key>NSMicrophoneUsageDescription</key>
10# <string>Video calling requires microphone access</string>
11
12# android/app/src/main/AndroidManifest.xml additions:
13# <uses-permission android:name="android.permission.CAMERA" />
14# <uses-permission android:name="android.permission.RECORD_AUDIO" />
15# <uses-permission android:name="android.permission.INTERNET" />
16# <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

Expected result: flutter pub get completes. Build the app on a real device — the permissions dialogs appear when Agora requests camera/mic access for the first time.

2

Deploy a Cloud Function to generate Agora channel tokens

Agora tokens are time-limited credentials that authorize a user to join a specific channel. They are generated using your App Certificate combined with the user's UID and channel name. The App Certificate must never leave the server — if exposed, anyone can join any channel in your Agora project. Create a Cloud Function called 'getAgoraToken' that accepts channel_name and user_uid as parameters, generates a token with a 1-hour expiry using the agora-access-token npm package, and returns the token string. In FlutterFlow, add this as an API Call pointing to the Cloud Function. Call it before joining any video channel.

functions/getAgoraToken.js
1// functions/getAgoraToken.js
2const functions = require('firebase-functions');
3const { RtcTokenBuilder, RtcRole } = require('agora-access-token');
4
5exports.getAgoraToken = functions.https.onCall(async (data, context) => {
6 if (!context.auth) {
7 throw new functions.https.HttpsError('unauthenticated', 'Must be signed in');
8 }
9 const { channel_name } = data;
10 if (!channel_name) {
11 throw new functions.https.HttpsError('invalid-argument', 'channel_name required');
12 }
13 const appId = functions.config().agora.app_id;
14 const appCertificate = functions.config().agora.app_certificate;
15 const uid = 0; // 0 = auto-assign UID
16 const role = RtcRole.PUBLISHER;
17 const expireTimeSeconds = 3600; // 1 hour
18 const currentTime = Math.floor(Date.now() / 1000);
19 const privilegeExpireTime = currentTime + expireTimeSeconds;
20 const token = RtcTokenBuilder.buildTokenWithUid(
21 appId, appCertificate, channel_name, uid, role, privilegeExpireTime
22 );
23 return { token, channel_name, app_id: appId };
24});

Expected result: Calling the Cloud Function with a channel name returns a token string. Test from the Firebase console — the token should be a ~200-character alphanumeric string.

3

Create the Agora Video Call Custom Widget

Create a Custom Widget called 'AgoraVideoCallWidget'. It accepts four parameters: appId (String), token (String), channelName (String), and uid (Integer). In the widget's build method, initialize the Agora RtcEngine with the appId, enable video, set a video encoder configuration (640x360 at 15fps for a balanced quality-bandwidth tradeoff), and call joinChannel with the token and channelName. Use a Stack to layer the remote user's video (AgoraVideoView with a RemoteVideoViewController) on the full background, and the local user's camera preview (AgoraVideoView with a LocalVideoViewController) as a small draggable picture-in-picture in the top-right corner. Add mute and camera toggle buttons at the bottom of the Stack, and a Leave Call button that calls engine.leaveChannel() and triggers a callback parameter to navigate back.

lib/custom_widgets/agora_video_call_widget.dart
1// lib/custom_widgets/agora_video_call_widget.dart (partial)
2import 'package:agora_rtc_engine/agora_rtc_engine.dart';
3import 'package:flutter/material.dart';
4import 'package:permission_handler/permission_handler.dart';
5
6class AgoraVideoCallWidget extends StatefulWidget {
7 final String appId;
8 final String token;
9 final String channelName;
10 final VoidCallback onCallEnded;
11
12 const AgoraVideoCallWidget({
13 super.key, required this.appId, required this.token,
14 required this.channelName, required this.onCallEnded,
15 });
16
17 @override State<AgoraVideoCallWidget> createState() => _AgoraVideoCallWidgetState();
18}
19
20class _AgoraVideoCallWidgetState extends State<AgoraVideoCallWidget> {
21 late RtcEngine _engine;
22 int? _remoteUid;
23 bool _muted = false;
24 bool _cameraOff = false;
25
26 @override void initState() {
27 super.initState();
28 _initAgora();
29 }
30
31 Future<void> _initAgora() async {
32 await [Permission.camera, Permission.microphone].request();
33 _engine = createAgoraRtcEngine();
34 await _engine.initialize(RtcEngineContext(appId: widget.appId,
35 channelProfile: ChannelProfileType.channelProfileCommunication));
36 _engine.registerEventHandler(RtcEngineEventHandler(
37 onUserJoined: (conn, remoteUid, elapsed) =>
38 setState(() => _remoteUid = remoteUid),
39 onUserOffline: (conn, remoteUid, reason) =>
40 setState(() => _remoteUid = null),
41 ));
42 await _engine.enableVideo();
43 await _engine.startPreview();
44 await _engine.joinChannel(
45 token: widget.token, channelId: widget.channelName,
46 uid: 0, options: const ChannelMediaOptions());
47 }
48
49 @override void dispose() {
50 _engine.leaveChannel();
51 _engine.release();
52 super.dispose();
53 }
54
55 @override Widget build(BuildContext context) => _buildCallUI();
56
57 Widget _buildCallUI() => Stack(children: [
58 // Remote video fills the background
59 _remoteUid != null
60 ? AgoraVideoView(controller: VideoViewController.remote(
61 rtcEngine: _engine, canvas: VideoCanvas(uid: _remoteUid!),
62 connection: RtcConnection(channelId: widget.channelName)))
63 : const Center(child: Text('Waiting for other participant...',
64 style: TextStyle(color: Colors.white))),
65 // Local preview in top-right corner
66 Positioned(top: 20, right: 20, width: 100, height: 140,
67 child: AgoraVideoView(controller: VideoViewController(
68 rtcEngine: _engine, canvas: const VideoCanvas(uid: 0)))),
69 // Call controls
70 Positioned(bottom: 40, left: 0, right: 0,
71 child: Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
72 IconButton(icon: Icon(_muted ? Icons.mic_off : Icons.mic, color: Colors.white),
73 onPressed: () { setState(() => _muted = !_muted); _engine.muteLocalAudioStream(_muted); }),
74 FloatingActionButton(backgroundColor: Colors.red,
75 onPressed: () async { await _engine.leaveChannel(); widget.onCallEnded(); },
76 child: const Icon(Icons.call_end)),
77 IconButton(icon: Icon(_cameraOff ? Icons.videocam_off : Icons.videocam, color: Colors.white),
78 onPressed: () { setState(() => _cameraOff = !_cameraOff); _engine.muteLocalVideoStream(_cameraOff); }),
79 ])),
80 ]);
81}

Expected result: The Custom Widget renders video from the local camera. When a second device joins the same channel with the same token, both remote and local video streams appear.

4

Build the Firestore chat subcollection for in-call messaging

In Firestore, create a 'calls' collection. Each document represents an active call with fields: channel_name, participants (Array of UIDs), created_at. Under each call document, create a subcollection called 'messages' with fields: sender_uid, sender_name, text, sent_at (Timestamp), and read (Boolean). In FlutterFlow, create a chat message Component with the sender name, message text, and timestamp. On your call page, add a Backend Query on calls/{channel_name}/messages ordered by sent_at ascending with real-time enabled. Bind the messages to a ListView. Add a TextField and Send button at the bottom — the Send action creates a new document in the messages subcollection with the current user's UID and the typed text.

Expected result: Both call participants can send and receive text messages that appear in real time during the call. Messages persist after the call ends for review.

5

Design the split-view call and chat layout

Create a 'VideoCallPage' in FlutterFlow with a dark background. The page layout is a Column with three sections: First, a Flexible widget (flex: 3) containing the AgoraVideoCallWidget Custom Widget — this takes 3/5 of the screen height. Second, a thin horizontal divider. Third, a Flexible widget (flex: 2) containing the chat panel — a Column with an Expanded ListView of message bubbles at the top and a fixed-height Row with a TextField and send button at the bottom. This flex-based split keeps the video prominent while making chat accessible without leaving the call. Add a toggle icon button that collapses the chat section to give video more space for users who want full-screen video.

Expected result: The call screen shows video in the top portion and scrollable chat below. The video does not compress when the keyboard opens. Participants can type messages while watching video.

Complete working example

functions/callManagement.js
1// callManagement.js — Cloud Functions for Agora token generation
2// and call session lifecycle management
3
4const functions = require('firebase-functions');
5const admin = require('firebase-admin');
6const { RtcTokenBuilder, RtcRole } = require('agora-access-token');
7
8if (!admin.apps.length) admin.initializeApp();
9const db = admin.firestore();
10
11// Generate Agora token for joining a video channel
12exports.getAgoraToken = functions.https.onCall(async (data, context) => {
13 if (!context.auth) {
14 throw new functions.https.HttpsError('unauthenticated', 'Sign in required');
15 }
16 const { channel_name } = data;
17 if (!channel_name) {
18 throw new functions.https.HttpsError('invalid-argument', 'channel_name is required');
19 }
20 const appId = functions.config().agora?.app_id;
21 const appCertificate = functions.config().agora?.app_certificate;
22 if (!appId || !appCertificate) {
23 throw new functions.https.HttpsError('failed-precondition', 'Agora config missing');
24 }
25 const expireTime = Math.floor(Date.now() / 1000) + 3600;
26 const token = RtcTokenBuilder.buildTokenWithUid(
27 appId, appCertificate, channel_name, 0,
28 RtcRole.PUBLISHER, expireTime
29 );
30 // Log the call session in Firestore
31 await db.collection('calls').doc(channel_name).set({
32 channel_name,
33 initiator_uid: context.auth.uid,
34 participants: admin.firestore.FieldValue.arrayUnion(context.auth.uid),
35 status: 'active',
36 created_at: admin.firestore.FieldValue.serverTimestamp(),
37 }, { merge: true });
38 return { token, app_id: appId, channel_name };
39});
40
41// End a call session — clean up and record duration
42exports.endCall = functions.https.onCall(async (data, context) => {
43 if (!context.auth) {
44 throw new functions.https.HttpsError('unauthenticated', 'Sign in required');
45 }
46 const { channel_name } = data;
47 if (!channel_name) return { success: false };
48 const callRef = db.collection('calls').doc(channel_name);
49 const callSnap = await callRef.get();
50 if (!callSnap.exists) return { success: false };
51 const createdAt = callSnap.data().created_at?.toMillis() || Date.now();
52 const durationSeconds = Math.round((Date.now() - createdAt) / 1000);
53 await callRef.update({
54 status: 'ended',
55 ended_at: admin.firestore.FieldValue.serverTimestamp(),
56 duration_seconds: durationSeconds,
57 });
58 return { success: true, duration_seconds: durationSeconds };
59});
60
61// Scheduled cleanup: mark calls as ended if inactive for > 2 hours
62exports.cleanupStaleCalls = functions.pubsub
63 .schedule('every 30 minutes').onRun(async () => {
64 const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
65 const snap = await db.collection('calls')
66 .where('status', '==', 'active')
67 .where('created_at', '<', twoHoursAgo)
68 .get();
69 const batch = db.batch();
70 snap.docs.forEach(d => batch.update(d.ref, { status: 'abandoned' }));
71 await batch.commit();
72 console.log(`Cleaned up ${snap.size} stale calls`);
73 return null;
74 });

Common mistakes when implementing a Video Call and Messaging System in FlutterFlow

Why it's a problem: Generating Agora channel tokens on the client side using the App Certificate

How to avoid: Generate tokens exclusively in a Firebase Cloud Function that reads the App Certificate from Firebase Functions config (server-side environment variables). The client calls the function and receives only the time-limited token.

Why it's a problem: Not requesting camera and microphone permissions before joining the Agora channel

How to avoid: Use the permission_handler package to explicitly request both permissions before any Agora initialization. Check the PermissionStatus and show an explanation dialog if the user denies — telling them the feature requires camera and microphone access.

Why it's a problem: Building the chat message list with a new Backend Query inside each message Component

How to avoid: Pass the message document data as a Component Parameter from the parent ListView, which is already bound to the single Backend Query result. The message Component only displays what is passed to it — no internal queries needed.

Best practices

  • Always generate Agora tokens server-side — never embed the App Certificate in client code.
  • Set a token expiry appropriate for your use case: 1 hour for typical calls, with logic to request a new token from the Cloud Function if the call is still active near expiry.
  • Use Agora's channel quality callback (onRtcStats) to display a connection quality indicator in the UI so users know if bandwidth is degraded.
  • Store call session metadata (duration, participant UIDs, start time) in Firestore for billing, analytics, and support purposes.
  • Implement a call waiting/ringing state using Firestore document status field: 'ringing' → 'active' → 'ended'. Update this from both clients so both see the correct state.
  • Test video calls on a real device over cellular — emulators and desktop WiFi don't represent real-world audio/video quality issues.
  • Handle the case where a participant's app is killed mid-call: listen to Firestore call document status changes so the remaining participant sees 'Call ended' instead of waiting indefinitely.

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I'm integrating Agora.io video calling into a Flutter app. Write a StatefulWidget called AgoraCallScreen that: initializes the RtcEngine with an App ID, requests camera and microphone permissions using permission_handler, joins a channel using a token and channelName passed as constructor parameters, displays remote video in full-screen and local video as a picture-in-picture overlay, and handles the onUserJoined and onUserOffline Agora callbacks to update the UI. Include a mute button, camera off button, and leave call button.

FlutterFlow Prompt

I'm building a video call feature in FlutterFlow. I have a Custom Widget called AgoraVideoCallWidget that takes appId, token, and channelName as parameters. I also have a Cloud Function API Call named 'getAgoraToken' that returns a token when given a channel_name. Walk me through building the action flow on my 'Start Call' button that: calls the getAgoraToken API, stores the token in a Page State variable, and then shows the AgoraVideoCallWidget on screen.

Frequently asked questions

Does Agora work for group video calls, not just 1-on-1?

Yes. Agora supports up to 1,000 video streams in a single channel. For group calls, display multiple remote UIDs using a GridView of AgoraVideoView widgets, each with a different remoteUid. Manage the list of active participants using the onUserJoined and onUserOffline callbacks.

How much does Agora cost?

Agora offers 10,000 free minutes per month on the free tier, which is generous enough for testing and small apps. Paid usage is approximately $0.0099 per minute per user for standard quality — a 30-minute 1-on-1 call costs about $0.60. Pricing is lower for audio-only calls.

Can I record video calls?

Yes. Agora's Cloud Recording API lets you record calls to cloud storage. Add a 'Start Recording' Cloud Function that calls Agora's recording API. Recordings are stored in Agora's storage (or you can configure an AWS S3/Azure bucket) and accessible via a URL you store in the call's Firestore document.

Will this work in FlutterFlow's web preview?

The Agora Flutter SDK supports web builds, but the Custom Widget requires code export to test. The Custom Widget won't render in FlutterFlow's Run Mode. Test video calling by running flutter run from your exported project targeting Chrome (flutter run -d chrome).

How do I handle a poor network connection gracefully?

Agora's SDK fires the onNetworkQuality callback with quality ratings 0-5. Listen to this callback and display a connection quality indicator (e.g., WiFi signal bars) in the call UI. When quality drops below 2, show a 'Poor connection' banner. Agora's Adaptive Bitrate (ABR) automatically reduces video resolution to maintain the call.

Can I add screen sharing to the video call?

Yes on Android (flutter screen capture is supported). On iOS, screen sharing from within the app requires a Broadcast Upload Extension — a separate app target in Xcode. Agora's documentation covers this setup. This is advanced functionality requiring significant post-export customization.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.