---
title: Flutter WebRTC Softphone App Using the sip_ua Package
description: "================================================ Application                          : Softphone Mobile Client Module : [SIP_UA](https://pub.dev/packages/sip_ua) Flutter Integration Document                       : Developer Guide ------------------------------------------------------------------- Version                                    : v1.0.0 Author         : Solusipbx (Developer Team) Created              : July 4, 2026 Last Updated            : July 4,…"
url: "https://solusipbx.id/en/pusat-bantuan/softphone-flutter-webrtc-sip-ua-eng/"
updated: "2026-10-02"
category: Developer
---

# Flutter WebRTC Softphone App Using the sip_ua Package

================================================

Application                          : Softphone Mobile Client

Module : [SIP_UA](https://pub.dev/packages/sip_ua) Flutter Integration

Document                       : Developer Guide

-------------------------------------------------------------------

Version                                    : v1.0.0

Author         : Solusipbx (Developer Team)

Created              : July 4, 2026

Last Updated            : July 4, 2026

===============================================

Below is a **Developer Guide** for building a WebRTC softphone app using the sip_ua package in Flutter, with 3CX as the [Core PBX](https://solusipbx.com). This guide is written so developers can copy, follow, and integrate it directly with a server infrastructure already running on port 443 (Nginx & CoTURN).

**🛠️****Step 1: Dependencies & Device Permissions**

Open your Flutter project's pubspec.yaml file, then add the sip_ua package along with the supporting audio library.

YAML

```
dependencies:
  flutter:
    sdk: flutter
  sip_ua: ^0.7.10 # Use the latest stable version
permission_handler: ^11.3.0 # For handling microphone permission
```

**OS Permission Configuration (Microphone & Internet)**

**Android (android/app/src/main/AndroidManifest.xml)**

Add the following lines inside the <manifest> tag:

XML

```
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
iOS (ios/Runner/Info.plist)
```

Add the following key to officially request microphone access on the Apple ecosystem:

XML

```
<key>NSMicrophoneUsageDescription</key>
<string>This app needs microphone access to make VoIP phone calls.</string>
```

**Step 2: Controller Class Architecture (sip_helper.dart)**

Create a new file named sip_helper.dart. This class wraps all the connection, registration, and call-state handling logic.

Dart

```
import 'package:sip_ua/sip_ua.dart';
import 'package:permission_handler/permission_handler.dart';

class MySipController implements SipUaHelperListener {
  final SIPUAHelper _helper = SIPUAHelper();

  // Callbacks passed through to the UI
  Function(RegistrationState state)? onRegistrationStateChanged;
  Function(Call call, CallState state)? onCallStateChanged;

  MySipController() {
    // Register the main listener
    _helper.addSipUaHelperListener(this);
  }

  /// Note: 'required String authId' is added to the function parameters
  Future<void> connectAndRegister({
    required String extension,
    required String authId,
    required String password,
    required String domain,
    required String wssUrl,
  }) async {
    // 1. Microphone permission must be granted first
    var status = await Permission.microphone.request();
    if (!status.isGranted) {
      print("Microphone permission denied by user.");
      return;
    }

    // 2. Set up the SIP credential configuration
    UASettings settings = UASettings();
    settings.wsUri = wssUrl;
    settings.uri = 'sip:$extension@$domain';
    settings.authId = authId; // Note: this variable is now valid
    settings.password = password;
    settings.displayName = extension;
    settings.transportType = TransportType.WS;

    // 3. Add your CoTURN configuration on port 443
    settings.pcConfig = {
      'iceServers': [
        {'url': 'stun:$domain:443'},
        {
          'url': 'turn:$domain:443',
          'username': 'admin_coturn',
          'credential': 'passwordcoturn' // password set in turnserver.conf
        }
      ],
      'iceTransportPolicy': 'all'
    };

    // 4. Start the registration process in the background
    _helper.start(settings);
  }

  /// Makes an outbound call
  void makeVoiceCall(String targetExtension) {
    _helper.call(targetExtension, voiceonly: true);
  }

  /// Hangs up or rejects a call
  void hangupOrReject(Call call) {
    call.hangup();
  }

  /// Answers an inbound call
  void answerCall(Call call) {
    call.answer(_helper.buildConstraints());
  }

  // ===========================================================================
  // SIPUAHELPERLISTENER IMPLEMENTATION (EVENT HANDLERS)
  // ===========================================================================

  @override
  void registrationStateChanged(RegistrationState state) {
    print('SIP registration status: ${state.state}');
    if (onRegistrationStateChanged != null) {
      onRegistrationStateChanged!(state);
    }
  }

  @override
  void callStateChanged(Call call, CallState state) {
    print('Call status: ${state.state}');
    if (onCallStateChanged != null) {
      onCallStateChanged!(call, state);
    }
  }

  @override
  void transportStateChanged(TransportState state) {
    print('WebSocket connection status: ${state.state}');
  }

  @override
  void onNewMessage(SIPMessageRequest request) {
    print('Received a new SIP text message');
  }

  @override
  void onNewNotify(Notify notify) {
    print('Received a new notification from the PBX');
  }
}
```

**Step 3: Integrating into the App UI (main.dart)**

Here's a simple UI implementation example for registering an extension, placing calls, and receiving inbound calls in real time.

```
import 'package:flutter/material.dart';
import 'package:sip_ua/sip_ua.dart';
import 'sip_helper.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: const SoftphoneScreen(),
    );
  }
}

class SoftphoneScreen extends StatefulWidget {
  const SoftphoneScreen({super.key});
  @override
  State<SoftphoneScreen> createState() => _SoftphoneScreenState();
}

class _SoftphoneScreenState extends State<SoftphoneScreen> {
  final MySipController _sipController = MySipController();
  final TextEditingController _targetController = TextEditingController();

  String _regStatus = "DISCONNECTED";
  Call? _activeCall;
  String _callStatus = "IDLE";

  @override
  void initState() {
    super.initState();

    // Wire the controller's event listeners to the UI state
    _sipController.onRegistrationStateChanged = (RegistrationState state) {
      setState(() {
        _regStatus = state.state == RegistrationStateEnum.REGISTERED
            ? "CONNECTED  ✔️ "
            : state.state.toString().split('.').last;
      });
    };

    _sipController.onCallStateChanged = (Call call, CallState state) {
      setState(() {
        _activeCall = call;
        _callStatus = state.state.toString().split('.').last;
      });
    };

    // Auto-connect when the app opens
    _sipController.connectAndRegister(
      extension: "101",
      authId: "passwordAuthid",
      password: "passwordExtension",
      domain: "pbx.yourdomain.com",
      wssUrl: "wss://rtc.yourdomain.com/yourappname",
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("Alana Hotel Softphone")),
      body: Padding(
        padding: const EdgeInsets.all(20.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text("Extension Status: $_regStatus",
                style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
            const SizedBox(height: 20),
            Text("Call Status: $_callStatus",
                style: TextStyle(fontSize: 16, color: Colors.blue.shade700)),
            const Divider(height: 40),

            if (_activeCall == null || _callStatus == "CALL_INITIATION" || _callStatus == "IDLE") ...[
              TextField(
                controller: _targetController,
                decoration: const InputDecoration(
                  labelText: "Enter the destination extension number",
                  border: OutlineInputBorder(),
                ),
                keyboardType: TextInputType.number,
              ),
              const SizedBox(height: 10),
              ElevatedButton.icon(
                onPressed: () => _sipController.makeVoiceCall(_targetController.text),
                icon: const Icon(Icons.call),
                label: const Text("Call"),
                style: ElevatedButton.styleFrom(backgroundColor: Colors.green, foregroundColor: Colors.white),
              ),
            ],

            if (_activeCall != null && _callStatus == "INCOMING") ...[
              Text("Incoming call from: ${_activeCall!.remote_identity}",
                  style: const TextStyle(fontSize: 16, color: Colors.orange)),
              const SizedBox(height: 15),
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                children: [
                  ElevatedButton(
                    onPressed: () => _sipController.answerCall(_activeCall!),
                    style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
                    child: const Text("Answer"),
                  ),
                  ElevatedButton(
                    onPressed: () => _sipController.hangupOrReject(_activeCall!),
                    style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
                    child: const Text("Reject"),
                  ),
                ],
              )
            ],

            if (_activeCall != null && _callStatus == "CONFIRMED") ...[
              const Icon(Icons.phone_in_talk, size: 60, color: Colors.green),
              const SizedBox(height: 15),
              ElevatedButton.icon(
                onPressed: () => _sipController.hangupOrReject(_activeCall!),
                icon: const Icon(Icons.call_end),
                label: const Text("End Call"),
                style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white),
              )
            ],
          ],
        ),
      ),
    );
  }
}
```

**Important Additional Notes for Developers**

1. **Extension URI Format:** The settings.uri string must use the full SIP URI format: 'sip:USERNAME@DOMAIN'. A typo or a missing sip: prefix will cause the handshake to fail.
2. **Audio Handling:** The sip_ua package coordinates directly with the phone's native WebRTC framework. As long as a call is in the CONFIRMED state, audio streaming between microphone and speaker is handled automatically in real time.
3. **Next Step (Push Notifications):** If you want this app to keep working while the phone is asleep/off, developers simply need to integrate the MySipController class above with the firebase_messaging (FCM) package and flutter_callkit_incoming, so an incoming call event can instantly wake up the Android/iOS operating system.
4. When the phone's operating system goes into power-saving/sleep mode, the WebSocket connection (wss://) that sip_ua maintains to the WebRTC gateway will automatically be dropped by the OS. This add-on section walks developers through integrating **Firebase Cloud Messaging (FCM)** and **Flutter CallKit Incoming** to wake the app remotely whenever there's an incoming call on the 3CX PBX.

What do you need to prepare? Add the following packages below the sip_ua dependency:

```
dependencies:
  # ... existing sip_ua and permission_handler dependencies ...

  # ADD-ON DEPENDENCIES FOR GOOGLE PUSH
  firebase_core: ^3.0.0
  firebase_messaging: ^15.0.0
  flutter_callkit_incoming: ^1.0.0 # Enables the full-screen call UI while asleep
```

**Additional OS Permission Configuration**

**Android (android/app/src/main/AndroidManifest.xml)**

Add these permissions inside the <manifest> tag to support push notifications and unlocking the lock screen:

XML

```
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
```

**iOS (ios/Runner/Info.plist)**

Add the following background capabilities (*Background Modes*):

XML

```
<key>UIBackgroundModes</key>
<array>
  <string>voip</string>
  <string>remote-notification</string>
</array>
```

**Step 2: Background Handler Logic (main.dart)**

Developers must register the background message handler function in main.dart before runApp is called. This function is invoked instantly by Google Play Services even while your app is fully closed.

```
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';

/// Handles incoming push packets while the app is fully closed/asleep
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp();

  // Configure the native OS incoming-call screen (CallKit) parameters
  var params = <String, dynamic>{
    'id': message.data['call_id'] ?? '123',
    'nameCaller': message.data['caller_name'] ?? 'Hotel Call',
    'handle': message.data['caller_extension'] ?? '000',
    'type': 0, // 0 = Voice call (Audio VoIP)
    'duration': 30000, // Ring timeout (30 seconds)
    'android': {
      'isCustomNotification': true,
      'isShowLogo': false,
      'ringtone': 'ringtone_default',
    },
  };

  // Show the full-screen incoming call interface
  await FlutterCallkitIncoming.showCallkitIncoming(params);
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Initialize Firebase Core
  await Firebase.initializeApp();

  // Register Google FCM's official background handler
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

  runApp(const MyApp());
```

**Step 3: Retrieving the Device's FCM Token**

Inside your main UI component, or as soon as the app detects a successful login/registration, the device token must be fetched and sent to your backend webhook server (Creomate).

```
import 'package:firebase_messaging/firebase_messaging.dart';

void getAndSaveFcmToken(String extensionNumber) async {
  FirebaseMessaging messaging = FirebaseMessaging.instance;

  // 1. Interactively ask the user for push notification permission
  NotificationSettings settings = await messaging.requestPermission(
    alert: true, badge: true, sound: true,
  );

  if (settings.authorizationStatus == AuthorizationStatus.authorized) {
    // 2. Get the unique token from Google's server
    String? token = await messaging.getToken();
    print("FCM Token for extension $extensionNumber: $token");

    // 3. TODO: Developer sends an HTTP POST to the Creomate API / internal database
    // to map: Extension 101 = Token XYZ
    // _apiService.saveTokenToServer(extensionNumber, token);
  }
}
```

**Step 4: Syncing the "Answer Call" Action with sip_ua**

When the CallKit screen appears and the user taps the green **"Accept"** button, the app needs to automatically re-initialize the sip_ua WebSocket connection and answer the call. Add this listener inside your main page's initState:

```
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';

void listenToCallKitActions() {
  FlutterCallkitIncoming.onEvent.listen((CallEvent? event) {
    if (event == null) return;

    switch (event.event) {
      case Event.actionCallAccept:
        print("User tapped Answer!");

        // 1. Quickly re-run the WebSocket registration to Janus/3CX
        _sipController.connectAndRegister(
          extension: "101",
          authId: "passwordAuthid",
          password: "passwordExtension",
          domain: "pbx.yourdomain.com",
          wssUrl: "wss://rtc.yourdomain.com/yourappname",
        ).then((_) {
          // 2. Add a short delay/wait for the INCOMING event to fire in sip_ua,
          // then call answerCall() automatically.
        });
        break;

      case Event.actionCallDecline:
        print("User declined the call from the lock screen.");
        // Send a reject signal to the controller if needed
        break;

      default:
        break;
    }
  });
}
```

[3CX PBX System]

      │

      ▼ (Event: Incoming call to a sleeping extension)

[Webhook Manager / Creomate]

      │

      ▼ (HTTP POST looking up the extension's FCM token)

[Google FCM Cloud Services]

      │

      ▼ (High-priority push signal)

[Android / iOS Phone (Sleep State)]

      │

      ├─► Wakes up '_firebaseMessagingBackgroundHandler'

      ├─► Triggers 'FlutterCallkitIncoming.showCallkitIncoming'

      └─► User taps Answer ──► WebSocket reconnects ──► WebRTC audio flows
