This tutorial teaches you how to build a Zoom/WhatsApp-style video calling app with Flutter.
- All calls run on Stream's Global Edge Network for optimal latency & reliability.
- Permissions give you fine-grained control over who can do what.
- Video quality and codecs are automatically optimized.
- Powered by Stream's Video Calling API.
- UI components are fully customizable, as demonstrated in the Flutter UI components overview.
This page works for both humans and AI coding agents: every step is a file operation or a shell command, every step has a verification checkpoint, and the few steps that need a human are explicitly marked.
Choose your path
This tutorial has two paths. In Path A you let your AI agent build the app for you. In Path B you build it yourself, step by step. Both paths end with a working video call - Path A's is shaped by what you ask for, Path B's by the steps below - so pick the one you prefer instead of working through both.
Path A - Let your AI agent build it (recommended)
Install the Stream CLI once, then add the skills. This step is required for Path A, not optional - the skills are what give Claude Code, Cursor, or Codex the Flutter integration patterns and current SDK APIs, so the agent builds against real docs instead of stale training data.
123456789# Install the getstream CLI curl -fsSL https://getstream.io/cli.sh | bash # Install the skills (router, docs, and builder) for your agent. Pick the # target: --universal for Cursor, Codex, and other AGENTS-ecosystem tools, # or --claude for Claude Code. The Flutter pack installs on demand the first # time it's needed, or add it explicitly: getstream skills --universal getstream skills stream-flutter --universal
Then ask your agent:
1234/stream-flutter Build a Flutter video calling app: create and join a call, render the participants, and show the default call controls. Provision credentials with the CLI: create or select my org and app, then mint a token. If you can't mint one, ask me to provide fallback credentials.
Only the first line matters - /stream-flutter Build a Flutter video calling app is enough to get going. The rest just steers the result: name a layout, ask for your own call controls instead of the default ones, add a lobby screen before joining. The skill supports custom controls as well as the defaults, so describe the UI you want rather than the UI the tutorial builds.
If the CLI can't mint credentials, provide the fallback ones from Step 3, Option 2 when the agent asks.
Where you come in. The agent writes the code, provisions the credentials, and edits AndroidManifest.xml and Info.plist for the camera and microphone permissions itself. It hands back to you twice during setup - browser sign-in, and picking which org and app to use during getstream init (new accounts get an organization created in that flow) - and once more at the end, when the app runs on a device and you grant camera and microphone access. Depending on what your agent is allowed to run, it may also ask you to run flutter create or flutter run yourself.
Already have your Flutter project? You can get the login out of the way first: run getstream init from inside the project before you prompt the agent, and it picks up the initialized project from there.
Human checkpoint: build and run the app. An Android emulator is fine - it has a virtual camera - but the iOS Simulator has none, so use a real device there. Grant camera and microphone access when prompted, and you should join the call and see your own video.
Skim Important Building Blocks if you want to understand what the agent wrote before you extend it.
Path B - Build it manually
Choose this path if you'd rather write the code yourself, or want to understand what the SDK is doing. Follow the steps below.
For AI assistants reading this page: append
.mdto any Stream docs URL for a clean Markdown version. A condensed index for the Flutter Video SDK is athttps://getstream.io/video/docs/flutter/llms.txt. Prefer these over parsing HTML.
Important Building Blocks
Four pieces, one mental model:
StreamVideo- the low-level client. Holds the API key, the user, the token, and the websocket connection. Created once, inmain(), and reachable anywhere asStreamVideo.instance.Call- a single call, created withStreamVideo.instance.makeCall(callType:, id:).call.getOrCreate()registers it on the backend, andcall.join()is what actually sets up audio and video.call.state- the observable state of the call: participants, connection status, and more.call.state.valueis the current snapshot andcall.state.valueStreamemits on every change, which is what your widgets read from.StreamCallContainer+StreamCallContent-StreamCallContainerjoins the call and gives you a complete calling screen (incoming, outgoing, active call);StreamCallContentis the active-call surface inside it. Swap parts through the*WidgetBuilderparameters, or drop down toStreamCallParticipant/StreamVideoRendererfor full control.
You'll get a bare call connected first, then render raw video, then swap in the prebuilt calling UI.
Step 1 - Create a New Flutter Project
To begin developing your video calling app, you need to create a new Flutter project. If you do not have Flutter or an IDE configured to work with it, we highly recommend following the Install and Set up an editor steps from the official documentation.
Please make sure you are using the latest version of Flutter from the stable channel:
12flutter channel stable flutter upgrade
Now, open your IDE and start a new Flutter application. For this tutorial, we are choosing to call it 'video_calling_tutorial'. If you are using Android Studio (recommended) make sure to create the project as a Flutter application and keep all default settings.

