Developer documentation · v1

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.

Never put the 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

What you need An app — create one free in the dashboard and copy its App ID and App Secret · a backend that can make one HTTPS request · a browser. That is it. No Firebase, no vendor account, nothing to install on the server side.

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);

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.

Rotating without downtime Several App Secrets can be active at once. Create a new key in the dashboard, deploy your backend with it, confirm traffic is healthy, then revoke the old key. Revoking with 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.

PlatformWhat you installStatus
Fluttervact_sdk on pub.devPackaged SDK — one line to connect
Web (any framework)Reference client — no packagesCopy one file into your project
ReactReference client + the hooks belowCopy one file into your project
React NativeReference client + react-native-webrtcCopy one file, three lines of setup
Android (Kotlin)org.webrtc onlyImplement the protocol — code below
iOS (Swift)WebRTC.framework onlyImplement the protocol — code below
VACT needs no SDK and no third-party account. Every platform talks to the same plain HTTPS API: JSON over 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;
  }
}
What this file deliberately leaves out ICE restart after a network change. The Flutter SDK renegotiates through the 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
Platform support Screen capture uses 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();
Ask for permissions before the first call If you request them while a call is already starting, the popup interrupts media setup and the first call often fails to connect. Ask on the screen before anyone taps “call”. This is true on every platform above.

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

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.

Sessions expire. A session lasts one hour by default. Read 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:

PropertyWhat it is
call.localStreamYour own camera/mic — show it small, as a preview
call.remoteStreamThe other person — show it big
call.statesStream of ringing → connecting → connected → ended
call.otherUserIdWho you are talking to
call.isCallertrue 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

MethodUse 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.

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),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}
Signed in on two devices? Both ring, and a server transaction makes the first one to accept win. The loser's 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.

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.
The mobile half is your app's job Without the native work above, calls simply will not ring when the app is closed. That is an operating-system rule, not a VACT limitation — and it is why VACT notifies your server rather than pretending it can wake your app itself.

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.

WhatHow 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.
RateDefault ₹0.10 per minute for audio and ₹0.40 per minute for video. Rates are set per app and are visible in the dashboard.
MeasurementPer call, from the connected report to hang-up, rounded to the second and priced in whole paise.
Rate changesEach call freezes the rate at creation, so a mid-call price change never re-prices a call in progress.
WalletA prepaid balance. Bills are settled from it automatically, oldest first.
BillGenerated on the 1st of each month for the month just ended.
Due date2 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
What "blocked" means, precisely Only starting new calls stops. Calls already in progress finish normally, incoming calls still work, and the dashboard and payment pages stay reachable so you can settle the bill. Creating a call while suspended returns HTTP 402 with 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
  }
}
Amounts are always integer minor units. 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.

SymptomCause 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.

WhatLooks likeLives whereWhy
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

  1. Create an app in the dashboard. Store the App Secret in your server's secret manager — never in source control or an app binary.
  2. 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.
  3. Connect from the app with connect(accessToken:). The SDK swaps the ticket for a session and handles the rest.
  4. Place a call with call(userId), using an id from your own contact list — VACT stores no directory.
  5. Receive calls from incomingCalls(). You only ever see calls addressed to you, and nothing is answered until your UI calls accept().
  6. 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.
  7. 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.

Two rules worth remembering The App Secret never leaves your server, and your server — not the app — decides who the user is. Everything else is ordinary app code.

Credential model

ValueLocationLifetimePower
vact_app_...Backend + appLong-livedPublic tenant identifier only
vact_live_...Backend secret managerUntil rotationMints user grants
vact_at_...App memory5 minutes, one useStarts one scoped session
vact_st_...App memorySession lifetime (1 h default)Participant API and event feed
TURN credentialSDK memory15 minutesAuthenticated relay only
What may be visible in a package?Only the App ID, API hostname and public Firebase application identifiers. These values do not authorize data access. No App Secret, provider token, customer record, service account, SDP or ICE candidate is built into the SDK.

API v1

Base URL: https://vact.online · Machine-readable spec: openapi.yaml (OpenAPI 3.1 — import into Postman, Insomnia, or generate a client).

MethodEndpointAuth
GET/v1/bootstrapPublic identifiers
POST/v1/apps/{appId}/tokensBackend App Secret
PUT/v1/apps/{appId}/webhookBackend App Secret
POST/v1/session/exchangeOne-time access token
GET/v1/eventsSession token
POST/v1/calls/{id}/candidatesSession token
GET/v1/rtc/configSDK session
POST/v1/callscall:create
POST/v1/calls/{id}/{accept|connected|decline|cancel|end|heartbeat}Scoped SDK session
POST/v1/telemetrytelemetry: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-deliveriesBackend App Secret
POST/v1/apps/{appId}/calls/{callId}/terminateBackend App Secret
POST/v1/apps/{appId}/firstparty-tokensVerified Google ID token
GET/v1/billing/{appId}/summaryDashboard sign-in
GET/v1/billing/{appId}/billsDashboard sign-in
GET/v1/billing/{appId}/transactionsDashboard sign-in
POST/v1/billing/{appId}/rechargeDashboard 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.

