FLUTTER • WEBRTC • STEP-BY-STEP

How to add voice calling to a Flutter app

Build a one-to-one voice call in Flutter with the RTCExpress plugin from pub.dev. In about 10 minutes you will have two phones calling each other, with accept, reject, mute and hang up, and no media servers to run yourself.

Works on the Free planFree projects include 1:1 voice calls with 2,000 voice participant minutes a month, so you can follow this tutorial without a paid plan. Create a free project to get an App ID.

1. What you need

  • Flutter 3.19 or newer (flutter --version).
  • Two Android devices, or one Android phone and an Android emulator. The plugin needs Android 7.0 (API 24) or newer.
  • A free RTCExpress project. Its App ID is in the customer console.

Android is the supported platform for the Flutter plugin today. iOS sources are included but are still experimental and not yet verified on devices.

2. Create an app and install the plugin

Start from a new Flutter project, then add the rtcexpress plugin and permission_handler, which asks the user for microphone access:

flutter create voice_call_demo
cd voice_call_demo
flutter pub add rtcexpress permission_handler

3. Set the Android minimum version

Open android/app/build.gradle.kts (or build.gradle in older projects) and make sure the minimum SDK is at least 24:

defaultConfig {
    minSdk = 24
    // ...keep the other generated values
}

You don't need to edit AndroidManifest.xml. The plugin already declares the internet and microphone permissions; your app only has to ask the user at runtime, which the code below does.

4. Write the call screen

Replace lib/main.dart with this. It is the whole app: one screen that connects, joins a room, and places or answers a call.

import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:rtcexpress/rtcexpress.dart';

// Test values from the console's Token tools, passed with --dart-define.
// In a real app, fetch the token from your own backend. Never ship an App Secret.
const appId = String.fromEnvironment('RTC_APP_ID');
const userId = String.fromEnvironment('RTC_USER_ID');
const peerId = String.fromEnvironment('RTC_PEER_ID');
const token = String.fromEnvironment('RTC_TOKEN');
const roomId = 'voice-test';

void main() => runApp(const MaterialApp(home: CallScreen()));

class CallScreen extends StatefulWidget {
  const CallScreen({super.key});

  @override
  State<CallScreen> createState() => _CallScreenState();
}

class _CallScreenState extends State<CallScreen> {
  final rtc = RTCExpress();
  String status = 'Not connected';
  bool joined = false;
  bool incoming = false;
  bool inCall = false;
  bool muted = false;

  @override
  void initState() {
    super.initState();
    rtc.on('roomJoined', (_) => setState(() {
          joined = true;
          status = 'Joined $roomId';
        }));
    rtc.on('callInvite', (p) => setState(() => incoming = true));
    rtc.on('callState', (p) => setState(() {
          final state = p['state'];
          status = 'Call ${state}';
          inCall = state == 'ringing' || state == 'connecting' || state == 'connected';
          if (state != 'ringing') incoming = false;
          if (!inCall) muted = false;
        }));
    rtc.on('error', (p) => setState(() => status = 'Error: ${p['message']}'));
  }

  Future<void> connect() async {
    if (!await Permission.microphone.request().isGranted) {
      setState(() => status = 'Allow microphone access to make calls');
      return;
    }
    setState(() => status = 'Connecting...');
    await rtc.init(const RTCInitOptions(
      serverUrl: 'https://api.devsquarelabs.com',
      appId: appId,
      userId: userId,
      token: token,
      mediaMode: 'p2p', // 1:1 calls on the Free plan are peer-to-peer
    ));
    await rtc.joinRoom(roomId);
  }

