sautikit
Real phone calls from a Flutter app, over SautiKit's voice network. One package for Android, iOS, web, macOS and Windows.
// `credentials` is called on every connect AND every reconnect. Point it at
// your server's token endpoint — do not close over a token you fetched once.
final phone = SautikitPhone(credentials: () async {
final r = await http.post(Uri.parse('$myServer/voice-token'));
return SautikitCredentials.fromMint(jsonDecode(r.body));
});
phone.events.listen((e) {
switch (e) {
case Registered(): setState(() => ready = true);
case Ringing(): setState(() => status = 'Ringing…');
case Answered(): setState(() => status = 'Connected');
case Hangup(:final reason): setState(() => status = reason ?? 'Ended');
case ErrorEvent(:final error): debugPrint('$error');
default: break;
}
});
await phone.connect();
await phone.call('+254700000001');
Getting a token
Your server mints it and hands it to the app:
POST https://api.sautikit.com/v1/webrtc/token
Authorization: Bearer <your workspace API key>
{ "phone_number": "+254700000001" }
The response carries the token, the gateway endpoint and TURN servers.
SautikitCredentials.fromMint reads that shape directly, so you can hand it
the decoded JSON.
Fetch it again on every reconnect. Tokens expire, and a reconnect is exactly when the old one is most likely to have. That is why the SDK takes a function rather than a string: a phone holding one credential for the life of the app comes back from a dropped socket with a dead token and retries until its budget runs out, reporting a network fault for what is really an expiry.
For a spike, SautikitPhone.withToken(token: …) takes a fixed string. It
stops working when that token expires, which is the honest trade and why it
is not the main constructor.
The SDK cannot mint its own token, on purpose. Minting needs a workspace API key, and a key shipped inside an app is a key anybody can unzip out of it. The token is short-lived and scoped to one of your numbers, which is the caller ID the person you ring will see.
Permissions
Android — android/app/src/main/AndroidManifest.xml:
<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 — ios/Runner/Info.plist:
<key>NSMicrophoneUsageDescription</key>
<string>Needed to place calls.</string>
Web — served over HTTPS (or localhost). Browsers refuse the microphone
on plain HTTP.
What you get
connect() |
open the socket and register |
call(destination) |
ring a number in E.164 |
accept() / reject() |
answer or refuse an inbound call |
hangup() |
end the call in progress |
setMuted(bool) |
silence this end, keep the call |
sendDigits('1') |
DTMF, for menus and PINs |
events |
a Stream of everything above |
dispose() |
hang up, close the socket, release the microphone |
dispose() is not optional. A live peer connection holds the microphone, and
on a phone that leaves a recording indicator showing long after the person
thinks they have finished.
Two things worth knowing
call() completes when the invitation is away, not when somebody answers.
Wait for Ringing then Answered. A future that completed on answer would
leave your caller watching a spinner through the whole ring.
Pass the TURN servers from the token mint. Without them a call works on the same network as the gateway and fails behind most mobile carriers — which is to say, it works in your office and not for your customers.
Licence
MIT
Libraries
- sautikit
- Real phone calls from a Flutter app, over SautiKit's voice network.