EventFires whenTypically used to
incoming_callA call starts ringingPush to the callee so a closed app wakes up
call_answeredThe callee acceptsStop the caller's ringback; dismiss the callee's other devices
call_endedAn answered call finishesTear down call UI on both sides; write your own call record
call_cancelledThe call ends before it was answered — declined, cancelled or unansweredDismiss 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

CodeHTTPMeaning and what to do
invalid_access_token400 / 401Expired (5 min), already used, or minted for another app. Mint a fresh one and connect again.
expired_session401The session's lifetime ran out. Get a new access token and reconnect.
payment_required402An unpaid bill is past its due date. Existing calls continue; show a billing notice. See billing.
usage_limit_reached402Your own monthly spend cap was hit. Raise or clear the cap.
call_not_ringing409Normal: another device answered, the caller hung up, or the 45s window closed. Dismiss the ringing screen.
call_not_active409Heartbeat or end for a call already finished. Stop the timer; treat the call as over.
callee_not_allowed403This session was scoped with allowedCalleeIds that exclude the target.
rate_limited429Too many requests. Back off and retry; do not loop.
idempotency_conflict409The 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.

CodeCause
invalid_app_idYou passed a secret where the public App ID belongs.
invalid_app_credentialsThe App Secret is wrong, revoked, or for another app.
app_secret_requiredA backend-only endpoint was called without the App Secret.
insufficient_scopeThe session lacks a permission — for example calling without call:create.
invalid_user_idUser IDs allow A-Z a-z 0-9 _ . -, up to 64 characters.
self_callCaller and callee are the same user.
invalid_offer / invalid_answerMalformed SDP, or the wrong type for the stage.
invalid_webhook_urlWebhook URLs must be public HTTPS; private and loopback hosts are refused.
first_party_disabledFirst-party sign-in is not enabled for this app.
dashboard_login_requiredAn SDK session token was used on a dashboard endpoint.

Service-side

CodeHTTPMeaning
turn_unavailable502Relay credentials could not be issued. Retry; calls may still connect peer-to-peer.
gateway_unavailable502The payment gateway did not respond. Your money is safe — pending payments are re-checked automatically.
gateway_not_configured503Payments are not set up on this deployment yet.
internal_error500Report 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 doCall
1Exchange the one-time ticket for a sessionPOST /v1/session/exchange
2Fetch ICE servers and build your peer connectionGET /v1/rtc/config
3Create an offer, then place the callPOST /v1/calls
4Send your ICE candidates as they are gatheredPOST /v1/calls/{id}/candidates
5Hold one long poll open for everything inboundGET /v1/events
6Callee answers with its SDPPOST /v1/calls/{id}/accept
7Report real connectivity — this starts billingPOST /v1/calls/{id}/connected
8Beat every 45 s while the call is upPOST /v1/calls/{id}/heartbeat
9Hang upPOST /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
}
EventCarriesWhat to do
incoming_calloffer, caller, call typeRing. Accept with your answer SDP, or decline.
call_answeredanswersetRemoteDescription; the call can now connect.
ice_candidatecandidateaddIceCandidate. Queue any that arrive before the answer.
call_connectedstatusMedia is flowing; billing has started.
call_endedstatus, endReasonTear down. status says ended, declined, cancelled or missed.
restart_offer / restart_answer / restart_requestSDP and a sequence nICE restart after a network change. Ignore any n you have already applied.
Events are idempotent. Each has a stable 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

ValueLimitWhat happens at the edge
Access token5 minutes, single useinvalid_access_token
Session1 hour by defaultexpired_session — reconnect with a fresh token
Ring window45 secondsStatus becomes missed
HeartbeatSend every 45 sSwept after 180 s of silence; billed to your last beat
TURN credential15 minutesRefetch /v1/rtc/config for the next call
ICE candidates128 per sideFurther writes are rejected by the rules
Billing is server-authoritative. A client cannot start, stop or alter billed time. The clock starts at your 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

What server-side enforcement can and cannot do. Billing is server-authoritative: durations, rates and ledgers are set by the server and cannot be forged by any client. When a call ends, the SDK stops the media automatically, and a relay call can no longer renew its credentials. The one thing no signalling provider can prevent is a deliberately modified client keeping a direct peer-to-peer stream open after it stops billing — the media never touches the server, so nothing server-side sees it. This is inherent to peer-to-peer WebRTC. The concurrent cap, relay quota, force-terminate and anomaly detection exist to make that costly and visible rather than free and silent.

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.