You can also create a new project using this command:
12flutter create video_calling_tutorial cd video_calling_tutorial
Checkpoint: you have a video_calling_tutorial directory and flutter run builds the Flutter starter app.
Step 2 - Install the SDK and Declare Permissions
The next step is to add Stream Video to your dependencies, to do that just open pubspec.yaml and add it inside the dependencies section.
123456dependencies: flutter: sdk: flutter # Replace ^latest with the current version from pub.dev, for example ^1.4.2 stream_video_flutter: ^latest
Stream has several packages that you can use to integrate video into your application.
In this tutorial, we will use the stream_video_flutter package which contains pre-built UI elements for you to use.
There is also a stream_video_push_notification package for push notifications and an end-to-end call flow (CallKit). This tutorial doesn't need it, so leave it out for now.
You can also use the stream_video package directly if you need direct access to the low-level client - stream_video_flutter re-exports it, so it comes with the dependency above.
Checkpoint: flutter pub get completes and import 'package:stream_video_flutter/stream_video_flutter.dart'; resolves.
Declare Permissions
Before you go ahead, you need to add the required permissions for video calling to your app.
In your AndroidManifest.xml file, add these permissions:
12345678910111213141516<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <uses-permission android:name="android.permission.INTERNET"/> <uses-feature android:name="android.hardware.camera"/> <uses-feature android:name="android.hardware.camera.autofocus"/> <uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/> <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/> ... </manifest>
For the corresponding iOS permissions, open the Info.plist file and add:
123456789101112<key>NSCameraUsageDescription</key> <string>Camera access is needed so other participants can see you</string> <key>NSMicrophoneUsageDescription</key> <string>Microphone access is needed so other participants can hear you</string> <key>UIBackgroundModes</key> <array> <string>audio</string> <string>fetch</string> <string>processing</string> <string>remote-notification</string> <string>voip</string> </array>
stream_video_flutter requires iOS 14.0, and flutter create scaffolds the project at 13.0, so raise the deployment target or the build fails. Set Minimum Deployments to 14.0 in Xcode, or change all three occurrences of IPHONEOS_DEPLOYMENT_TARGET in ios/Runner.xcodeproj/project.pbxproj to 14.0.
Run the App
An Android emulator works here - it has a virtual camera - but the iOS Simulator has none, so use a physical device on iOS to see your own video. The first iOS device build needs a signing team: open ios/Runner.xcworkspace in Xcode and, under Signing & Capabilities, select your Team and change the bundle identifier if the default is taken.
1flutter run
Human checkpoint: the app builds and launches on your device. Agents: run the build, then ask the human to confirm it launched.
Step 3 - Get Your Credentials
The code ahead needs four values: an API key, a user token and a user id in Step 5, plus a call id in Step 6. There are two ways to get them.
Option 1 - Your own Stream app, via the Stream CLI
The getstream CLI provisions all of it in one flow. Run these from the video_calling_tutorial directory you created in Step 1 - the CLI stores project credentials there.
1. Install the CLI (skip if you did this in Path A):
1curl -fsSL https://getstream.io/cli.sh | bash
2. Initialize the project. This one command authenticates you, lets you create or select an organization and app, and writes the project credentials. New to Stream? The login flow creates your organization. Already have an org or an app? It lets you pick them.
1getstream init
Human checkpoint: getstream init opens a browser to authenticate. Agents: run it, then ask the human to finish logging in before continuing. It's required first - token and api fail with "project credentials missing", and open fails with "stream project is not initialized", until it runs.
3. Grab your API key. This opens the app you just selected in the Stream dashboard, where the API key is shown. Paste it into the code in Step 5.
1getstream open
4. Mint a user token for a user in your app (never expiring by default; add a TTL for production-like testing):
12getstream token john_doe getstream token john_doe --ttl 1d
5. Pick a call id. Anything URL-safe works, for example my-first-call. Calls are created the first time somebody calls getOrCreate(), so there is nothing to provision up front.
Checkpoint: you have an API key, a token printed by the CLI, the user id you minted it for, and a call id you chose. All four belong to the same app.
Pasting the API key straight into your source is fine for this tutorial - it's a publishable key, not a secret. The user token is the one to be careful with: in production your backend mints it after login and the app fetches it, rather than shipping a hardcoded one.
Option 2 - Pre-filled tutorial credentials, no account
Want to skip account setup entirely? Every code block below marked with the lock icon is filled in for you with working credentials against Stream's shared tutorial environment. Copy the block as-is and it runs.
To actually run this sample we need a valid user token. The user token is typically generated by your server-side API. When a user logs in to your app you return the user token that gives them access to the call. To make this tutorial easier to follow we'll generate a user token for you:
Here are credentials to try out the app with:
| Property | Value |
|---|---|
| API Key | Waiting for an API key ... |
| Token | Token is generated ... |
| User ID | Loading ... |
| Call ID | Creating random call ID ... |
Swap in your own credentials from Option 1 before building anything real. Tutorial credentials are shared and short lived.
Step 4 - Setup a Starter UI
Start with a basic UI: a home screen with a button, and a placeholder call screen to navigate to. Both get wired up to the SDK in the steps that follow.
Create a new file named lib/call_screen.dart to hold the call screen, then copy the following content into each file, replacing whatever is there (the header of each block names the file):
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true, ), home: const MyHomePage(title: 'Flutter Demo Home Page'), ); } } class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title}); final String title; State<MyHomePage> createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: Text(widget.title), ), body: Center( child: ElevatedButton( child: const Text('Create Call'), onPressed: () async { // Step 6 fills this in. }, ), ), ); } }
123456789101112131415161718192021import 'package:flutter/material.dart'; import 'package:stream_video_flutter/stream_video_flutter.dart'; class CallScreen extends StatefulWidget { final Call call; const CallScreen({ super.key, required this.call, }); State<CallScreen> createState() => _CallScreenState(); } class _CallScreenState extends State<CallScreen> { Widget build(BuildContext context) { return const Placeholder(); } }
In the CallScreen widget, we take the call we create in Step 6 as a parameter. Step 6 is also where main.dart starts importing and navigating to it.
The UI as of the moment is a simple button on the screen:

Checkpoint: the app shows the home screen with a "Create Call" button. The button does nothing yet - Step 6 wires it up.
Step 5 - Setup the Video Client
To start adding the SDK to your app, initialise the Stream Video SDK with a user.
Add the SDK import and rewrite main() in lib/main.dart. Everything below it - MyApp, MyHomePage, _MyHomePageState - stays exactly as you left it in Step 4, so this is a merge, not a whole-file replacement:
123456789101112131415161718import 'package:flutter/material.dart'; import 'package:stream_video_flutter/stream_video_flutter.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); // Right after creation client connects to the backend and authenticates the user. // You can set `options: StreamVideoOptions(autoConnect: false)` if you want to disable auto-connect. StreamVideo( 'REPLACE_WITH_API_KEY', user: User.regular(userId: 'REPLACE_WITH_USER_ID', role: 'admin', name: 'John Doe'), userToken: 'REPLACE_WITH_TOKEN', ); runApp(const MyApp()); } // MyApp, MyHomePage and _MyHomePageState from Step 4 stay unchanged.
Fill in the API key, user id and token from Step 3. They're hard-coded to keep the tutorial short; in production your backend returns the token at sign-in and the API key comes from environment config.
You do not need to cache the client using state management - access the client anywhere using StreamVideo.instance
Your user is now connected.
Checkpoint: the app still builds and launches. StreamVideo.instance.currentUser.id returns the user id you minted the token for - drop it into a Text widget on the home screen if you want to see it, then remove it again.
Step 6 - Create & Join a Call
Now that the dependencies, permissions, and initialisations are set, we can get onto creating a call.
To instantiate a call, you can use the StreamVideo.instance.makeCall() method. You then need to create this
on the backend using the call.getOrCreate() method. That returns a Result, so check it before
going any further. call.join() is then what actually sets up the audio and video connection.
Once this call is created, you can navigate to the call screen you created in Step 4.
First, import that screen at the top of lib/main.dart, next to the two existing imports:
1import 'call_screen.dart';
Then fill in the onPressed you left empty in Step 4. This replaces just the ElevatedButton inside _MyHomePageState.build() - the rest of the file stays as it is:
12345678910111213141516171819202122232425262728293031323334ElevatedButton( child: const Text('Create Call'), onPressed: () async { try { var call = StreamVideo.instance.makeCall( callType: StreamCallType.defaultType(), id: 'REPLACE_WITH_CALL_ID', ); final result = await call.getOrCreate(); if (result.isFailure) { debugPrint('Could not create the call: ${result.getErrorOrNull()}'); return; } // call.join() is what sets up the audio and video connection. await call.join(); if (!context.mounted) return; // Created in Step 4 Navigator.push( context, MaterialPageRoute( builder: (context) => CallScreen(call: call), ), ); } catch (e) { debugPrint('Error joining or creating call: $e'); debugPrint(e.toString()); } }, )
You have created a Stream Video call. Let's set up the call screen UI so that the user can see other users and interact.
When connecting other users, you can use the same process. The call.getOrCreate() method will create
a call if it doesn't exist, and simply return the existing call if it already does.
Before rendering any video, replace the Placeholder in call_screen.dart with the participant count. PartialCallStateBuilder rebuilds only when the value its selector returns changes, so this updates on its own as people come and go:
1234567891011121314151617181920class _CallScreenState extends State<CallScreen> { Widget build(BuildContext context) { return Scaffold( body: Center( child: PartialCallStateBuilder( call: widget.call, selector: (state) => state.callParticipants.length, builder: (context, int count) { return Text( 'Call ${widget.call.id} has $count participants', style: const TextStyle(fontSize: 24), textAlign: TextAlign.center, ); }, ), ), ); } }
Checkpoint: tapping "Create Call" navigates to the call screen and it reads Call <call-id> has 1 participants. Still on the placeholder or stuck at 0? The join never completed - check the console, which usually means the API key and token belong to different apps.
Step 7 - Join a Call From the Web
Let's join the call from your browser to make this a little more interactive.
You don't need a second device to test this. Join the same call from your browser:
- Restart the app and tap "Create Call".
- Click "Join Call" below to open the same call in a browser tab.
- The browser opens a lobby screen first - click Join there too. The count only moves after that second click, and the browser signs you in under a generated guest name rather than the user id in the link.
The app updates to Call <call-id> has 2 participants. Keep that browser tab open for the rest of the tutorial.
Using your own credentials from Option 1? The Join Call button above joins the shared tutorial call, not yours. Run the app on a second device (or simulator) with a different user id and token, and the same call id. Two clients signed in as the same user count as one participant.
Checkpoint: the participant count reads 2.
Step 8 - Rendering Video
Now let's render the actual video instead of a count. StreamVideoRenderer is the low-level widget that displays a single participant's video track, and it's what the higher-level widgets are built on.
Update call_screen.dart to render the remote participant full-screen with your own video floating in the corner:
12345678910111213141516171819202122232425262728293031323334353637383940class _CallScreenState extends State<CallScreen> { Widget build(BuildContext context) { return Scaffold( body: PartialCallStateBuilder( call: widget.call, selector: (state) => state.callParticipants, builder: (context, List<CallParticipantState> participants) { final local = participants.where((p) => p.isLocal).firstOrNull; final remote = participants.where((p) => !p.isLocal).toList(); return Stack( children: [ if (remote.isNotEmpty) StreamVideoRenderer( call: widget.call, participant: remote.first, videoTrackType: SfuTrackType.video, ) else const Center(child: Text('Waiting for another participant...')), if (local != null) Positioned( top: 16, right: 16, width: 120, height: 160, child: StreamVideoRenderer( call: widget.call, participant: local, videoTrackType: SfuTrackType.video, ), ), ], ); }, ), ); } }
isLocal on CallParticipantState is what separates your own participant from everyone else. Video is only requested for participants you actually display, so a 200-participant call showing 10 only pulls 10 streams. This is how software like Zoom and Google Meet make large calls work.
Checkpoint: your own video plays in the floating tile at the top right, and the participant from Step 7 fills the rest of the screen. Black floating tile? Check that you allowed camera access when the device asked; on iOS the Simulator has no camera, so use a real device; on Android check the emulator's AVD has a camera configured rather than none.
Step 9 - Render a Full Video Calling UI
The step above showed the low-level approach. For a production UI you'd also want speaking indicators, network quality, multi-participant layouts, name labels, and a call header and controls. Stream ships all of that as widgets.
In the build() method, we use the StreamCallContainer widget - a widget made by the Stream team to
make it easy to build video calls:
12345678910class _CallScreenState extends State<CallScreen> { Widget build(BuildContext context) { return Scaffold( body: StreamCallContainer( call: widget.call, ), ); } }
And that's... pretty much it.
StreamCallContainer joins the call itself, so it also replaces the call.join() you added in Step 6 - leaving that call in is harmless, because join() returns early when the call is already connected.
Once you navigate to the CallScreen after the button press, this is what you will be greeted with:

Human checkpoint: allow camera and microphone access if the device hasn't already asked - the prompt appears the first time the app joins a call. Agents: build and launch, then ask the human to grant access and confirm both video streams render.
Step 10 - Customize the Calling UI
To customize any aspect of the call screen made previously, you can use the callContentWidgetBuilder parameter
of the StreamCallContainer.
For example, if you want to add your own call controls to the call, you can do it using the callControlsWidgetBuilder. This replaces the bare StreamCallContainer you passed as the Scaffold's body in Step 9 - the rest of call_screen.dart stays as it is:
123456789101112131415161718192021222324252627282930StreamCallContainer( call: widget.call, callContentWidgetBuilder: ( BuildContext context, Call call, ) { return StreamCallContent( call: call, callControlsWidgetBuilder: ( BuildContext context, Call call, ) { return StreamCallControls( options: [ CallControlOption( icon: const Icon(Icons.chat_outlined), onPressed: () { // Open your chat window }, ), FlipCameraOption(call: call), AddReactionOption(call: call), ToggleMicrophoneOption(call: call), ToggleCameraOption(call: call), ], ); }, ); }, ),
When building Whatsapp/Telegram style calling, you need to add incoming and outgoing screens to the app.
The StreamCallContainer also has these screens inbuilt and allows you to change these using the incomingCallWidgetBuilder and
outgoingCallWidgetBuilder.
For more about building these kinds of applications, check out our ringing guide.
Checkpoint: the control bar shows your own set of buttons, including the chat icon, and each one still toggles the right thing.
Verify the whole build
Build to your device and run the full loop:
1flutter run
Tap "Create Call", allow camera and microphone, join from a browser tab, and check both video tiles render. Toggle your mic and watch the indicator update in the browser. Leave the call and confirm you land back on the home screen.
Troubleshooting
Invalid version constraintfromflutter pub get- the^latestplaceholders inpubspec.yamlare not real constraints. Replace them with the current version from pub.dev (Step 2).project credentials missingorstream project is not initialized- the CLI has not been initialized here. Rungetstream initin the project directory first.token is invalid/ auth error - the token was minted for a different app, or it expired. Re-mint withgetstream token <user-id>and confirm the API key matches.- Stuck on the placeholder, or the count stays at 0 -
getOrCreate()orjoin()did not complete. TheResultcheck andtry/catchin Step 6 print the reason withdebugPrint. - Participant count stays at
1- two sessions signed in as the same user count as one participant. Use a different user id in the second session. - No video, only a black tile - the iOS Simulator has no camera, so build to a physical device. On Android, check the emulator's AVD has a camera configured rather than
none. - Camera and mic permission prompts never appear - the usage description keys are missing from
Info.plist, or the permissions are missing fromAndroidManifest.xml(Step 2). The package product 'stream-video-flutter' requires minimum platform version 14.0- the iOS deployment target is still at theflutter createdefault of 13.0. Raise it to 14.0 (Step 2). Recent Flutter versions use Swift Package Manager and generate noPodfile, so the fix lives in the Xcode project, not aPodfile.- iOS build fails to find a pod - only applies if your project still uses CocoaPods (there is an
ios/Podfile). Runpod installfrom theiosfolder, orflutter cleanfollowed byflutter run. On a Swift Package Manager project,flutter cleanand rebuild instead. - iOS device build fails to sign - open
ios/Runner.xcworkspacein Xcode, pick a Team under Signing & Capabilities, and change the bundle identifier if the default is taken.
Recap
Please do let us know if you ran into any issues while building a video calling app with our Flutter SDK. Our team is also happy to review your UI designs and offer recommendations on how to achieve it with Stream.
To recap what we've learned:
- You set up a call: (
var call = StreamVideo.instance.makeCall(callType: StreamCallType.defaultType(), id: 'my-first-call')). - The call type ("default" in the above case) controls which features are enabled and how permissions are set up.
- When you join a call, real-time communication is set up for audio & video calling: (
call.join()). - Published objects in
call.stateandcall.state.value.callParticipantsmake it easy to build your own UI. StreamVideoRendereris the low-level component that renders video.- We've used Stream's Video Calling API, which means calls run on a global edge network of video servers. By being closer to your users, the latency and reliability of calls are better. The Flutter SDK enables you to build in-app video calling, audio rooms, and livestreaming in days.
Find the complete code for this tutorial in the Flutter Video Tutorials Repository.
We hope you've enjoyed this tutorial, and please feel free to reach out if you have any suggestions or questions.
Next steps
- Ringing & CallKit - native incoming-call screens and VoIP push: ringing tutorial
- Chat in a call - the Chat Flutter SDK integrates with video: chat integration guide
- Other call types - audio rooms and livestreaming
- AI voice assistant - connect a realtime voice agent to a call: voice assistant tutorial
Pricing is flexible with multiple tiers, including a free maker plan for hobby projects and small companies.
Final Thoughts
In this video app tutorial, we built a fully functioning Flutter video calling app with our Flutter SDK component library. We also showed how easy it is to customize the behavior and the style of the Flutter video app components with minimal code changes.
Both the video SDK for Flutter and the API have plenty more features available to support more advanced use-cases.
Machine-readable resources
For AI agents and coding assistants working with this SDK:
- CLI + skills:
curl -fsSL https://getstream.io/cli.sh | bash, thengetstream skills --universalandgetstream skills stream-flutter --universalfor the Flutter pack (use--claudeinstead for Claude Code). (Alternative:npx skills add GetStream/agent-skills -s stream.) - Provisioning:
getstream init(auth + create/select org & app) ->getstream open(dashboard, to read the API key) ->getstream token <user-id>(mint a token); pick any URL-safe call id - Data & config from the CLI:
getstream api <Endpoint> --request '{...}'for users, calls, and call members - Flutter integration skill: invoke
/stream-flutterin your agent for Flutter setup patterns;/stream-docssearches live SDK docs with citations - Docs index for LLMs:
https://getstream.io/video/docs/flutter/llms.txt - Markdown endpoints: append
.mdto any docs URL for a clean, token-efficient version - Source of truth for APIs: the SDK repository - check the installed version's source rather than assuming APIs from training data

