Voice and video calls without shipping a secret.
VACT supplies one-to-one WebRTC signalling, managed TURN, incoming-call events, telemetry and authoritative usage billing through one small client API.
vact_live_... App Secret in a client.
Flutter, Android, iOS, web and desktop binaries are inspectable. Keep the
secret in your backend secret manager and send the app only a five-minute,
one-time access token.
Your first call in 5 minutes
This is the shortest complete path: a Node backend that mints a token, and a web page that places a call. Copy the two blocks, fill in your two credentials, and you have a working call. Once it works, pick your own stack below — every step has the same code for Flutter, React, Python, PHP and more.
1. Mint a token from your backend. The App Secret lives only here, never in the app.
// server.js · npm install express
const express = require('express');
const app = express();
app.post('/api/vact-token', async (req, res) => {
const r = await fetch(
'https://vact.online/v1/apps/YOUR_APP_ID/tokens',
{
method: 'POST',
headers: {
Authorization: 'Bearer YOUR_APP_SECRET', // keep this on the server
'Content-Type': 'application/json',
},
// In real code, use YOUR logged-in user's id here.
body: JSON.stringify({ userId: req.query.user || 'alice' }),
},
);
res.json(await r.json());
});
app.listen(3000, () => console.log('http://localhost:3000'));
2. Place a call from a web page. No build step — open it in two
browser tabs, one as alice, one as bob.
<!-- index.html -->
<video id="remote" autoplay playsinline></video>
<video id="local" autoplay playsinline muted></video>
<button id="call">Call bob</button>
<script type="module">
import {VactClient} from 'https://esm.sh/@firstlogicmetalab/client';
const me = new URLSearchParams(location.search).get('me') || 'alice';
const vact = new VactClient('YOUR_APP_ID');
// Ring on incoming calls.
vact.onIncomingCall = async (c) => {
const call = await c.accept();
remote.srcObject = call.remoteStream;
};
// Ask your backend for a one-time token, then connect.
const {accessToken} = await (await fetch('/api/vact-token?user=' + me)).json();
await vact.connect(accessToken);
call.onclick = async () => {
const c = await vact.call('bob', {video: true});
local.srcObject = c.localStream;
remote.srcObject = c.remoteStream;
};
</script>
Open ?me=alice in one tab and ?me=bob in
another, click Call bob, and the two tabs are on a video call. That
is the whole system — everything below is the same three steps with your
framework, plus production concerns like closed-app ringing and billing.
Jump to: backend token · your platform · placing calls · receiving calls · billing · troubleshooting
1. Mint a token on your backend
Your backend—not the app—decides the VACT user ID after checking its normal login, permissions and abuse limits. Pick your stack:
A callable function is the shortest path when your app already uses Firebase Authentication: the caller's identity arrives verified, so you never trust a client-supplied user ID.
const {onCall, HttpsError} = require('firebase-functions/v2/https');
const {defineSecret} = require('firebase-functions/params');
const APP_SECRET = defineSecret('VACT_APP_SECRET');
const APP_ID = 'vact_app_your_public_app_id';
exports.vactToken = onCall({secrets: [APP_SECRET]}, async (request) => {
// Firebase verified this identity; the client cannot forge it.
if (!request.auth) throw new HttpsError('unauthenticated', 'Sign in first');
const response = await fetch(
`https://vact.online/v1/apps/${APP_ID}/tokens`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${APP_SECRET.value()}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
userId: request.auth.uid,
permissions: [
'call:create', 'call:receive', 'call:accept', 'call:end',
'telemetry:write'
],
sessionTtlSeconds: 3600
})
}
);
if (!response.ok) throw new HttpsError('unavailable', 'calling_unavailable');
const token = await response.json();
return {accessToken: token.accessToken, expiresAt: token.tokenExpiresAt};
});
Store the secret once, then deploy:
firebase functions:secrets:set VACT_APP_SECRET
firebase deploy --only functions:vactToken
Calling it from Flutter:
final result = await FirebaseFunctions.instance
.httpsCallable('vactToken')
.call();
await vact.connect(accessToken: result.data['accessToken'] as String);
app.post('/api/vact-token', requireYourLogin, async (req, res) => {
const response = await fetch(
`https://vact.online/v1/apps/${process.env.VACT_APP_ID}/tokens`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VACT_APP_SECRET}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
userId: req.user.id,
permissions: [
'call:create', 'call:receive', 'call:accept', 'call:end',
'telemetry:write'
],
// Optional: allowedCalleeIds: ['user_42'],
sessionTtlSeconds: 3600
})
}
);
if (!response.ok) return res.status(503).json({error: 'calling_unavailable'});
const token = await response.json();
res.json({accessToken: token.accessToken, expiresAt: token.tokenExpiresAt});
});
npm install @firstlogicmetalab/server-sdk
View @firstlogicmetalab/server-sdk on npm →
The server SDK validates arguments before spending a network call and keeps the secret in a private field.
const {VactServer} = require('@firstlogicmetalab/server-sdk');
const vact = new VactServer({
appId: process.env.VACT_APP_ID,
appSecret: process.env.VACT_APP_SECRET
});
app.post('/api/vact-token', requireYourLogin, async (req, res) => {
const token = await vact.createAccessToken({
userId: req.user.id,
sessionTtlSeconds: 3600
// Optional: allowedCalleeIds: ['user_42']
});
res.json({accessToken: token.accessToken, expiresAt: token.tokenExpiresAt});
});
The official SDK keeps the App Secret handling, retries and webhook verification out of your code. It has no dependencies.
pip install vact-server
from vact_server import VactServer
vact = VactServer(
app_id=os.environ["VACT_APP_ID"],
app_secret=os.environ["VACT_APP_SECRET"],
)
# After YOUR login has decided who this is:
token = vact.create_access_token(user_id=current_user.id)
return {"accessToken": token["accessToken"]}
Or call the API directly, with no SDK at all:
import os, requests
from fastapi import FastAPI, Depends, HTTPException
app = FastAPI()
APP_ID = os.environ['VACT_APP_ID']
APP_SECRET = os.environ['VACT_APP_SECRET']
@app.post('/api/vact-token')
def vact_token(user = Depends(require_your_login)):
response = requests.post(
f'https://vact.online/v1/apps/{APP_ID}/tokens',
headers={'Authorization': f'Bearer {APP_SECRET}'},
json={
'userId': user.id,
'permissions': [
'call:create', 'call:receive', 'call:accept', 'call:end',
'telemetry:write',
],
'sessionTtlSeconds': 3600,
},
timeout=10,
)
if not response.ok:
raise HTTPException(status_code=503, detail='calling_unavailable')
token = response.json()
return {'accessToken': token['accessToken'],
'expiresAt': token['tokenExpiresAt']}
The official SDK handles the App Secret, retries and webhook
signature verification. It needs only ext-curl and
ext-json — no Composer dependencies.
composer require firstlogicmetalab/vact-server
View firstlogicmetalab/vact-server on Packagist →
use Vact\VactServer;
$vact = new VactServer(getenv('VACT_APP_ID'), getenv('VACT_APP_SECRET'));
// After YOUR login has decided who this is:
$token = $vact->createAccessToken($currentUser->id);
header('Content-Type: application/json');
echo json_encode(['accessToken' => $token['accessToken']]);
Or call the API directly, with no SDK at all:
<?php
// POST /api/vact-token — call require_your_login() first.
$user = require_your_login();
$payload = json_encode([
'userId' => $user['id'],
'permissions' => ['call:create', 'call:receive', 'call:accept',
'call:end', 'telemetry:write'],
'sessionTtlSeconds' => 3600,
]);
$ch = curl_init('https://vact.online/v1/apps/'
. getenv('VACT_APP_ID') . '/tokens');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . getenv('VACT_APP_SECRET'),
],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) {
http_response_code(503);
echo json_encode(['error' => 'calling_unavailable']);
exit;
}
$token = json_decode($body, true);
echo json_encode([
'accessToken' => $token['accessToken'],
'expiresAt' => $token['tokenExpiresAt'],
]);
Useful for testing. Run it from a trusted machine only — never from a browser or an app.
curl -X POST \
"https://vact.online/v1/apps/$VACT_APP_ID/tokens" \
-H "Authorization: Bearer $VACT_APP_SECRET" \
-H "Content-Type: application/json" \
-d '{
"userId": "user_42",
"permissions": ["call:create","call:receive","call:accept",
"call:end","telemetry:write"],
"sessionTtlSeconds": 3600
}'
# {"accessToken":"vact_at_...","tokenExpiresAt":"...","sessionExpiresAt":"..."}
The grant expires in five minutes and is single-use. Never accept an arbitrary userId from the client and pass it through.
Storing the App Secret
The examples above read the secret from the environment. Put it in a
managed secret store — not in source control, a Dockerfile, a build arg or
a .env file you commit. Pick your host:
Firebase Functions has a built-in secret store backed by Google Secret Manager. Declare the secret in code, set it once, then deploy.
# 1. Store it (prompts for the value; never appears in shell history)
firebase functions:secrets:set VACT_APP_SECRET
# 2. Deploy — the function declares {secrets: [APP_SECRET]}
firebase deploy --only functions:vactToken
# Rotate later: set a new version, then redeploy
firebase functions:secrets:set VACT_APP_SECRET
firebase deploy --only functions:vactToken
Access it with defineSecret('VACT_APP_SECRET').value(),
as in the Firebase tab above. Never use functions.config()
or a plain env var for this value.
For Cloud Run, App Engine or GCE, use Secret Manager and mount the secret as an environment variable at deploy time.
# 1. Create the secret
printf '%s' "$VACT_APP_SECRET" | \
gcloud secrets create VACT_APP_SECRET --data-file=-
# 2. Let the service account read it
gcloud secrets add-iam-policy-binding VACT_APP_SECRET \
--member="serviceAccount:YOUR_SERVICE_ACCOUNT" \
--role="roles/secretmanager.secretAccessor"
# 3. Mount it on the service
gcloud run deploy your-api \
--set-secrets=VACT_APP_SECRET=VACT_APP_SECRET:latest
# Rotate: add a version; :latest picks it up on next deploy
printf '%s' "$NEW_SECRET" | \
gcloud secrets versions add VACT_APP_SECRET --data-file=-
Use Secrets Manager (or Parameter Store) and read it at cold start, not per request — the value is cached for the life of the container.
# 1. Store it
aws secretsmanager create-secret \
--name vact/app-secret \
--secret-string "$VACT_APP_SECRET"
# 2. Grant your Lambda/ECS role read access in its IAM policy:
# "Action": "secretsmanager:GetSecretValue"
# "Resource": "arn:aws:secretsmanager:REGION:ACCOUNT:secret:vact/app-secret-*"
// Read once per container, reuse across invocations.
const {SecretsManagerClient, GetSecretValueCommand} =
require('@aws-sdk/client-secrets-manager');
let cached;
async function appSecret() {
if (cached) return cached;
const client = new SecretsManagerClient({});
const out = await client.send(
new GetSecretValueCommand({SecretId: 'vact/app-secret'}));
cached = out.SecretString;
return cached;
}
Key Vault holds the secret; App Service or Functions reads it through a managed identity, so no credential is stored in app settings.
# 1. Store it
az keyvault secret set \
--vault-name your-vault \
--name VACT-APP-SECRET \
--value "$VACT_APP_SECRET"
# 2. Give the app's managed identity read access
az keyvault set-policy --name your-vault \
--object-id YOUR_APP_IDENTITY_OBJECT_ID \
--secret-permissions get list
# 3. Reference it from app settings
az webapp config appsettings set --name your-app --resource-group your-rg \
--settings VACT_APP_SECRET="@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/VACT-APP-SECRET/)"
Both platforms encrypt environment variables at rest. Scope the variable to server-side use only.
# Vercel — do NOT prefix with NEXT_PUBLIC_ or it ships to the browser
vercel env add VACT_APP_SECRET production
# Netlify
netlify env:set VACT_APP_SECRET "your-secret" --context production
NEXT_PUBLIC_, VITE_,
REACT_APP_ or PUBLIC_ is compiled into the
browser bundle. The App Secret must never carry one of those
prefixes, and the endpoint that uses it must be a server route.
Inject the secret at run time; never bake it into an image layer, and
never pass it as a --build-arg (build args are visible in
image history).
# Docker Compose — file stays out of source control
services:
api:
image: your-api:latest
environment:
VACT_APP_SECRET: ${VACT_APP_SECRET} # from the host env or an
# untracked .env file
# Docker Swarm / Kubernetes — prefer a real secret object
kubectl create secret generic vact \
--from-literal=app-secret="$VACT_APP_SECRET"
# systemd on a plain VM: root-only environment file
sudo install -m 600 /dev/null /etc/your-api.env
sudo sh -c 'echo VACT_APP_SECRET=your-secret > /etc/your-api.env'
# your-api.service
[Service]
EnvironmentFile=/etc/your-api.env
revokeSessions: true also
bumps the auth epoch, which invalidates every live SDK session
immediately — use that only if a secret has leaked.
Choose your client platform
Steps 2 to 6 below are tabbed. Pick your framework once and every step on this page follows you — the choice is remembered while you read.
| Platform | What you install | Status |
|---|---|---|
| Flutter | vact_sdk on pub.dev | Packaged SDK — one line to connect |
| Web (any framework) | Reference client — no packages | Copy one file into your project |
| React | Reference client + the hooks below | Copy one file into your project |
| React Native | Reference client + react-native-webrtc | Copy one file, three lines of setup |
| Android (Kotlin) | org.webrtc only | Implement the protocol — code below |
| iOS (Swift) | WebRTC.framework only | Implement the protocol — code below |
fetch (or your language's HTTP client) plus the WebRTC that
is already built into browsers and mobile OSes. There is no Firebase, no
signalling library and no vendor account to create — the
reference client below is a complete working
implementation in one dependency-free file, and the
protocol reference documents every call so you
can port it anywhere. Flutter additionally has a packaged SDK.
The JavaScript reference client
Web, React and React Native share one package with zero dependencies — a single small file, no transitive installs, TypeScript types included. It performs the exact sequence described in the protocol reference, so behaviour, error codes and billing are identical on every platform.
npm install @firstlogicmetalab/client
View @firstlogicmetalab/client on npm →
import {VactClient, VactError} from '@firstlogicmetalab/client';
Prefer not to add a dependency at all? The entire package is the single
file below — paste it in as vact-client.js and it behaves
identically.
// vact-client.js — a complete VACT client. No dependencies at all.
// On the web it uses only fetch() and the WebRTC APIs already in your runtime.
// On React Native, pass react-native-webrtc's primitives via the constructor
// (see @firstlogicmetalab/react-native) — nothing else changes.
const API_BASE = 'https://vact.online';
const HEARTBEAT_MS = 45000; // must stay well under the 180s server grace
export class VactError extends Error {
constructor(code, message, status) {
super(message);
this.code = code;
this.status = status;
}
}
// A non-cryptographic v4 UUID, used only for the create-call Idempotency-Key.
// Web and modern Node have crypto.randomUUID; React Native does not, so this
// is the fallback when no randomUUID is supplied.
function fallbackUuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
}
export class VactClient {
// `webrtc` lets a non-browser runtime (React Native) inject its own
// RTCPeerConnection / mediaDevices / MediaStream. Omit it on the web.
constructor(appId, { apiBase = API_BASE, webrtc, randomUUID } = {}) {
if (!/^(vact_app_[a-f0-9]{24}|app_[a-f0-9]{8})$/.test(appId)) {
throw new VactError('invalid_app_id', 'Invalid VACT App ID');
}
this.appId = appId;
this.apiBase = apiBase;
this.calls = new Map(); // callId -> internal call record
this.incoming = new Map(); // callId -> ringing VactIncomingCall
this.incomingListeners = new Set();
this.cursor = 0; // position in the event feed
// Resolve the WebRTC primitives once. Browser globals by default.
const g = typeof globalThis !== 'undefined' ? globalThis : {};
this._RTCPeerConnection = webrtc?.RTCPeerConnection ?? g.RTCPeerConnection;
this._mediaDevices = webrtc?.mediaDevices ?? g.navigator?.mediaDevices;
this._MediaStream = webrtc?.MediaStream ?? g.MediaStream;
this._randomUUID = randomUUID
?? (g.crypto?.randomUUID ? g.crypto.randomUUID.bind(g.crypto) : fallbackUuid);
if (!this._RTCPeerConnection || !this._mediaDevices || !this._MediaStream) {
throw new VactError('webrtc_unavailable',
'No WebRTC support found. On React Native, pass react-native-webrtc via the `webrtc` option.');
}
}
// ---- 1. Session ---------------------------------------------------------
/** Exchanges the one-time vact_at_ ticket your backend minted. */
async connect(accessToken) {
const session = await this.request('POST', '/v1/session/exchange', {
appId: this.appId,
accessToken,
});
this.sessionToken = session.sessionToken;
this.userId = session.userId;
this.sessionExpiresAt = new Date(session.sessionExpiresAt);
this.listen(); // start the event loop
return this.userId;
}
/** Swaps in a fresh session. Active calls are unaffected. */
async renew(accessToken) {
const session = await this.request('POST', '/v1/session/exchange', {
appId: this.appId,
accessToken,
});
this.sessionToken = session.sessionToken;
this.sessionExpiresAt = new Date(session.sessionExpiresAt);
}
disconnect() {
this.stopped = true;
for (const call of this.calls.values()) call.shutdown();
if (this.incoming.size) { this.incoming.clear(); this.emitIncoming(); }
this.sessionToken = null;
}
// ---- 2. The event loop --------------------------------------------------
// One long-poll carries everything: ringing calls, the answer, remote ICE
// candidates and status changes. It returns as soon as something happens,
// so this is not polling in the wasteful sense.
async listen() {
while (!this.stopped && this.sessionToken) {
try {
const batch = await this.request(
'GET', `/v1/events?cursor=${this.cursor}&wait=25`);
this.cursor = batch.cursor;
for (const event of batch.events) await this.handle(event);
} catch (e) {
if (e.code === 'expired_session') { this.onSessionExpired?.(); return; }
await new Promise((r) => setTimeout(r, 2000)); // back off, then retry
}
}
}
// Subscribe to the set of currently-ringing calls. The listener is called
// immediately with the current list and again whenever it changes (a new
// call rings, or one is answered, declined or cancelled). Returns an
// unsubscribe function. Mirrors the Flutter SDK's incomingCalls().
onIncomingCalls(listener) {
this.incomingListeners.add(listener);
listener([...this.incoming.values()]);
return () => this.incomingListeners.delete(listener);
}
emitIncoming() {
const list = [...this.incoming.values()];
for (const listener of this.incomingListeners) listener(list);
}
async handle(event) {
const call = this.calls.get(event.callId);
switch (event.type) {
case 'incoming_call': {
// Removing from the ringing set is idempotent, so answering/declining
// updates every onIncomingCalls listener exactly once.
const clear = () => { if (this.incoming.delete(event.callId)) this.emitIncoming(); };
const incoming = {
id: event.callId,
fromUserId: event.fromUserId,
callerName: event.callerName,
video: event.callType === 'video',
offer: event.offer,
accept: (opts) => { clear(); return this.accept(event, opts); },
decline: () => { clear(); return this.decline(event.callId); },
};
this.incoming.set(event.callId, incoming);
this.onIncomingCall?.(incoming); // backward-compatible singular hook
this.emitIncoming();
break;
}
case 'call_answered':
if (call) {
await call.pc.setRemoteDescription(event.answer);
call.remoteReady = true;
while (call.queued.length) {
await call.pc.addIceCandidate(call.queued.shift()).catch(() => {});
}
}
break;
case 'ice_candidate':
if (!call) break;
if (call.remoteReady) await call.pc.addIceCandidate(event.candidate).catch(() => {});
else call.queued.push(event.candidate);
break;
case 'call_connected':
call?.setState('connected');
break;
case 'call_ended':
call?.shutdown();
// The caller hung up before we answered: drop it from the ringing set.
if (this.incoming.delete(event.callId)) this.emitIncoming();
break;
}
}
// ---- 3. Placing and answering -------------------------------------------
async call(toUserId, { video = false, callerName } = {}) {
const { pc, media, early } = await this.peer(video);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const created = await this.request('POST', '/v1/calls', {
toUserId,
callType: video ? 'video' : 'audio',
...(callerName ? { callerName } : {}),
offer: { type: offer.type, sdp: offer.sdp },
}, { 'Idempotency-Key': this._randomUUID() });
return this.activate(created.callId, toUserId, true, pc, media, early);
}
async accept(incoming, { video } = {}) {
const wantsVideo = video ?? incoming.callType === 'video';
const { pc, media, early } = await this.peer(wantsVideo);
await pc.setRemoteDescription(incoming.offer);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await this.request('POST', `/v1/calls/${incoming.callId}/accept`, {
answer: { type: answer.type, sdp: answer.sdp },
});
const call = this.activate(incoming.callId, incoming.fromUserId, false, pc, media, early);
call.remoteReady = true; // the offer was already applied above
return call;
}
decline(callId) {
return this.request('POST', `/v1/calls/${callId}/decline`, {});
}
// ---- 4. Internals -------------------------------------------------------
async peer(video) {
const media = await this._mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
video: video ? { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 720 } } : false,
});
// TURN credentials are session-scoped and expire in 15 minutes.
const { iceServers } = await this.request('GET', '/v1/rtc/config');
const pc = new this._RTCPeerConnection({ iceServers, iceCandidatePoolSize: 2 });
media.getTracks().forEach((track) => pc.addTrack(track, media));
// Gathering begins at setLocalDescription — before we know the callId to
// post candidates to. Buffer anything emitted in that window so fast
// (loopback / host-only) candidates are not lost before activate() runs.
const early = [];
pc.onicecandidate = (e) => {
if (e.candidate) early.push({
candidate: e.candidate.candidate,
sdpMid: e.candidate.sdpMid,
sdpMLineIndex: e.candidate.sdpMLineIndex,
});
};
return { pc, media, early };
}
activate(callId, otherUserId, isCaller, pc, media, early = []) {
const remote = new this._MediaStream();
pc.ontrack = (e) => (e.streams[0] || new this._MediaStream([e.track]))
.getTracks()
.forEach((t) => { if (!remote.getTrackById(t.id)) remote.addTrack(t); });
const client = this;
const record = {
id: callId, otherUserId, isCaller, pc,
localStream: media, remoteStream: remote,
state: isCaller ? 'ringing' : 'connecting',
remoteReady: false, queued: [], pending: [], beat: null,
isScreenSharing: false, _cameraTrack: null,
onState: null, onScreenShare: null,
setState(s) { if (this.state !== s) { this.state = s; this.onState?.(s); } },
setMicrophoneEnabled: (on) => media.getAudioTracks().forEach((t) => { t.enabled = on; }),
setCameraEnabled: (on) => media.getVideoTracks().forEach((t) => { t.enabled = on; }),
// Replace the outgoing camera track with the screen, keeping the same
// sender so no renegotiation is needed. Requires a call started with
// video. Reverts automatically when the user stops sharing in the
// browser's own UI.
async startScreenShare(options) {
if (record.isScreenSharing) return;
const sender = pc.getSenders().find((s) => s.track && s.track.kind === 'video');
if (!sender) {
throw new VactError('screen_share_unavailable',
'Start the call with video before sharing your screen.');
}
if (!client._mediaDevices.getDisplayMedia) {
throw new VactError('screen_share_unsupported',
'This runtime cannot capture the screen (getDisplayMedia is unavailable).');
}
const display = await client._mediaDevices.getDisplayMedia({
video: options?.video ?? { frameRate: 15 },
audio: false,
});
const screenTrack = display.getVideoTracks()[0];
record._cameraTrack = sender.track;
await sender.replaceTrack(screenTrack);
// Reflect the shared surface in the local preview stream.
if (record._cameraTrack) media.removeTrack(record._cameraTrack);
media.addTrack(screenTrack);
record.isScreenSharing = true;
record.onScreenShare?.(true);
// The browser's built-in "Stop sharing" control ends the track.
screenTrack.addEventListener?.('ended', () => {
record.stopScreenShare().catch(() => {});
});
},
async stopScreenShare() {
if (!record.isScreenSharing) return;
const sender = pc.getSenders().find((s) => s.track && s.track.kind === 'video');
const screenTrack = sender?.track;
if (sender && record._cameraTrack) await sender.replaceTrack(record._cameraTrack);
if (screenTrack) { media.removeTrack(screenTrack); screenTrack.stop(); }
if (record._cameraTrack) media.addTrack(record._cameraTrack);
record._cameraTrack = null;
record.isScreenSharing = false;
record.onScreenShare?.(false);
},
end: () => this.finish(callId, 'end'),
cancel: () => this.finish(callId, 'cancel'),
shutdown: () => {
if (record.state === 'ended') return;
record.setState('ended');
clearInterval(record.beat);
media.getTracks().forEach((t) => t.stop());
if (record._cameraTrack) { record._cameraTrack.stop(); record._cameraTrack = null; }
pc.close();
this.calls.delete(callId);
},
};
this.calls.set(callId, record);
// Batch candidates: one request per burst instead of one per candidate.
const flush = () => {
clearTimeout(record.flush);
record.flush = setTimeout(() => {
const batch = record.pending.splice(0, 32);
if (batch.length) {
this.request('POST', `/v1/calls/${callId}/candidates`, { candidates: batch })
.catch(() => {});
}
}, 50);
};
pc.onicecandidate = (e) => {
if (!e.candidate) return;
record.pending.push({
candidate: e.candidate.candidate,
sdpMid: e.candidate.sdpMid,
sdpMLineIndex: e.candidate.sdpMLineIndex,
});
flush();
};
// Send anything gathered before the callId was known (see peer()).
if (early.length) { record.pending.push(...early); flush(); }
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'connected') {
record.setState('connected');
// The heartbeat doubles as a liveness gate: if the server reports the
// call is no longer active — a hang-up elsewhere, a backend terminate,
// or the sweep — tear the media down. This is what makes a server-side
// termination actually stop the call on this end.
record.beat ||= setInterval(() => {
this.request('POST', `/v1/calls/${callId}/heartbeat`, {}).catch((e) => {
if (e instanceof VactError &&
(e.code === 'call_not_active' || e.code === 'call_not_found')) {
record.shutdown();
}
});
}, HEARTBEAT_MS);
// Billing starts here — never when the user merely taps Accept.
this.request('POST', `/v1/calls/${callId}/connected`, {}).catch(() => {});
} else if (pc.connectionState === 'failed') {
record.setState('failed');
}
};
return record;
}
async finish(callId, action) {
const record = this.calls.get(callId);
try {
await this.request('POST', `/v1/calls/${callId}/${action}`,
action === 'end' ? { endReason: 'completed' } : {});
} finally {
record?.shutdown();
}
}
async request(method, path, body, extraHeaders) {
const headers = { Accept: 'application/json', ...extraHeaders };
if (body) headers['Content-Type'] = 'application/json';
if (this.sessionToken) headers.Authorization = `Bearer ${this.sessionToken}`;
const res = await fetch(this.apiBase + path, {
method, headers, body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
const err = payload.error || {};
throw new VactError(err.code || 'request_failed',
err.message || 'VACT request failed', res.status);
}
return payload;
}
}
restart_offer / restart_answer events
described in the protocol reference; the
reference client reports failed instead. Add it once calls
work.
Screen sharing
On a video call, swap your camera for your screen. It reuses the
same video sender via replaceTrack, so there is no
renegotiation — the other side simply starts seeing your screen. It reverts
automatically when the user stops sharing from the browser's own control.
// call must have been started with { video: true }
await call.startScreenShare(); // camera → screen
call.onScreenShare = (sharing) => updateButton(sharing);
// later…
await call.stopScreenShare(); // screen → camera
getDisplayMedia, which exists on the web
and in Electron but not in React Native (where screen sharing needs
a native module — startScreenShare throws
screen_share_unsupported there). On an audio-only call it
throws screen_share_unavailable: start with video first.
2. Install and set up permissions
Calls need the camera and microphone, so each platform needs a little one-time setup. Do this before writing any call code.
a. Add the packages
In pubspec.yaml. flutter_webrtc gives you the
video widgets, permission_handler asks for camera and mic.
dependencies:
vact_sdk: ^2.1.0 # no Firebase, no transitive vendor SDKs
flutter_webrtc: ^1.5.2 # RTCVideoRenderer / RTCVideoView
permission_handler: ^12.0.3 # camera + microphone prompts
flutter pub get
b. Android
WebRTC needs API 23 or newer. In
android/app/build.gradle.kts:
android {
defaultConfig {
minSdk = 23 // WebRTC requires 23+
}
}
Then add these to
android/app/src/main/AndroidManifest.xml, directly inside
<manifest> and above
<application>:
<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.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
c. iOS
In ios/Runner/Info.plist. The strings are shown to the user
in the permission popup, so write something they will understand:
<key>NSCameraUsageDescription</key>
<string>Used for video calls</string>
<key>NSMicrophoneUsageDescription</key>
<string>Used for calls</string>
Also set the iOS deployment target to 12.0 or higher in
ios/Podfile.
d. Ask before the first call
import 'package:permission_handler/permission_handler.dart';
await [Permission.microphone, Permission.camera].request();
a. Add the package
One package, zero dependencies. WebRTC is already in every browser.
npm install @firstlogicmetalab/client
Or paste the single-file client in instead — same code, no dependency at all.
b. Serve over HTTPS
Browsers expose getUserMedia only on a secure origin.
localhost counts as secure, so local development works —
but a staging site on plain http:// will fail with
NotAllowedError before VACT is ever reached.
c. Permissions
There is nothing to declare. The browser prompts the user the first
time you call getUserMedia. Trigger that prompt on a real
click — Chrome and Safari block it otherwise:
// Call this from a button handler, on a screen BEFORE the call screen.
// The stream is discarded; it exists only to raise the prompt early.
const probe = await navigator.mediaDevices.getUserMedia({audio: true, video: true});
probe.getTracks().forEach((t) => t.stop());
d. If your app is cross-origin
Embedding your call UI in an <iframe> needs an
explicit grant, or the camera is silently denied:
<iframe src="https://your-app.example" allow="camera; microphone"></iframe>
a. Add the package
npm install @firstlogicmetalab/react-native react-native-webrtc
cd ios && pod install && cd ..
@firstlogicmetalab/react-native
wires the client to react-native-webrtc for you and adds a
useVact hook — no globals to register, no UUID polyfill.
b. Call from a component
import { useVact } from '@firstlogicmetalab/react-native';
import { RTCView } from 'react-native-webrtc';
function CallScreen({ accessToken }) {
const { status, incomingCall, activeCall, call, accept, decline, hangup } =
useVact({ appId: 'vact_app_xxx', accessToken }); // accessToken = your backend's vact_at_ token
if (activeCall) return (
<>
<RTCView streamURL={activeCall.remoteStream.toURL()} style={{ flex: 1 }} objectFit="cover" />
<RTCView streamURL={activeCall.localStream.toURL()} style={{ width: 110, height: 160 }} mirror />
<Button title="Hang up" onPress={hangup} />
</>
);
if (incomingCall) return (
<View>
<Text>{incomingCall.fromUserId} is calling</Text>
<Button title="Answer" onPress={() => accept()} />
<Button title="Decline" onPress={decline} />
</View>
);
return <Button title="Call bob" disabled={status !== 'ready'} onPress={() => call('bob', { video: true })} />;
}
Prefer to drive the client yourself? createVactClient('vact_app_xxx')
returns the same VactClient as web, already wired to
react-native-webrtc.
c. Android
android/build.gradle needs minSdkVersion = 24
for react-native-webrtc. Then 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.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
Android 6+ also needs a runtime request:
import {PermissionsAndroid} from 'react-native';
await PermissionsAndroid.requestMultiple([
PermissionsAndroid.PERMISSIONS.CAMERA,
PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
]);
d. iOS
In ios/YourApp/Info.plist:
<key>NSCameraUsageDescription</key>
<string>Used for video calls</string>
<key>NSMicrophoneUsageDescription</key>
<string>Used for calls</string>
Minimum deployment target 13.0. Note that the iOS Simulator has no camera — video calls must be tested on a real device.
a. Add the dependencies
In app/build.gradle.kts. Signalling is plain HTTPS,
so you only need a WebRTC build and an HTTP client.
dependencies {
// A WebRTC build and an HTTP client. That is the entire dependency list.
implementation("io.github.webrtc-sdk:android:125.6422.07")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
}
android {
defaultConfig { minSdk = 23 } // WebRTC requires 23+
}
vact.online. If your
app already uses Firebase or any other backend, VACT does not
interact with it.
b. Manifest permissions
<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.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
c. Runtime request
private val permissions = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { /* check the results before showing your call button */ }
permissions.launch(arrayOf(Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO))
a. Add the dependencies
Add WebRTC through Swift Package Manager. Nothing else is needed —
URLSession handles every VACT call:
// Package.swift — WebRTC only; URLSession covers the rest.
dependencies: [
.package(url: "https://github.com/stasel/WebRTC", from: "125.0.0"),
]
vact.online. If your
app already uses Firebase or any other backend, VACT does not
interact with it.
b. Info.plist
<key>NSCameraUsageDescription</key>
<string>Used for video calls</string>
<key>NSMicrophoneUsageDescription</key>
<string>Used for calls</string>
Minimum deployment target 13.0.
c. Audio session
iOS routes call audio to the earpiece unless you say otherwise. Set this once, before the first call:
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .videoChat,
options: [.defaultToSpeaker, .allowBluetooth])
try session.setActive(true)
The Simulator has no camera — test video calls on a real device.
3. Connect
Create one client for the signed-in user and connect it with a token from step 1. Keep it alive for as long as the user is signed in — one per app, not one per call. Mint the token immediately before connecting: it dies after five minutes and works once.
import 'package:vact_sdk/vact_sdk.dart';
// 1. Create the client. Only the PUBLIC App ID goes here.
final vact = Vact(appId: 'vact_app_your_public_app_id');
// 2. Ask YOUR backend for a fresh token (see step 1).
final accessToken = await yourApi.getVactToken();
// 3. Connect. After this the user can call and be called.
await vact.connect(accessToken: accessToken);
print('connected as ${vact.userId}'); // e.g. "user_42"
Handle failures by code, show the message:
try {
await vact.connect(accessToken: accessToken);
} on VactException catch (e) {
// e.code is machine-readable, e.message is human-readable.
print('Could not connect: ${e.code} — ${e.message}');
}
When the user signs out, release everything:
await vact.dispose(); // ends any active call and closes the session
Using the reference client. Plain JavaScript first; the React wiring is directly below it.
import {VactClient, VactError} from './vact-client.js';
const vact = new VactClient('vact_app_your_public_app_id');
// Ask YOUR backend for a fresh token (see step 1), then connect.
const {accessToken} = await (await fetch('/api/vact-token', {
method: 'POST',
credentials: 'include', // your own login cookie decides who the user is
})).json();
try {
await vact.connect(accessToken);
console.log('connected as', vact.userId); // e.g. "user_42"
} catch (e) {
if (e instanceof VactError) console.error(e.code, e.message);
}
In React, connect once in a provider so the whole tree shares one client and one incoming-call listener:
// useVact.js — one client for the whole app, shared through context.
import { createContext, useContext, useEffect, useRef, useState } from 'react';
import { VactClient } from './vact-client';
const VactContext = createContext(null);
export function VactProvider({ appId, getAccessToken, children }) {
const clientRef = useRef(null);
const [ready, setReady] = useState(false);
const [incoming, setIncoming] = useState(null);
useEffect(() => {
let stopIncoming = () => {};
let cancelled = false;
const client = new VactClient(appId);
clientRef.current = client;
(async () => {
// Mint the token immediately before connecting: it dies in 5 minutes.
await client.connect(await getAccessToken());
if (cancelled) return;
setReady(true);
stopIncoming = client.onIncomingCalls((calls) => setIncoming(calls[0] || null));
})();
return () => {
cancelled = true;
stopIncoming();
client.disconnect();
};
}, [appId, getAccessToken]);
return (
<VactContext.Provider value={{ vact: clientRef.current, ready, incoming }}>
{children}
</VactContext.Provider>
);
}
export const useVact = () => useContext(VactContext);
// useCall.js — drives one call and re-renders on every state change.
export function useCall() {
const { vact } = useVact();
const [call, setCall] = useState(null);
const [state, setState] = useState('idle');
const attach = (next) => {
next.onState = (s) => {
setState(s);
if (s === 'ended') setCall(null);
};
setCall(next);
setState(next.state);
return next;
};
return {
call,
state,
start: async (toUserId, video = true) => attach(await vact.call(toUserId, { video })),
answer: async (incoming) => attach(await vact.accept(incoming)),
hangUp: () => call?.end(),
};
}
Wrap your app in it, and the rest of the page is ordinary React:
<VactProvider
appId="vact_app_your_public_app_id"
getAccessToken={async () => {
const r = await fetch('/api/vact-token', {method: 'POST', credentials: 'include'});
return (await r.json()).accessToken;
}}>
<CallScreen />
</VactProvider>
Identical to the web — the same reference client file, unmodified. There is no persistence layer to configure, because the session token is just a string you can keep wherever you already keep your own login:
import {VactClient} from './vact-client';
const vact = new VactClient('vact_app_your_public_app_id');
const {accessToken} = await (await fetch('https://your-api.example/vact-token', {
method: 'POST',
headers: {Authorization: `Bearer ${await yourOwnLogin.getToken()}`},
})).json();
await vact.connect(accessToken);
console.log('connected as', vact.userId);
Three things happen here: fetch the public Firebase config, exchange the one-time ticket for a custom token, and sign in. The order matters — bootstrap first, so a configuration failure never burns the token.
// VactSession.kt — the whole session layer.
class VactSession(private val appId: String, private val base: String = "https://vact.online") {
private val http = OkHttpClient()
private var sessionToken: String? = null
var userId: String = ""; private set
/** accessToken is the one-time vact_at_ ticket minted by YOUR backend. */
fun connect(accessToken: String) {
val body = JSONObject().put("appId", appId).put("accessToken", accessToken)
val session = post("/v1/session/exchange", body, auth = false)
sessionToken = session.getString("sessionToken")
userId = session.getString("userId")
}
/** Every later call carries the session token as a bearer. */
fun post(path: String, body: JSONObject, auth: Boolean = true): JSONObject {
val builder = Request.Builder().url(base + path)
.post(body.toString().toRequestBody("application/json".toMediaType()))
if (auth) builder.header("Authorization", "Bearer " + sessionToken!!)
http.newCall(builder.build()).execute().use { response ->
return JSONObject(response.body!!.string())
}
}
/** One long poll delivers ringing calls, the answer, candidates and status. */
fun events(cursor: Long, onEvent: (JSONObject) -> Unit): Long {
val request = Request.Builder()
.url("$base/v1/events?cursor=$cursor&wait=25")
.header("Authorization", "Bearer " + sessionToken!!).build()
http.newCall(request).execute().use { response ->
val payload = JSONObject(response.body!!.string())
val list = payload.getJSONArray("events")
for (i in 0 until list.length()) onEvent(list.getJSONObject(i))
return payload.getLong("cursor")
}
}
}
// Usage, once per signed-in user.
val vact = VactSession("vact_app_your_public_app_id")
vact.connect(yourApi.getVactToken())
Log.d("vact", "connected as ${vact.userId}")
Three things happen here: fetch the public Firebase config, exchange the one-time ticket for a custom token, and sign in. The order matters — bootstrap first, so a configuration failure never burns the token.
// VactSession.swift — the whole session layer. URLSession only.
final class VactSession {
let appId: String
private var sessionToken: String?
private(set) var userId = ""
private let base = URL(string: "https://vact.online")!
init(appId: String) { self.appId = appId }
/// accessToken is the one-time vact_at_ ticket minted by YOUR backend.
func connect(accessToken: String) async throws {
let session = try await send("/v1/session/exchange",
["appId": appId, "accessToken": accessToken], authenticated: false)
sessionToken = session["sessionToken"] as? String
userId = session["userId"] as? String ?? ""
}
/// Every later call carries the session token as a bearer.
func send(_ path: String, _ body: [String: Any]?,
authenticated: Bool = true) async throws -> [String: Any] {
var request = URLRequest(url: base.appendingPathComponent(path))
request.httpMethod = body == nil ? "GET" : "POST"
if let body { request.httpBody = try JSONSerialization.data(withJSONObject: body) }
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if authenticated, let token = sessionToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
}
/// One long poll delivers ringing calls, the answer, candidates and status.
func events(cursor: Int) async throws -> (events: [[String: Any]], cursor: Int) {
let payload = try await send("/v1/events?cursor=\(cursor)&wait=25", nil)
return (payload["events"] as? [[String: Any]] ?? [], payload["cursor"] as? Int ?? cursor)
}
}
// Usage, once per signed-in user.
let vact = VactSession(appId: "vact_app_your_public_app_id")
try await vact.connect(accessToken: yourApi.getVactToken())
print("connected as \(vact.userId)")
Keeping the session alive
A session lasts one hour by default. Rather than discovering that from failed calls, listen for the warning and swap in a fresh token. Renewing does not interrupt an active call — the underlying identity is unchanged.
// Fires 5 minutes before expiry, then again at expiry with Duration.zero.
vact.sessionExpiring.listen((remaining) async {
final token = await yourApi.getVactToken(); // a NEW one-time token
await vact.renew(accessToken: token);
});
If you would rather drive it yourself, vact.sessionExpiresAt
is the exact deadline.
sessionExpiresAt and reconnect with a fresh token
before it passes, or calls start failing with
expired_session. Reconnecting is the same three lines as
connecting — mint a new token and call connect again.
4. Place a call and show the video
Starting a call is one line. The interesting part is rendering it, so here is a complete screen you can copy.
// toUserId is an id from YOUR contact list — VACT has no directory.
final call = await vact.call('user_42', video: true);
// For a voice-only call: vact.call('user_42', video: false);
A VactCall gives you two video streams and a state stream:
| Property | What it is |
|---|---|
call.localStream | Your own camera/mic — show it small, as a preview |
call.remoteStream | The other person — show it big |
call.states | Stream of ringing → connecting → connected → ended |
call.otherUserId | Who you are talking to |
call.isCaller | true if you started the call |
Call quality and audio routing
Mobile operating systems send call audio to the earpiece by default, which is wrong for a video call. Set the route when the call screen opens:
await call.setAudioRoute(VactAudioRoute.speaker); // or .earpiece
call.stats emits a quality sample about every two seconds
while connected. quality is 0-4, ready to drive signal
bars:
call.stats.listen((s) {
setState(() => _bars = s.quality); // 0 = unusable, 4 = excellent
// s.roundTripTime, s.packetsLostRatio, s.jitter, s.bitrateKbps
// s.connectionType is 'host', 'srflx' or 'relay'
});
A complete call screen
Video streams are drawn with RTCVideoRenderer. Each renderer
must be initialize()d before use and dispose()d
after, or you will leak memory.
import 'dart:async'; // for StreamSubscription
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:vact_sdk/vact_sdk.dart';
class CallScreen extends StatefulWidget {
const CallScreen({super.key, required this.call});
final VactCall call;
@override
State<CallScreen> createState() => _CallScreenState();
}
class _CallScreenState extends State<CallScreen> {
final _localRenderer = RTCVideoRenderer();
final _remoteRenderer = RTCVideoRenderer();
late final StreamSubscription<VactCallState> _sub;
var _muted = false;
var _cameraOn = true;
@override
void initState() {
super.initState();
_setUpRenderers();
// Rebuild whenever the call changes state, and leave when it ends.
// The `mounted` check matters: the call can end after the user has
// already navigated away, and touching context then throws.
_sub = widget.call.states.listen((state) {
if (!mounted) return;
setState(() {});
if (state == VactCallState.ended || state == VactCallState.failed) {
Navigator.of(context).pop();
}
});
}
Future<void> _setUpRenderers() async {
await _localRenderer.initialize();
await _remoteRenderer.initialize();
_localRenderer.srcObject = widget.call.localStream;
_remoteRenderer.srcObject = widget.call.remoteStream;
if (mounted) setState(() {});
}
@override
void dispose() {
_sub.cancel();
_localRenderer.dispose();
_remoteRenderer.dispose();
// Leaving the screen should end the call. Safe to call twice.
widget.call.end();
super.dispose();
}
String get _status => switch (widget.call.state) {
VactCallState.ringing => 'Ringing…',
VactCallState.connecting => 'Connecting…',
VactCallState.connected => 'Connected',
VactCallState.reconnecting => 'Reconnecting…',
VactCallState.ended => 'Call ended',
VactCallState.failed => 'Call failed',
};
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
// The other person, full screen.
Positioned.fill(child: RTCVideoView(_remoteRenderer)),
// Your own camera, small, top-right.
if (_cameraOn)
Positioned(
top: 48, right: 16, width: 110, height: 150,
child: RTCVideoView(_localRenderer, mirror: true),
),
Positioned(
top: 48, left: 20,
child: Text('${widget.call.otherUserId} · $_status',
style: const TextStyle(color: Colors.white)),
),
// Controls.
Positioned(
left: 0, right: 0, bottom: 40,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: Icon(_muted ? Icons.mic_off : Icons.mic,
color: Colors.white),
onPressed: () async {
setState(() => _muted = !_muted);
await widget.call.setMicrophoneEnabled(!_muted);
},
),
IconButton(
icon: Icon(_cameraOn ? Icons.videocam : Icons.videocam_off,
color: Colors.white),
onPressed: () async {
setState(() => _cameraOn = !_cameraOn);
await widget.call.setCameraEnabled(_cameraOn);
},
),
IconButton(
icon: const Icon(Icons.cameraswitch, color: Colors.white),
onPressed: widget.call.switchCamera,
),
const SizedBox(width: 20),
FloatingActionButton(
backgroundColor: Colors.red,
onPressed: () => widget.call.end(),
child: const Icon(Icons.call_end),
),
],
),
),
],
),
);
}
}
Push it from wherever you start the call:
final call = await vact.call('user_42', video: true);
if (!mounted) return;
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => CallScreen(call: call)),
);
Ending a call
| Method | Use when |
|---|---|
call.end() | Hanging up a call that was answered |
call.cancel() | You are the caller and giving up before they answer |
vact.decline(incoming) | Rejecting a call you have not answered |
All three are safe to call more than once, and billing is finalised server-side either way.
A call from the reference client gives you the
same three things the Flutter SDK does: localStream,
remoteStream and a state callback. Attach the streams to
two <video> elements and you are done.
<!-- index.html — the markup app.js drives. -->
<video id="remote" autoplay playsinline></video>
<video id="local" autoplay playsinline muted></video>
<input id="to" placeholder="user_42">
<button id="call">Call</button>
<button id="hangup">Hang up</button>
<span id="status">idle</span>
<script type="module" src="./app.js"></script>
// app.js — a working call page in 40 lines.
import { VactClient, VactError } from './vact-client.js';
const vact = new VactClient('vact_app_your_public_app_id');
let active = null;
// 1. Connect. getVactToken() is YOUR endpoint from step 1.
const token = await (await fetch('/api/vact-token', { method: 'POST' })).json();
await vact.connect(token.accessToken);
console.log('connected as', vact.userId);
// 2. Place a call and show both video streams.
document.querySelector('#call').onclick = async () => {
try {
active = await vact.call(document.querySelector('#to').value, { video: true });
document.querySelector('#local').srcObject = active.localStream;
document.querySelector('#remote').srcObject = active.remoteStream;
active.onState = (state) => {
document.querySelector('#status').textContent = state;
if (state === 'ended') active = null;
};
} catch (e) {
if (e instanceof VactError) alert(e.message); // e.code is for your logs
}
};
// 3. Hang up.
document.querySelector('#hangup').onclick = () => active?.end();
// 4. Ring on incoming calls.
vact.onIncomingCalls((calls) => {
const incoming = calls[0];
if (!incoming || active) return;
if (confirm(`${incoming.callerName} is calling`)) {
vact.accept(incoming).then((call) => {
active = call;
document.querySelector('#local').srcObject = call.localStream;
document.querySelector('#remote').srcObject = call.remoteStream;
});
} else {
vact.decline(incoming);
}
});
In React, a MediaStream cannot be passed as a
prop — it has to be attached to the element imperatively. That one
detail is the only React-specific part of the whole integration:
// CallScreen.jsx — a MediaStream cannot be a React prop, so attach it in an
// effect. This is the one React-specific detail of the whole integration.
import { useEffect, useRef } from 'react';
import { useCall, useVact } from './useVact';
function Video({ stream, muted, className }) {
const ref = useRef(null);
useEffect(() => {
if (ref.current) ref.current.srcObject = stream || null;
}, [stream]);
return <video ref={ref} className={className} autoPlay playsInline muted={muted} />;
}
export default function CallScreen() {
const { ready, incoming, vact } = useVact();
const { call, state, start, answer, hangUp } = useCall();
if (!ready) return <p>Connecting…</p>;
if (!call && incoming) {
return (
<div>
<h2>{incoming.callerName} is calling</h2>
<button onClick={() => answer(incoming)}>Accept</button>
<button onClick={() => vact.decline(incoming)}>Decline</button>
</div>
);
}
if (!call) return <button onClick={() => start('user_42', true)}>Call user_42</button>;
return (
<div className="call">
<Video stream={call.remoteStream} className="remote" />
<Video stream={call.localStream} className="local" muted />
<p>{state}</p>
<button onClick={() => call.setMicrophoneEnabled(false)}>Mute</button>
<button onClick={hangUp}>Hang up</button>
</div>
);
}
Ending a call
call.end(); // hanging up a call that was answered
call.cancel(); // you are the caller, giving up before they answer
vact.decline(incoming); // rejecting a call you have not answered
All three are safe to call more than once, and billing is finalised server-side either way.
Placing the call is identical to the web — same client, same
call(). Only the rendering differs:
RTCView takes a stream URL instead of a DOM
srcObject.
const call = await vact.call('user_42', {video: true});
// call.localStream.toURL() → feed to <RTCView streamURL={...} />
// CallScreen.js — React Native renders streams with RTCView, which takes a
// stream URL string instead of a DOM srcObject.
import { RTCView } from 'react-native-webrtc';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useCall, useVact } from './useVact';
export default function CallScreen() {
const { ready, incoming } = useVact();
const { call, state, answer, hangUp, start } = useCall();
if (!ready) return <Text>Connecting…</Text>;
if (!call && incoming) {
return (
<View style={styles.center}>
<Text style={styles.name}>{incoming.callerName} is calling</Text>
<TouchableOpacity onPress={() => answer(incoming)}><Text>Accept</Text></TouchableOpacity>
</View>
);
}
if (!call) {
return (
<TouchableOpacity onPress={() => start('user_42', true)}>
<Text>Call user_42</Text>
</TouchableOpacity>
);
}
return (
<View style={styles.fill}>
<RTCView streamURL={call.remoteStream.toURL()} style={styles.fill} objectFit="cover" />
<RTCView streamURL={call.localStream.toURL()} style={styles.preview} zOrder={1} mirror />
<Text style={styles.state}>{state}</Text>
<TouchableOpacity style={styles.hangup} onPress={hangUp}><Text>End</Text></TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
fill: { flex: 1, backgroundColor: '#000' },
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
name: { fontSize: 22, marginBottom: 24 },
preview: { position: 'absolute', right: 16, top: 48, width: 110, height: 160 },
state: { position: 'absolute', top: 16, left: 16, color: '#fff' },
hangup: { position: 'absolute', bottom: 48, alignSelf: 'center' },
});
react-native-foreground-service) and the
voip background mode on iOS.
Ending a call
call.end(); // hanging up a call that was answered
call.cancel(); // you are the caller, giving up before they answer
vact.decline(incoming); // rejecting a call you have not answered
Build an ordinary PeerConnection from the
iceServers that GET /v1/rtc/config returns,
then drive it with the calls below. Every field name is fixed by the
protocol reference.
// VactCall.kt — placing a call and exchanging ICE candidates.
// `peer` is an ordinary org.webrtc.PeerConnection built from the iceServers
// that GET /v1/rtc/config returns.
suspend fun placeCall(toUserId: String, video: Boolean): String {
val offer = peer.createOfferSuspend()
peer.setLocalDescriptionSuspend(offer)
val created = session.post("/v1/calls", JSONObject()
.put("toUserId", toUserId)
.put("callType", if (video) "video" else "audio")
.put("offer", JSONObject().put("type", "offer").put("sdp", offer.description)),
headers = mapOf("Idempotency-Key" to UUID.randomUUID().toString()))
val callId = created.getString("callId")
val purgeAt = Instant.parse(created.getString("purgeAt"))
val callRef = session.db.document("apps/${session.appId}/calls/$callId")
// Write our candidates. Document ids MUST be "0".."127" — the security
// rules reject anything else, which caps a hostile client at 128 writes.
var seq = 0
peer.onIceCandidate = { candidate ->
if (seq < 128) callRef.collection("callerCandidates")
.document((seq++).toString())
.set(mapOf(
"candidate" to candidate.sdp,
"sdpMid" to candidate.sdpMid,
"sdpMLineIndex" to candidate.sdpMLineIndex,
"createdAt" to FieldValue.serverTimestamp(),
"expiresAt" to Timestamp(Date.from(purgeAt)),
))
}
// Read theirs, and pick up the answer the callee wrote.
callRef.collection("calleeCandidates").addSnapshotListener { snap, _ ->
snap?.documentChanges?.filter { it.type == ADDED }?.forEach {
peer.addIceCandidate(IceCandidate(
it.document.getString("sdpMid"),
it.document.getLong("sdpMLineIndex")!!.toInt(),
it.document.getString("candidate")!!))
}
}
callRef.addSnapshotListener { snap, _ ->
val answer = snap?.get("answer") as? Map<*, *> ?: return@addSnapshotListener
peer.setRemoteDescription(SessionDescription(ANSWER, answer["sdp"] as String))
when (snap.getString("status")) {
"accepted", "connected" -> startHeartbeat(callId) // every 45s
"ended", "declined", "cancelled", "missed" -> shutdown()
}
}
return callId
}
// Billing starts only when WebRTC actually connects — report it yourself.
fun onPeerConnected(callId: String) {
scope.launch { session.post("/v1/calls/$callId/connected", JSONObject()) }
}
// One beat every 45 seconds. The server sweeps after 180s of silence and
// bills only up to your last successful beat, so a dropped beat is safe.
fun startHeartbeat(callId: String) = scope.launch {
while (isActive) {
delay(45_000)
runCatching { session.post("/v1/calls/$callId/heartbeat", JSONObject()) }
}
}
Ending a call
// Answered call — bills the connected duration and finalises the ledger.
session.post("/v1/calls/$callId/end", JSONObject().put("endReason", "completed"))
// Outgoing call you are giving up on before it was answered.
session.post("/v1/calls/$callId/cancel", JSONObject())
Both are safe to send more than once; the server makes billing finalisation idempotent.
Build an ordinary RTCPeerConnection from the
iceServers that GET /v1/rtc/config returns,
then drive it with the calls below. Every field name is fixed by the
protocol reference.
// VactCall.swift — placing a call and exchanging ICE candidates.
// `peer` is an ordinary RTCPeerConnection built from the iceServers that
// GET /v1/rtc/config returns.
func placeCall(to userId: String, video: Bool) async throws -> String {
let offer = try await peer.offer(for: .default)
try await peer.setLocalDescription(offer)
let created = try await session.post("/v1/calls", [
"toUserId": userId,
"callType": video ? "video" : "audio",
"offer": ["type": "offer", "sdp": offer.sdp],
], headers: ["Idempotency-Key": UUID().uuidString])
let callId = created["callId"] as! String
let purgeAt = ISO8601DateFormatter().date(from: created["purgeAt"] as! String)!
let callRef = session.db.document("apps/\(session.appId)/calls/\(callId)")
// Write our candidates. Document ids MUST be "0"..."127" — the security
// rules reject anything else, which caps a hostile client at 128 writes.
var seq = 0
onLocalCandidate = { candidate in
guard seq < 128 else { return }
callRef.collection("callerCandidates").document("\(seq)").setData([
"candidate": candidate.sdp,
"sdpMid": candidate.sdpMid ?? NSNull(),
"sdpMLineIndex": candidate.sdpMLineIndex,
"createdAt": FieldValue.serverTimestamp(),
"expiresAt": Timestamp(date: purgeAt),
])
seq += 1
}
// Read theirs, and pick up the answer the callee wrote.
callRef.collection("calleeCandidates").addSnapshotListener { snap, _ in
snap?.documentChanges.filter { $0.type == .added }.forEach { change in
let d = change.document
peer.add(RTCIceCandidate(
sdp: d["candidate"] as! String,
sdpMLineIndex: d["sdpMLineIndex"] as? Int32 ?? 0,
sdpMid: d["sdpMid"] as? String))
}
}
callRef.addSnapshotListener { snap, _ in
if let answer = snap?["answer"] as? [String: Any] {
peer.setRemoteDescription(RTCSessionDescription(
type: .answer, sdp: answer["sdp"] as! String))
}
switch snap?["status"] as? String {
case "accepted", "connected": startHeartbeat(callId) // every 45s
case "ended", "declined", "cancelled", "missed": shutdown()
default: break
}
}
return callId
}
// Billing starts only when WebRTC actually connects — report it yourself.
func peerDidConnect(_ callId: String) {
Task { try? await session.post("/v1/calls/\(callId)/connected", [:]) }
}
// One beat every 45 seconds. The server sweeps after 180s of silence and
// bills only up to your last successful beat, so a dropped beat is safe.
func startHeartbeat(_ callId: String) {
heartbeat = Timer.scheduledTimer(withTimeInterval: 45, repeats: true) { _ in
Task { try? await session.post("/v1/calls/\(callId)/heartbeat", [:]) }
}
}
Ending a call
// Answered call — bills the connected duration and finalises the ledger.
try await session.post("/v1/calls/\(callId)/end", ["endReason": "completed"])
// Outgoing call you are giving up on before it was answered.
try await session.post("/v1/calls/\(callId)/cancel", [:])
Both are safe to send more than once; the server makes billing finalisation idempotent.
5. Receive calls
You only ever see calls addressed to the connected user, so there is nothing to filter — the security rules enforce that, not your code. Nothing is answered automatically: the call connects only when you accept it.
import 'dart:async'; // for StreamSubscription
late final StreamSubscription<List<VactIncomingCall>> _incomingSub;
@override
void initState() {
super.initState();
_incomingSub = vact.incomingCalls().listen((calls) {
if (calls.isEmpty) return;
final incoming = calls.first;
// Show your ringing screen. Do NOT auto-accept.
Navigator.of(context).push(MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => RingingScreen(incoming: incoming, vact: vact),
));
});
}
@override
void dispose() {
_incomingSub.cancel(); // always cancel, or it leaks
super.dispose();
}
Each VactIncomingCall tells you who is calling:
incoming.fromUserId // 'user_42'
incoming.callerName // 'Alice' — whatever the caller's app sent
incoming.type // VactCallType.audio or .video
incoming.expiresAt // unanswered calls are marked missed after 45s
A ringing screen
class RingingScreen extends StatelessWidget {
const RingingScreen({super.key, required this.incoming, required this.vact});
final VactIncomingCall incoming;
final Vact vact;
Future<void> _accept(BuildContext context) async {
try {
final call = await vact.accept(incoming);
if (!context.mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => CallScreen(call: call)),
);
} on VactException catch (e) {
// Someone answered on another device, or the call expired.
if (!context.mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.message)));
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final isVideo = incoming.type == VactCallType.video;
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(incoming.callerName, style: const TextStyle(fontSize: 28)),
Text(isVideo ? 'Incoming video call' : 'Incoming call'),
const SizedBox(height: 40),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FloatingActionButton(
backgroundColor: Colors.red,
onPressed: () async {
await vact.decline(incoming);
if (context.mounted) Navigator.of(context).pop();
},
child: const Icon(Icons.call_end),
),
FloatingActionButton(
backgroundColor: Colors.green,
onPressed: () => _accept(context),
child: Icon(isVideo ? Icons.videocam : Icons.call),
),
],
),
],
),
),
);
}
}
accept() throws — catch it and close the screen,
as above.
This covers calls while your app is open. To ring a closed app, see background calls below.
onIncomingCalls returns an unsubscribe function and
hands you only calls addressed to the connected user. Nothing is
answered until you call accept.
const stop = vact.onIncomingCalls(async (calls) => {
const incoming = calls[0];
if (!incoming) return setRinging(null); // caller hung up or it timed out
setRinging(incoming); // {callerName, video, fromUserId}
});
// Later, from your Accept button:
try {
const call = await vact.accept(incoming);
document.querySelector('#remote').srcObject = call.remoteStream;
} catch (e) {
// call_not_ringing is normal: another device answered, or it expired.
setRinging(null);
}
// And on sign-out:
stop();
In React the listener already lives in VactProvider
from step 3, so a component only reads
incoming from the context — see the
CallScreen above.
Exactly the same listener as the web. On a phone the difference is what you do with it — you almost certainly want a full-screen ringing UI rather than a banner:
useEffect(() => {
const stop = vact.onIncomingCalls((calls) => setRinging(calls[0] || null));
return stop; // unsubscribe when the screen unmounts
}, [vact]);
// Accept from your ringing screen.
const call = await vact.accept(ringing);
navigation.navigate('Call', {callId: call.id});
To ring while the app is closed, this listener is not enough — nothing is running to listen. See step 6.
Query your own ringing calls directly. The security rules already restrict you to calls addressed to your uid, so this listener cannot return anyone else's:
val ringing = session.db.collection("apps/${session.appId}/calls")
.whereEqualTo("to", session.uid)
.whereEqualTo("status", "ringing")
.orderBy("createdAt", Query.Direction.DESCENDING)
.limit(25)
ringing.addSnapshotListener { snap, _ ->
val call = snap?.documents?.firstOrNull {
it.getTimestamp("expiresAt")!!.toDate().after(Date())
} ?: return@addSnapshotListener
showRingingScreen(
callId = call.id,
callerName = call.getString("callerName") ?: "Unknown caller",
video = call.getString("callType") == "video",
offer = call.get("offer") as Map<*, *>,
)
}
Accepting sends your answer SDP. A server transaction guarantees only one device wins when the user is signed in on several:
peer.setRemoteDescription(SessionDescription(OFFER, offer["sdp"] as String))
val answer = peer.createAnswerSuspend()
peer.setLocalDescriptionSuspend(answer)
session.post("/v1/calls/$callId/accept", JSONObject()
.put("answer", JSONObject().put("type", "answer").put("sdp", answer.description)))
// A 409 call_not_ringing here is normal — another device answered first.
Query your own ringing calls directly. The security rules already restrict you to calls addressed to your uid, so this listener cannot return anyone else's:
session.db.collection("apps/\(session.appId)/calls")
.whereField("to", isEqualTo: session.uid)
.whereField("status", isEqualTo: "ringing")
.order(by: "createdAt", descending: true)
.limit(to: 25)
.addSnapshotListener { snap, _ in
guard let call = snap?.documents.first(where: {
($0["expiresAt"] as! Timestamp).dateValue() > Date()
}) else { return }
showRingingScreen(
callId: call.documentID,
callerName: call["callerName"] as? String ?? "Unknown caller",
video: call["callType"] as? String == "video",
offer: call["offer"] as! [String: Any])
}
Accepting sends your answer SDP. A server transaction guarantees only one device wins when the user is signed in on several:
peer.setRemoteDescription(RTCSessionDescription(
type: .offer, sdp: offer["sdp"] as! String))
let answer = try await peer.answer(for: .default)
try await peer.setLocalDescription(answer)
try await session.post("/v1/calls/\(callId)/accept", [
"answer": ["type": "answer", "sdp": answer.sdp],
])
// A 409 call_not_ringing here is normal — another device answered first.
6. Ringing a closed app (optional)
Everything above works while your app is running. A closed or backgrounded app has nothing listening, so the operating system must wake it with a push notification — and only your Firebase/Apple account can push to your app. So VACT tells your server, and your server sends the push.
caller places a call
│
▼
VACT backend ──POST──> your webhook ──> your FCM / APNs ──> callee's device
│
app wakes, shows call UI
Set the webhook up once from a trusted backend script:
const {VactServer} = require('@firstlogicmetalab/server-sdk');
const vact = new VactServer({
appId: process.env.VACT_APP_ID,
appSecret: process.env.VACT_APP_SECRET,
});
const {signingSecret} = await vact.configureWebhook({
url: 'https://api.example.com/webhooks/vact',
});
// Store signingSecret alongside your App Secret — you need it to verify.
Then receive and verify. Always verify the signature before
trusting the body, and always deduplicate on eventId — VACT
retries delivery, so the same event can legitimately arrive twice:
const {verifyVactWebhook} = require('@firstlogicmetalab/server-sdk');
// Note: express.raw — the signature covers the EXACT bytes sent.
app.post('/webhooks/vact', express.raw({type: 'application/json'}),
async (req, res) => {
let event;
try {
event = verifyVactWebhook({
rawBody: req.body,
headers: req.headers,
signingSecret: process.env.VACT_WEBHOOK_SECRET,
});
} catch (e) {
return res.status(400).send('bad signature'); // never process it
}
// Reply fast, then do the slow work — VACT retries on timeouts.
res.sendStatus(200);
if (await alreadyHandled(event.eventId)) return;
await markHandled(event.eventId);
if (event.type === 'incoming_call') {
const tokens = await yourDb.pushTokensFor(event.data.toUserId);
await yourFcm.sendEach(tokens.map((t) => ({
token: t,
data: {
callId: event.data.callId,
from: event.data.fromUserId,
callerName: event.data.callerName,
callType: event.data.callType,
},
android: {priority: 'high'},
apns: {headers: {'apns-priority': '10', 'apns-push-type': 'voip'}},
})));
}
});
Receiving that push in your app
The webhook and the FCM/APNs send above are the same on every platform. What differs is how your app turns the push into a ringing screen — and on both mobile operating systems this is a native requirement, not something VACT can do for you.
Use firebase_messaging for the wake-up and
flutter_callkit_incoming for the ringing UI — it wraps
Android's full-screen intent and iOS CallKit behind one API.
FirebaseMessaging.onBackgroundMessage(_onBackground);
@pragma('vm:entry-point')
Future<void> _onBackground(RemoteMessage message) async {
await FlutterCallkitIncoming.showCallkitIncoming(CallKitParams(
id: message.data['callId'],
nameCaller: message.data['callerName'],
type: message.data['callType'] == 'video' ? 1 : 0,
));
}
// When the user taps Accept, connect as usual and answer the ringing call
// that incomingCalls() now reports.
A browser tab that is closed cannot ring, so this step does not apply the way it does on mobile. What you can do is a Web Push notification from a service worker, which the user must grant:
// sw.js — your own service worker, pushed to by your own backend.
self.addEventListener('push', (event) => {
const call = event.data.json();
event.waitUntil(self.registration.showNotification(
`${call.callerName} is calling`,
{body: 'Tap to answer', data: call, requireInteraction: true},
));
});
self.addEventListener('notificationclick', (event) => {
event.waitUntil(clients.openWindow(`/call?id=${event.notification.data.callId}`));
});
onIncomingCalls listener from step 5 is all you need and
is far more reliable.
Use @react-native-firebase/messaging to receive the
data message and react-native-callkeep for the native
call UI on both platforms:
import messaging from '@react-native-firebase/messaging';
import RNCallKeep from 'react-native-callkeep';
messaging().setBackgroundMessageHandler(async (message) => {
RNCallKeep.displayIncomingCall(
message.data.callId,
message.data.from,
message.data.callerName,
'generic',
message.data.callType === 'video',
);
});
// RNCallKeep emits 'answerCall'. Connect the VactClient there, then accept
// the matching call from onIncomingCalls.
RNCallKeep.addEventListener('answerCall', async ({callUUID}) => {
await vact.connect(await getAccessToken());
// onIncomingCalls now reports it; accept the one whose id === callUUID.
});
On iOS this must be driven by PushKit, not ordinary FCM — see the iOS tab.
Android wakes your app with a high-priority data message. To ring over the lock screen you need a full-screen intent on a high-importance channel:
class CallMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
val fullScreen = PendingIntent.getActivity(
this, 0, Intent(this, IncomingCallActivity::class.java)
.putExtra("callId", message.data["callId"])
.putExtra("callerName", message.data["callerName"]),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
val notification = NotificationCompat.Builder(this, CALL_CHANNEL)
.setSmallIcon(R.drawable.ic_call)
.setContentTitle(message.data["callerName"])
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setFullScreenIntent(fullScreen, true) // this is what rings
.setOngoing(true)
.build()
NotificationManagerCompat.from(this).notify(1, notification)
}
}
The channel must be created with
IMPORTANCE_HIGH, and Android 14+ requires the
USE_FULL_SCREEN_INTENT permission. Once the user answers,
run the normal connect and accept flow.
Apple requires PushKit + CallKit for VoIP. An ordinary APNs alert cannot ring a terminated app, and iOS will kill your app if a PushKit push does not report a call to CallKit immediately:
func pushRegistry(_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType) async {
let data = payload.dictionaryPayload
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .generic, value: data["from"] as! String)
update.localizedCallerName = data["callerName"] as? String
update.hasVideo = data["callType"] as? String == "video"
// MUST happen before this function returns, or iOS terminates the app.
try? await provider.reportNewIncomingCall(
with: UUID(uuidString: data["callId"] as! String)!, update: update)
}
Send these from your backend with
apns-push-type: voip to the VoIP token (the one
PushKit gives you), not the standard APNs device token. When CallKit
reports the answer, run the normal
connect and accept flow.
Billing and payments
You are charged for connected call time and nothing else. Ringing, declined and missed calls are free, and the clock starts only once WebRTC actually connects — not when a user taps Accept.
| What | How it works |
|---|---|
| Free trial | ₹100 of free credit is added to a new app's wallet automatically. Integrate and place real calls before paying anything. |
| Rate | Default ₹0.10 per minute for audio and ₹0.40 per minute for video. Rates are set per app and are visible in the dashboard. |
| Measurement | Per call, from the connected report to hang-up, rounded to the second and priced in whole paise. |
| Rate changes | Each call freezes the rate at creation, so a mid-call price change never re-prices a call in progress. |
| Wallet | A prepaid balance. Bills are settled from it automatically, oldest first. |
| Bill | Generated on the 1st of each month for the month just ended. |
| Due date | 2 days after the bill is generated. |
The monthly cycle
during the month calls accumulate usage
1st, 00:30 UTC a bill is generated for the month just ended
any wallet balance is applied to it immediately
1st + 2 days if anything is still owing, NEW CALLS ARE BLOCKED
on payment the bill settles and calling resumes within the hour
payment_required — handle it by showing your users a
billing notice rather than a generic failure.
try {
final call = await vact.call('user_42', video: true);
} on VactException catch (e) {
if (e.code == 'payment_required') {
showBillingNotice(); // your account owes a bill
} else if (e.code == 'usage_limit_reached') {
showSpendCapNotice(); // your own monthly cap, not a debt
}
}
Paying
Top up the wallet from the dashboard's payment overview card. Payments go through Paygic; the minimum is ₹1 and the maximum is ₹10,00,000 per transaction. A recharge that has left your bank but has not been confirmed yet shows as pending — the gateway is re-checked automatically every 15 minutes, so a payment is never lost because a callback went missing.
Paying immediately settles the oldest outstanding bills, and any suspension is lifted as soon as nothing is overdue.
Spend caps
Separately from bills, an app can carry a monthly hard limit.
When the month's usage reaches it, new calls are refused with
usage_limit_reached (HTTP 402). This is your own guardrail
against a runaway integration, not a debt — it resets with the month.
Reading your usage programmatically
Everything the dashboard shows is available to any signed-in member of the app:
GET /v1/billing/{appId}/summary # wallet balance, unbilled and billed due
GET /v1/billing/{appId}/bills # bill history, newest first
GET /v1/billing/{appId}/transactions # every money movement
{
"wallet": {"balanceMinor": 250000, "currency": "INR", "minorUnit": 2},
"summary": {
"unbilledMinor": 41250, // used this month, not yet billed
"billedDueMinor": 0, // owed on generated bills
"suspended": false
}
}
250000 with minorUnit: 2 means ₹2,500.00.
Money never travels as a float — divide by 10^minorUnit
only when you display it.
Troubleshooting
The errors you are most likely to hit first, and what they actually mean.
| Symptom | Cause and fix |
|---|---|
invalid_access_token on connect() |
The token expired (5 minutes), was already used, or you sent an App Secret by mistake. Mint a fresh one immediately before connecting. Tokens are single-use — you cannot reuse one across app restarts. |
invalid_app_id |
You passed the App Secret where the App ID belongs. The App ID starts vact_app_; the secret starts vact_live_ and must never be in the app. |
| Black video, call otherwise fine | The renderer was not wired. Check you called initialize() on each RTCVideoRenderer and set srcObject, and that you setState afterwards. |
| First call never connects, later ones work | The permission popup appeared during call setup. Request camera/mic on a screen before the first call. |
| Callee never rings | Check both devices connected with different userIds under the same App ID, and that you are calling the exact id the other device connected with. |
| Rings, connects, then drops after ~1 minute | The call ended without a hang-up and was swept. Usually the app was backgrounded and stopped its heartbeat — expected on mobile without the background setup above. |
| Audio only, no video, on a video call | Camera permission was denied. Check Permission.camera.status and send the user to settings if it is permanently denied. |
call_not_ringing when accepting |
Normal and safe: another device answered first, the caller hung up, or the 45-second ring window elapsed. Close your ringing screen. |
Every SDK error is a VactException with a stable
code and a readable message. Log the code, show
the message:
try {
final call = await vact.call('user_42', video: true);
} on VactException catch (e) {
print('VACT error: ${e.code}'); // for you
showSnackBar(e.message); // for the user
}
How it works, in plain terms
Background reading. You already placed a call above; this explains why the design looks the way it does.
There are three pieces of information, and the whole design comes down to which one lives where.
| What | Looks like | Lives where | Why |
|---|---|---|---|
| App ID | vact_app_… |
Anywhere, including your app | Only names your account. Grants nothing on its own. |
| App Secret | vact_live_… |
Your server only | Full power. Anyone who extracts it can act as your app. |
| Access token | vact_at_… |
Handed to your app | Works once, expires in 5 minutes, scoped to one user. |
A phone app can be unpacked and read, so it must never contain the App Secret. Instead your server keeps the secret and hands the app a short-lived ticket. That is the entire idea; everything below follows from it.
The seven steps
- Create an app in the dashboard. Store the App Secret in your server's secret manager — never in source control or an app binary.
- Add one endpoint to your backend. It checks your own login, then asks VACT for a token for that user. This is the only backend work required.
- Connect from the app with
connect(accessToken:). The SDK swaps the ticket for a session and handles the rest. - Place a call with
call(userId), using an id from your own contact list — VACT stores no directory. - Receive calls from
incomingCalls(). You only ever see calls addressed to you, and nothing is answered until your UI callsaccept(). - Optional: ring a closed app. Only your own Firebase/Apple account can wake your app, so VACT notifies your server by webhook and your server sends the push.
- Read the dashboard. Duration, quality, failures and billing appear automatically — you never report usage.
Steps 1–5 are a complete working integration. Step 6 is only needed if calls must ring while the app is closed.
Credential model
| Value | Location | Lifetime | Power |
|---|---|---|---|
vact_app_... | Backend + app | Long-lived | Public tenant identifier only |
vact_live_... | Backend secret manager | Until rotation | Mints user grants |
vact_at_... | App memory | 5 minutes, one use | Starts one scoped session |
vact_st_... | App memory | Session lifetime (1 h default) | Participant API and event feed |
| TURN credential | SDK memory | 15 minutes | Authenticated relay only |
API v1
Base URL: https://vact.online · Machine-readable spec:
openapi.yaml
(OpenAPI 3.1 — import into Postman, Insomnia, or generate a client).
| Method | Endpoint | Auth |
|---|---|---|
| GET | /v1/bootstrap | Public identifiers |
| POST | /v1/apps/{appId}/tokens | Backend App Secret |
| PUT | /v1/apps/{appId}/webhook | Backend App Secret |
| POST | /v1/session/exchange | One-time access token |
| GET | /v1/events | Session token |
| POST | /v1/calls/{id}/candidates | Session token |
| GET | /v1/rtc/config | SDK session |
| POST | /v1/calls | call:create |
| POST | /v1/calls/{id}/{accept|connected|decline|cancel|end|heartbeat} | Scoped SDK session |
| POST | /v1/telemetry | telemetry:write |
| PUT | /v1/devices/{deviceId} | device:write |
| DELETE | /v1/devices/{deviceId} | device:write |
| DELETE | /v1/apps/{appId}/users/{userId} | Backend App Secret |
| GET | /v1/apps/{appId}/webhook-deliveries | Backend App Secret |
| POST | /v1/apps/{appId}/calls/{callId}/terminate | Backend App Secret |
| POST | /v1/apps/{appId}/firstparty-tokens | Verified Google ID token |
| GET | /v1/billing/{appId}/summary | Dashboard sign-in |
| GET | /v1/billing/{appId}/bills | Dashboard sign-in |
| GET | /v1/billing/{appId}/transactions | Dashboard sign-in |
| POST | /v1/billing/{appId}/recharge | Dashboard sign-in |
Deleting a user's data
One authenticated call erases sessions, signalling and device
registrations, and anonymizes the identity fields kept in billing records.
It pages internally until the user is gone; truncated is true
only when the work exceeded one request, in which case call it again.
curl -X DELETE https://vact.online/v1/apps/$APP_ID/users/user_42 \
-H "Authorization: Bearer $VACT_APP_SECRET" \
-H "Content-Type: application/json" \
-d '{"confirm": true}'
{
"ok": true,
"sessionsDeleted": 3,
"callsDeleted": 41,
"logsAnonymized": 41,
"billingEventsAnonymized": 39,
"passes": 1,
"truncated": false
}
Registering a device for push
Only needed if you use VACT's own sender. Most apps should take the webhook route and push from their own Firebase or Apple account instead.
await vact.registerDevice(
deviceId: 'stable-per-install-id',
fcmToken: token,
platform: 'android', // or 'ios'
);
{
"error": {
"code": "invalid_access_token",
"message": "access token is invalid, expired, or already exchanged"
}
}
Webhook events
Every event is delivered to the URL you registered, signed with your
vact_whsec_ secret. Verify the signature before trusting the
body, and deduplicate on eventId — delivery is retried, so the
same event can legitimately arrive more than once.
| Event | Fires when | Typically used to |
|---|---|---|
incoming_call | A call starts ringing | Push to the callee so a closed app wakes up |
call_answered | The callee accepts | Stop the caller's ringback; dismiss the callee's other devices |
call_ended | An answered call finishes | Tear down call UI on both sides; write your own call record |
call_cancelled | The call ends before it was answered — declined, cancelled or unanswered | Dismiss the ringing UI; log a missed call |
All four share one payload shape. status is what
distinguishes a decline from a cancel from a ring timeout:
{
"eventId": "call_abc123:call_cancelled:declined",
"type": "call_cancelled",
"apiVersion": "v1",
"createdAt": "2026-07-22T09:14:03.221Z",
"data": {
"appId": "vact_app_...",
"callId": "call_abc123",
"status": "declined", // declined | cancelled | missed | ended | accepted | ringing
"toUserId": "user_42", // the callee — call direction, not who to notify
"fromUserId": "user_7", // the caller
"callType": "video",
"callerName": "Priya",
"endedBy": "user_42", // null unless someone actively ended it
"endReason": "declined",
"expiresAt": "2026-07-22T09:14:45.000Z"
}
}
toUserId is the callee, not the recipient.
The payload describes the call's direction, which never changes. Who
needs telling depends on the event: a decline concerns the
caller, a cancel concerns the callee, and an end
concerns both. Route on type and status, not on
toUserId.
Delivery history
Every attempt is recorded, so you can see exactly what failed while
your endpoint was down instead of inferring it from missing calls.
Transient failures (timeouts, 5xx, 429) are retried automatically;
willRetry tells you whether one is still coming.
curl "https://vact.online/v1/apps/$APP_ID/webhook-deliveries?failed=true&limit=50" -H "Authorization: Bearer $VACT_APP_SECRET"
{
"deliveries": [
{
"eventId": "call_abc123:incoming_call:ringing",
"type": "incoming_call",
"callId": "call_abc123",
"outcome": "retrying", // delivered | retrying | rejected | unreachable
"status": 503, // what your endpoint returned, if it answered
"attempts": 3,
"willRetry": true,
"lastAttemptAt": "2026-07-22T09:14:31.882Z",
"deliveredAt": null
}
]
}
History is kept for 30 days.
Error reference
Every failure is
{"error": {"code": "...", "message": "..."}} with a matching
HTTP status. Branch on code; show message. In the
Flutter SDK these arrive as VactException.code.
Worth handling explicitly
| Code | HTTP | Meaning and what to do |
|---|---|---|
invalid_access_token | 400 / 401 | Expired (5 min), already used, or minted for another app. Mint a fresh one and connect again. |
expired_session | 401 | The session's lifetime ran out. Get a new access token and reconnect. |
payment_required | 402 | An unpaid bill is past its due date. Existing calls continue; show a billing notice. See billing. |
usage_limit_reached | 402 | Your own monthly spend cap was hit. Raise or clear the cap. |
call_not_ringing | 409 | Normal: another device answered, the caller hung up, or the 45s window closed. Dismiss the ringing screen. |
call_not_active | 409 | Heartbeat or end for a call already finished. Stop the timer; treat the call as over. |
callee_not_allowed | 403 | This session was scoped with allowedCalleeIds that exclude the target. |
rate_limited | 429 | Too many requests. Back off and retry; do not loop. |
idempotency_conflict | 409 | The same Idempotency-Key was reused with a different body. Use a fresh key per distinct call. |
Configuration mistakes
These mean something is wrong with your setup, not with a particular call. They should surface in development and never in production.
| Code | Cause |
|---|---|
invalid_app_id | You passed a secret where the public App ID belongs. |
invalid_app_credentials | The App Secret is wrong, revoked, or for another app. |
app_secret_required | A backend-only endpoint was called without the App Secret. |
insufficient_scope | The session lacks a permission — for example calling without call:create. |
invalid_user_id | User IDs allow A-Z a-z 0-9 _ . -, up to 64 characters. |
self_call | Caller and callee are the same user. |
invalid_offer / invalid_answer | Malformed SDP, or the wrong type for the stage. |
invalid_webhook_url | Webhook URLs must be public HTTPS; private and loopback hosts are refused. |
first_party_disabled | First-party sign-in is not enabled for this app. |
dashboard_login_required | An SDK session token was used on a dashboard endpoint. |
Service-side
| Code | HTTP | Meaning |
|---|---|---|
turn_unavailable | 502 | Relay credentials could not be issued. Retry; calls may still connect peer-to-peer. |
gateway_unavailable | 502 | The payment gateway did not respond. Your money is safe — pending payments are re-checked automatically. |
gateway_not_configured | 503 | Payments are not set up on this deployment yet. |
internal_error | 500 | Report it with the timestamp; nothing is billed for a failed request. |
Client protocol reference
Everything on this page — the Flutter SDK included — is built on the sequence below. If your platform is not listed above, implement these eleven steps and you have a complete VACT client.
The sequence
| # | What you do | Call |
|---|---|---|
| 1 | Exchange the one-time ticket for a session | POST /v1/session/exchange |
| 2 | Fetch ICE servers and build your peer connection | GET /v1/rtc/config |
| 3 | Create an offer, then place the call | POST /v1/calls |
| 4 | Send your ICE candidates as they are gathered | POST /v1/calls/{id}/candidates |
| 5 | Hold one long poll open for everything inbound | GET /v1/events |
| 6 | Callee answers with its SDP | POST /v1/calls/{id}/accept |
| 7 | Report real connectivity — this starts billing | POST /v1/calls/{id}/connected |
| 8 | Beat every 45 s while the call is up | POST /v1/calls/{id}/heartbeat |
| 9 | Hang up | POST /v1/calls/{id}/end (or cancel / decline) |
That is the entire protocol. Every call is JSON over HTTPS with an
Authorization: Bearer header.
The session token
Exchanging an access grant returns a vact_st_ session
token. Send it as the bearer on every subsequent request. It is opaque —
nothing to decode, verify or refresh mid-flight — and it expires at
sessionExpiresAt, at which point you exchange a fresh access
token from your backend.
POST /v1/session/exchange
{"appId": "vact_app_...", "accessToken": "vact_at_..."}
200 OK
{
"sessionToken": "vact_st_...", // bearer for every later call
"userId": "user_42", // the id your backend chose
"appId": "vact_app_...",
"sessionExpiresAt": "2026-07-23T11:00:00.000Z"
}
The event feed
One long poll delivers everything the client needs to react to. It
returns the moment something happens, so ringing is immediate; if nothing
happens it returns empty after wait seconds and you call
again with the same cursor. Persist the cursor and a reconnect resumes
exactly where it left off.
GET /v1/events?cursor=0&wait=25
200 OK
{
"events": [
{"id": "call_1_ringing", "type": "incoming_call", "callId": "call_1",
"fromUserId": "user_7", "callerName": "Priya", "callType": "video",
"offer": {"type": "offer", "sdp": "v=0..."}},
],
"cursor": 1770000000000
}
| Event | Carries | What to do |
|---|---|---|
incoming_call | offer, caller, call type | Ring. Accept with your answer SDP, or decline. |
call_answered | answer | setRemoteDescription; the call can now connect. |
ice_candidate | candidate | addIceCandidate. Queue any that arrive before the answer. |
call_connected | status | Media is flowing; billing has started. |
call_ended | status, endReason | Tear down. status says ended, declined, cancelled or missed. |
restart_offer / restart_answer / restart_request | SDP and a sequence n | ICE restart after a network change. Ignore any n you have already applied. |
id. A retry or an overlapping poll can
deliver one twice, so skip ids you have already handled — a set of the
last few hundred is plenty.
Sending ICE candidates
Post candidates as you gather them; batching a burst into one request
is cheaper and perfectly fine. The other side receives each as an
ice_candidate event. Up to 128 per side are accepted.
POST /v1/calls/{callId}/candidates
{
"candidates": [
{"candidate": "candidate:842163049 1 udp ...", "sdpMid": "0", "sdpMLineIndex": 0}
]
}
Timing that matters
| Value | Limit | What happens at the edge |
|---|---|---|
| Access token | 5 minutes, single use | invalid_access_token |
| Session | 1 hour by default | expired_session — reconnect with a fresh token |
| Ring window | 45 seconds | Status becomes missed |
| Heartbeat | Send every 45 s | Swept after 180 s of silence; billed to your last beat |
| TURN credential | 15 minutes | Refetch /v1/rtc/config for the next call |
| ICE candidates | 128 per side | Further writes are rejected by the rules |
connected report — after real WebRTC connectivity, not
when a user taps Accept — and stops at end or at your last
successful heartbeat, whichever comes first.
Security guarantees
- Secrets are random, shown once, stored only as hashes and compared in constant time.
- Access grants are short-lived, one-time, hashed at rest, rate-limited and scoped.
- Key, webhook and token events create server-only audit records with hashed user IDs.
- Every API call rechecks server-owned session expiry, app state and revocation epoch.
- Call IDs use 128 bits of cryptographic randomness and are created only by the server.
- Lifecycle and billing fields cannot be written directly by client SDKs.
- Participants can read only their own calls and write at most 128 immutable ICE candidates on their side while the call is live.
- Billable time starts only after the SDK reports a real WebRTC connection, not when the user merely taps Accept.
- Billing uses server timestamps, immutable events and a per-call rate snapshot.
- Telemetry is participant-only and field-allowlisted; arbitrary JSON is rejected.
- A per-user concurrent-call cap and a per-app daily relay-credential quota bound abusive usage before it reaches your bill.
- Your backend can force-disconnect any call with
POST /v1/apps/{appId}/calls/{callId}/terminate; the SDK tears the media down within one heartbeat. - Ending a call — by hang-up, backend terminate, or the server sweep — stops the SDK's media automatically, and relay credentials cannot be renewed for a call that is no longer active.
Privacy and retention
WebRTC media is protected in transit with DTLS-SRTP. A standard TURN service relays encrypted packets; VACT does not intentionally record media. VACT does process SDP, ICE candidates, opaque user IDs, timestamps, call state, usage totals and registered push tokens.
Use opaque internal IDs and avoid phone numbers, emails or real names unless required. SDP and ICE candidates are erased when a call ends; seven-day TTL fields provide fallback cleanup for abandoned calls. Sessions expire at their configured lifetime, rate buckets expire automatically and device registrations are refreshed or expire after 90 days. The server SDK's deleteUserData(userId) deletes sessions/signalling/devices and anonymizes identity fields in retained billing records.
Mobile production responsibilities
The SDK provides signalling and push-token registration. The integrating app still needs Android notification channels, foreground/full-screen call behaviour, OEM testing and iOS native PushKit/CallKit handling. Background FCM/APNs alone is not a promise of WhatsApp-like terminated-app reliability.
Scope and roadmap
VACT v1 is one-to-one P2P/TURN calling. It is not yet a group-room SFU/MCU and does not provide server recording, webinars, live streaming, transcription or media moderation. Agora-level breadth also requires regional control planes, global TURN capacity, native incoming-call modules, load/chaos evidence, a public status page and contractual SLA/DPA processes.