Guide · Developer
How to Add Video Calling to a Flutter App
Add a 1:1 or group video call to a Flutter app with voltkit_rtc: token endpoint, join and publish, remote video tiles, mute, quality, reconnects and leaving.
- Author
- VoltKit
- Published
- Reading time
- 9 min read
This guide adds a video call to a Flutter app for Android and iOS with voltkit_rtc, the Flutter package of VoltKit RTC: a token endpoint on your backend, joining and publishing, remote video tiles, mute and camera controls, video quality, reconnects and leaving. The same code serves a 1:1 call and a group call. Every snippet comes from the Flutter SDK guide, which has the full reference.
Before you start
You need a VoltKit account and an RTC project; the quickstart creates both and gives you the API base URL and secret. Then add the package, with flutter_webrtc for the video views:
flutter pub add voltkit_rtc flutter_webrtc
import 'package:flutter_webrtc/flutter_webrtc.dart'; // RTCVideoRenderer, RTCVideoView
import 'package:voltkit_rtc/voltkit_rtc.dart';
The package runs on Android 6.0 (minSdk 23) and later and on iOS 13 and later, with Dart 3 and Flutter 3.10 or newer. Flutter Web is not supported, because the package uses dart:io; in a browser, use the JavaScript SDK.
Declare the camera and microphone. Android, in android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
iOS, in ios/Runner/Info.plist:
<key>NSCameraUsageDescription</key>
<string>Your camera is shown to the room while you are live.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Your microphone is heard by the room while you are live.</string>
The system asks the user the first time publish() opens the camera and microphone; someone who only watches is never asked.
Step 1: the token endpoint
The app never holds your API secret. Your backend makes two REST calls with it: PUT /v1/rooms/{roomId} creates the room or returns the live one, and POST /v1/rooms/{roomId}/tokens mints a token for one user with the role you decide. Put both behind your own login. In Node.js with Express 5 and the server SDK:
const express = require("express");
const { StreamClient } = require("@voltkit-rtc/server");
const rtc = new StreamClient({ baseUrl: process.env.VOLTKIT_RTC_URL, apiKey: process.env.VOLTKIT_RTC_SECRET });
const app = express();
app.use(express.json());
// POST /calls/:callId/token (your auth middleware sets req.user)
app.post("/calls/:callId/token", requireUser, async (req, res) => {
const call = await db.calls.find(req.params.callId);
if (!call || ![call.callerId, call.calleeId].includes(req.user.id)) return res.sendStatus(404);
await rtc.ensureRoom({ roomId: call.id, hostIdentity: call.callerId });
const role = req.user.id === call.callerId ? "host" : "cohost";
const { token, wsUrl, expiresAt } = await rtc.createToken({ roomId: call.id, identity: req.user.id, role });
res.set("Cache-Control", "no-store").json({ token, wsUrl, expiresAt });
});
The caller gets host and the callee cohost, so both can publish, and anyone outside the call gets a 404. How tokens and roles work is on Authentication; the same endpoint in Spring Boot, Python, Go and PHP is in build a video call token server, and every option is on issue tokens from your backend.
Step 2: fetch the credentials in Dart
With the http package, ask your endpoint for a token right before joining, because a token lives ten minutes:
import 'dart:convert';
import 'package:http/http.dart' as http;
// Your own endpoint: it checks who the user is and returns { token, wsUrl }.
Future<({String token, String wsUrl})> fetchRoomCredentials(String roomId) async {
final res = await http.post(
Uri.parse('https://api.example.com/rooms/$roomId/token'),
headers: {'Authorization': 'Bearer ${myAuth.accessToken}'}, // your app's login, not the API secret
);
if (res.statusCode != 200) throw Exception('token endpoint answered ${res.statusCode}');
final body = jsonDecode(res.body) as Map<String, dynamic>;
return (token: body['token'] as String, wsUrl: body['wsUrl'] as String);
}
Step 3: join and publish
In your call screen's State, make the client, wire the events, then connect:
// Fields: late StreamClient client; final videos = <String, RTCVideoRenderer>{};
final creds = await fetchRoomCredentials(roomId);
client = StreamClient(wsUrl: creds.wsUrl, token: creds.token);
// Wire events before connecting: tracks already being published arrive during connect().
client.onTrack.listen((t) async {
if (t.kind != 'video') return; // remote sound plays without a renderer
final renderer = RTCVideoRenderer();
await renderer.initialize();
renderer.srcObject = t.stream;
setState(() => videos[t.producerId] = renderer); // show each with RTCVideoView(renderer)
});
client.onTrackEnded.listen((e) async {
final renderer = videos.remove(e.producerId);
setState(() {});
await renderer?.dispose();
});
client.onClosed.listen(handleClosed); // why it ended: see Errors
final joined = await client.connect(); // throws RpcError when the join is refused
if (joined.role.canPublish) {
await client.publish(); // camera and microphone
}
Remote sound plays by itself; only video needs a renderer. Roles come from the token: host and cohost publish, viewer watches.
Step 4: the call screen
Show each renderer in an RTCVideoView, in a grid that grows with the call, and your own picture from client.localStream. The controls are one line each:
await client.publish(); // camera and microphone
await client.publish(constraints: {'audio': true, 'video': false}); // microphone only
await client.publishStream(stream); // a stream you captured yourself
preview.srcObject = client.localStream; // your own picture
client.onLocalStreamChanged.listen((s) => preview.srcObject = s); // after a camera switch or a reconnect
await client.setMicEnabled(false); // mute; the room sees it on onTrackMuted
await client.setCamEnabled(false); // camera off, device kept, so switching back is instant
client.micEnabled; // on air now?
await client.unpublish(); // stop publishing and release the devices
Show onTrackMuted on everyone else's tile: a paused camera stops sending frames, and its tile would otherwise show the last one, frozen. To leave, call await client.close() and dispose your renderers. A closed client stays closed: make a new StreamClient to join again.
Step 5: video quality and busy phones
Without quality, the highest the project allows is sent, which is 1080p unless an administrator restricted it. Ask for a quality and frame rate when you publish:
await client.publish(video: const VideoPublishOptions(
quality: VideoQuality.p1080,
frameRate: 30, // default; 60 is opt-in
));
30 fps is the default. 60 fps uses roughly 1.5 times the bitrate and more battery, and many front cameras capture only 30 fps. Then tell the SDK how big each remote tile is, so it receives the smallest copy that fills it and pauses a hidden one:
LayoutBuilder(builder: (context, box) {
client.setVideoView(
track.producerId,
width: box.maxWidth,
height: box.maxHeight,
pixelRatio: MediaQuery.devicePixelRatioOf(context),
visible: isOnScreen, // false while scrolled away or behind another page
);
return RTCVideoView(renderer, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover);
});
Video up to 1080p at 30 fps is one rate, with HD audio included; the 60 fps upgrades are charged only while 60 fps is actually delivered. Video quality has the full ladder and what it means for data use.
Step 6: reconnects and errors
The SDK reconnects by itself: the waits grow from half a second to 10 seconds, and after 8 failed attempts in a row (the default) the session ends with gaveUp. While connectionState is reconnecting, show a banner and keep the call screen; it is not the end of the call. When the session does end, onClosed says why:
void handleClosed(ClosedReason reason) {
final text = switch (reason) {
ClosedReason.left => null, // you called close()
ClosedReason.replaced => 'You joined this room from another device.',
ClosedReason.kicked => 'You were removed from this room.',
ClosedReason.roomClosed => 'This room has ended.',
ClosedReason.gaveUp => 'Connection lost.',
};
if (text != null) showEnded(text, offerRejoin: reason == ClosedReason.gaveUp);
}
When connect() itself fails, switch on the RpcError code: bad-token means fetch a new token and try once more; no-room or room-ended means the call is over; kicked means this person was removed; participant-quota or viewer-quota means the room is full; quota-exhausted and the daily or monthly limit codes mean the project cannot take calls right now. The Flutter SDK guide shows the join with every failure handled, and Errors lists every code.
Group calls and roles
A group call is the same room with more people. Give everyone who should be on camera host or cohost; give people who only watch viewer, and they receive without publishing. Use a room id per call that nobody can guess, such as a random UUID. Hosts can also mute, remove and promote people from the app; the moderation calls are on Rooms.
flutter_webrtc on its own, Agora, or a managed SDK
- flutter_webrtc on its own gives you the WebRTC engine. You still build the signalling, run media servers for group calls and relay servers for strict networks, and handle reconnection yourself.
- Agora is a managed service priced in US dollars by resolution tier: Video HD at $3.99 and Video Full HD at $8.99 per 1,000 minutes, as published on 2026-10-24 (agora.io).
- VoltKit RTC is a managed service priced in rupees per participant-minute by the media each person uses, with one rate for video up to 1080p at 30 fps. The comparison with Agora and other video APIs lists each vendor's list prices with their sources.
What a 1:1 call costs
Two people on video for an hour is 120 participant-minutes:
2 people × 60 minutes = 120 participant-minutes
120 participant-minutes × 99 ÷ 1,000 = 11.88 credits, ₹11.88 (≈ $0.13) at today's rate
Full HD Video + HD Audio: 99 credits per 1,000 participant-minutes. Billed per second, so a 7½-minute call costs 7½ minutes.
Prices are in Indian rupees; dollar figures are approximate, at ₹95 per US dollar on 24 October 2026, for orientation only. You are billed in rupees.
It is billed per second from your prepaid wallet, and tokens and API calls cost nothing. Work out other sessions with the participant-minute calculator.
iOS
Flutter apps run on iOS with voltkit_rtc: add the two Info.plist keys above and the same code works. There is no separately released native Swift SDK; it is in preview, with early access by email, as the iOS preview notes say.
Check a token while you develop
In code, treat the token as opaque and read its expiry from expiresAt in your endpoint's reply. While you develop, the JWT Decoder shows a test token's expiry on your own device and never sends it anywhere:
Use test tokens only: a token is a credential for a room, and production tokens belong nowhere but the app that asked for them.
Frequently asked questions
How do I add video calling to a Flutter app?
Add voltkit_rtc and flutter_webrtc, mint a token on your backend, then in the app create a StreamClient with the token and wsUrl, listen for onTrack, call connect() and publish(), and show each remote video in an RTCVideoView.
Does the Flutter SDK work on iOS?
Yes. voltkit_rtc runs on Android 6.0 and later and on iOS 13 and later, with Dart 3 and Flutter 3.10 or newer. The native iOS (Swift) SDK is a separate preview and not released yet.
Where does the token come from?
From your backend. It calls the VoltKit RTC API with your API secret and returns token and wsUrl to the app. The app sends your own login to your backend, never the secret.
Can I start audio-only and add video later?
Yes. publish(constraints: {'audio': true, 'video': false}) publishes the microphone alone, and a later publish() adds the camera. Each participant is billed at the rate of the media actually used.
How long does a token live?
Ten minutes by default, so ask for one right before joining. While the app is connected, the service sends it a fresh token before the old one lapses, so a call can last for hours.
What happens when the network drops?
The SDK reconnects with backoff and publishes your camera and microphone again, and connectionState reads reconnecting meanwhile. If it cannot reconnect after eight attempts by default, onClosed fires with gaveUp: offer to rejoin with a new token.
How is a 1:1 video call billed?
Per participant-second at the rate of the media used: two people on video for an hour is 120 participant-minutes at the video rate, which already includes HD audio. API calls and tokens are not billed.