  @override
  void dispose() {
    rtc.destroy();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Voice call: $userId')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Text(status, textAlign: TextAlign.center),
            const SizedBox(height: 24),
            if (!joined)
              FilledButton(onPressed: connect, child: const Text('Connect and join')),
            if (joined && !inCall)
              FilledButton(onPressed: () => rtc.callUser(peerId), child: Text('Call $peerId')),
            if (incoming) ...[
              FilledButton(onPressed: rtc.acceptCall, child: const Text('Accept')),
              OutlinedButton(onPressed: rtc.rejectCall, child: const Text('Reject')),
            ],
            if (inCall && !incoming) ...[
              OutlinedButton(
                onPressed: () {
                  setState(() => muted = !muted);
                  rtc.muteMicrophone(muted);
                },
                child: Text(muted ? 'Unmute' : 'Mute'),
              ),
              FilledButton(onPressed: rtc.endCall, child: const Text('End call')),
            ],
          ],
        ),
      ),
    );
  }
}

How it works

  • init connects to RTCExpress with a short-lived user token. mediaMode: 'p2p' sends audio directly between the two phones, which is how 1:1 calls work on the Free plan.
  • joinRoom asks to join. Membership is confirmed by the roomJoined event, so the Call button only appears after it fires.
  • callUser rings the other user. On their phone, callInvite fires and they can acceptCall or rejectCall.
  • callState reports ringing, connecting, connected, rejected and ended, which drive the buttons.

5. Create two test tokens

Log in to your customer console → Token tools and create two short-lived tokens in the same project and room:

  • User ID alice, room ID voice-test.
  • User ID bob, room ID voice-test.
Use user tokens, never your App SecretTest tokens expire, which is what you want. Your App Secret must never be built into a mobile app, because anyone can extract it from the APK.

6. Run it on two devices

List your connected devices with flutter devices. Then start the app on the first device as Alice:

flutter run -d FIRST_DEVICE_ID --dart-define=RTC_APP_ID=YOUR_APP_ID --dart-define=RTC_USER_ID=alice --dart-define=RTC_PEER_ID=bob --dart-define=RTC_TOKEN=ALICE_TOKEN

In a second terminal, start it on the other device as Bob:

flutter run -d SECOND_DEVICE_ID --dart-define=RTC_APP_ID=YOUR_APP_ID --dart-define=RTC_USER_ID=bob --dart-define=RTC_PEER_ID=alice --dart-define=RTC_TOKEN=BOB_TOKEN
  1. Press Connect and join on both devices and allow microphone access. Wait for Joined voice-test.
  2. On Alice's device, press Call bob.
  3. On Bob's device, press Accept.
  4. Both screens should show Call connected. Talk, then try Mute and End call.

Two phones next to each other will echo; use headphones, or move apart. Want to hear the quality first without building anything? Open the live demo on your phone and laptop.

Troubleshooting

  • Build fails with a minSdk error: set minSdk = 24 as in step 3, then run flutter clean.
  • Error after Connect: check the App ID, that each device uses its own user ID, and that the token hasn't expired. Create new tokens if in doubt.
  • Call button never appears: both tokens must be for the same room, voice-test.
  • No incoming call: RTC_PEER_ID must exactly match the other device's user ID, and that device must be connected.
  • Connected but no sound: check microphone permission in Android settings and the media volume. Emulators need the host microphone enabled in their extended controls.

7. Before you ship

Replace the --dart-define token with a call to your own backend. Your server signs the user in, checks they may join the room, and requests a short-lived token from POST https://api.devsquarelabs.com/v1/token with your App ID and App Secret. The App Secret stays on the server.

Then test on real devices across Wi-Fi and mobile data, with denied permissions, rejected calls, expired tokens and an app in the background. This tutorial shows foreground 1:1 voice calling; ringing a phone while the app is closed needs push notifications.

Flutter voice calling questions

Do I need to run my own WebRTC, TURN or signaling server?

No. RTCExpress hosts signaling, TURN relays and media servers. The Flutter plugin connects to them with the token your backend issues.

Can I add video to the same app?

Yes. Call rtc.callUser(peerId, video: true) and show the streams with the RTCVideoView widget. Video calling is included in the Pro plan.

How are voice minutes counted?

In participant minutes: a two-person call lasting ten minutes uses twenty. The Free plan includes 2,000 a month and Starter includes 30,000. See pricing.