DocumentationWebsiteOpen console

Opera Signal developers

Real-time voice rooms for your app: a stage with many microphones and an audience listening, or a voice call where everyone talks. Opera Signal runs on the Cloudflare global network. You keep your users, your rooms and your rules; we carry the audio.

Base URL for everything on this page: https://api.operasignal.com. Video, recording and large live audiences are on the roadmap; voice rooms and voice calls are available today.

Core concepts

ConceptWhat it is
AccountYour company in Opera Signal. It owns the prepaid credit, the projects and the console logins.
ProjectOne app. Each project has a public App ID (goes in the app) and a private App Secret (only on your server). If you have several apps, create one project per app to keep usage and keys apart.
User tokenA short-lived signed token your server creates for one user. The app gives it to the SDK to join a room. The App Secret never reaches the phone.
RoomIdentified by any roomId you choose ([0-9A-Za-z_-], up to 80 chars). It opens when the first person joins and closes when the last one leaves; there is nothing to create beforehand.
PublishingBeing on stage: your microphone is sent to everyone in the room. Listeners don't publish and cost nothing to send.
Participant-minuteThe billing unit: one person connected to a room for one minute, speaking or not.

Quickstart

1. Create a project

In the console, open Projects → Create project, pick Voice room and copy the App ID and App Secret. The secret is shown only once; you can rotate it later.

2. Issue a token on your server

import { createUserToken } from '@opera-signal/server-auth'

// e.g. inside your authenticated "join room" endpoint
const token = await createUserToken({
  appId: process.env.OPERA_APP_ID,
  appSecret: process.env.OPERA_APP_SECRET,
  userId: user.id,          // your user id, [0-9A-Za-z_-] up to 40 chars
  roomId: 'room-42',        // optional: lock the token to one room
  expiresInSeconds: 3600,  // optional: default 24 h, max 7 days
})

3. Join from the app

import { VoiceClient } from '@opera-signal/client'

const voice = new VoiceClient({ serverUrl: 'https://api.operasignal.com' })
voice.on('userJoined', (u) => console.log('joined', u.userId))
voice.on('soundLevels', (levels) => drawWaves(levels))   // { userId: 0–100 }
voice.on('error', (e) => console.warn(e.code, e.message))

await voice.joinRoom('room-42', tokenFromYourServer, { name: 'Ana' })
await voice.startPublishing()      // go on stage (asks for the microphone)
voice.muteMicrophone(true)          // stays on stage, sends nothing

Open the same room in a second tab or phone and the two of you hear each other. The console's Test room does exactly this without writing any code.

User tokens

A user token is a JWT signed with HS256 using your App Secret. Your server creates it; the SDK sends it when connecting; Opera Signal checks the signature, the expiry and the room.

ClaimTypeMeaning
appstringYour App ID.
substringThe user id in your system ([0-9A-Za-z_-], 1–40 chars). It is the userId everyone else sees.
roomstring?If present, the token only opens this room. Without it, any room of the project.
iat / expnumberIssued at / expires at (Unix seconds). Lifetime between 60 s and 7 days; default 24 h.

createUserToken(options)

OptionRequiredDescription
appIdyesProject App ID.
appSecretyesProject App Secret. Keep it on the server.
userIdyesYour user id.
roomIdnoLock the token to one room (recommended).
expiresInSecondsnoDefault 86 400, clamped to 60 … 604 800.

@opera-signal/server-auth uses only WebCrypto, so it runs on Node 18+, Cloudflare Workers, Deno, Bun and the browser. It also exports verifyUserToken(token, appSecret) and peekAppId(token). In any other language, sign the claims above as a standard HS256 JWT.

Issue tokens only to users your backend has authenticated, and only for rooms they are allowed in. Anyone holding a valid token can join and consumes your credit.

To debug, the console has Tools → Generate test token (valid 24 h) and Verify token, which tells you whether a token is valid, which project it belongs to and when it expires.

Server credentials

The REST API is for your server only. Authenticate every call with HTTP Basic using the project credentials:

Authorization: Basic base64("<App ID>:<App Secret>")
curl -u "$OPERA_APP_ID:$OPERA_APP_SECRET" https://api.operasignal.com/v1/rooms

Rotating the App Secret in the console invalidates the old one immediately for new tokens and REST calls. People already in a room stay connected.

Install the client SDK

npm install @opera-signal/client
npm install @opera-signal/server-auth   # on your server

Without a bundler, load the browser build. It exposes the global OperaSignal:

<script src="https://api.operasignal.com/sdk/opera-signal.js"></script>
<script>
  const voice = new OperaSignal.VoiceClient({ serverUrl: 'https://api.operasignal.com' })
</script>

VoiceClient

new VoiceClient(options: VoiceClientOptions)
OptionTypeDescription
serverUrlstringhttps://api.operasignal.com
webrtcWebRTCImpl?WebRTC implementation. Browser: the global one (default). React Native: @livekit/react-native-webrtc.
audioPartial<AudioOptions>?Initial audio options.
hooksAudioHooks?Lets your app prepare the system audio session and plug native processing. See types.
soundLevelIntervalMsnumber?How often soundLevels fires. Default 400.
autoReconnectboolean?Reconnect by itself when the network drops. Default true.

Methods

MethodDescription
joinRoom(roomId, token, { name? })Joins the room with a token from your server. Resolves when connected. Leaves the current room first if there is one.
leaveRoom()Leaves the room and releases the microphone.
startPublishing()Goes on stage: opens the microphone and sends it to the room.
stopPublishing()Leaves the stage.
muteMicrophone(muted)Mutes my microphone. I stay on stage, so unmuting is instant, but while muted zero packets are sent.
muteRoom(muted)Stops hearing the whole room (volume only), or hears it again.
muteUser(userId, muted)Stops hearing one person, only for me. Cancels that subscription, so nothing is received or billed; unmuting resubscribes in about 0.5 s.
setReceiving(enabled)Turns receiving the whole room on/off. Off: nothing arrives and nothing is billed for egress. Use it when the app has been in the background for a while.
isConnectionAlive()Resolves true if the socket answers a ping within 1.5 s and no audio connection has failed.
resume()Call when the app returns to the foreground. If the connection is alive it does nothing (audio is back immediately); if not, it reconnects right away.
setAudioOptions(partial)Changes audio options. Publishing options (bitrate, stereo, DTX, AEC/NS/AGC) republish the microphone.
sendRoomMessage(message, topic?)Sends a text to everyone else in the room (fires roomMessage on their side).
getNetworkStats()Network since the last call: send/receive kbps, loss, jitter, RTT, codec, concealed audio %.
getUsers()Everyone else in the room, as RoomUser[].
on(event, cb) / off(event, cb?)Subscribe / unsubscribe. on returns an unsubscribe function.
PropertyDescription
connectionState'disconnected' | 'connecting' | 'connected' | 'reconnecting'
userIdMy user id (the token's sub).
isPublishingWhether I am on stage.
audioOptionsCurrent audio options.

Events

EventPayloadWhen
userJoinedRoomUserSomeone entered (also once per person already in the room when I join).
userLeftuserIdSomeone left.
userUpdatedRoomUserSomeone's stage or microphone state changed.
soundLevels{ [userId]: 0–100 }Voice level of the others, every soundLevelIntervalMs. Use it for the speaking animation.
localSoundLevel0–100My microphone level.
roomMessagefromUserId, message, topicSomeone called sendRoomMessage.
connectionStateChangedConnectionStateConnection state changed.
publishingStateChangedbooleanI went on or off stage.
error{ code, message }See error codes. Terminal codes end the session; the SDK does not reconnect after them.

Audio options

OptionDefaultDescription
bitrateKbps64Opus bitrate. Voice doesn't benefit from more than about 64.
stereofalseTwo channels (music). Voice is mono.
dtxfalseSilence uses no bandwidth. Saves traffic but can clip the first syllable.
echoCancellationtrueWebRTC echo cancellation.
noiseSuppressiontrueWebRTC noise suppression. Turn off if your native module already does it.
autoGainControltrueWebRTC automatic gain. Turn off if your native module already does it.
outputGain1Volume of the people I hear.

Types

type RoomUser = { userId: string; name: string; publishing: boolean; micOpen: boolean }

type NetworkStats = {
  sendKbps, receiveKbps, sendLossPct, receiveLossPct, jitterMs, rttMs: number | null
  codec: string | null
  concealedPct: number | null   // % of received audio that was concealed (loss)
  listening: number              // how many voices I am receiving
}

type AudioHooks = {
  prepare?(options): Promise<void> | void       // before connecting: audio session, route
  afterConnect?(options): Promise<void> | void  // native processing needs WebRTC up
  release?(): Promise<void> | void              // on leaving
}

Background & economy

Most of the cost of a voice room is audio flowing to people who aren't listening. The SDK is built to send and receive only what is heard:

  • Muted microphone sends nothing. muteMicrophone(true) keeps you on stage but sends zero packets; unmuting is instant.
  • Muting someone unsubscribes. muteUser stops receiving that voice at the network level, not just the volume.
  • Long background? Stop receiving. Call setReceiving(false) after a few minutes in the background, and setReceiving(true) on return.
  • Coming back from the background. Call resume(). If the connection survived, voice is back in about 0.3 s; there is no need to leave and join again (that takes seconds).
AppState.addEventListener('change', (s) => {
  if (s === 'active') voice.resume()
})

React Native

import { mediaDevices, MediaStream, RTCPeerConnection } from '@livekit/react-native-webrtc'
import { VoiceClient } from '@opera-signal/client'

const voice = new VoiceClient({
  serverUrl: 'https://api.operasignal.com',
  webrtc: { RTCPeerConnection, mediaDevices, MediaStream },
})

Android needs the RECORD_AUDIO permission (and MODIFY_AUDIO_SETTINGS); iOS needs NSMicrophoneUsageDescription. A native audio package with our noise reduction is in development.

Server REST reference

All endpoints use Basic auth with the project credentials and return JSON with an ok field. Errors return { ok: false, error } with HTTP 4xx.

Account balance

GET/v1/balance
{
  "ok": true,
  "tenantId": "ten_3f9a…",
  "status": "active",          // active | suspended
  "creditSeconds": 5400000,     // participant-seconds
  "creditMinutes": 90000,
  "usage": [{ "app_id": "app_…", "day": "2026-10-20", "seconds": 41220 }]   // last days, per project
}

Live rooms of the project

GET/v1/rooms
{ "ok": true, "rooms": [
  { "room_id": "room-42", "participants": 18, "publishers": 4,
    "started_at": "2026-10-20T13:00:00Z", "updated_at": "2026-10-20 13:41:10" }
] }

Who is in a room

GET/v1/rooms/{roomId}
{ "ok": true, "roomId": "room-42", "startedAt": "2026-10-20T13:00:00Z",
  "people": [{ "userId": "42", "name": "Ana", "publishing": true, "micOpen": false }] }

Remove a person

POST/v1/rooms/{roomId}/kick
curl -u "$ID:$SECRET" -X POST https://api.operasignal.com/v1/rooms/room-42/kick \
  -H 'content-type: application/json' -d '{"userId":"42"}'

The person stops hearing and speaking immediately (their tracks are closed on the media server) and receives the kicked error. Returns 404 not_in_room if they are not there. Remember to stop issuing tokens for that room to them, or they can join again.

Close a room

POST/v1/rooms/{roomId}/close
{ "ok": true, "removed": 18 }

Everyone receives room_closed and is disconnected. The room id can be used again afterwards.

Callbacks

Set a callback URL (https) and generate a callback secret in Project → Callbacks. Opera Signal then sends a POST to your server on each event. Callbacks are sent only when both URL and secret are set.

EventExtra fieldsWhen
room.started—The first person joined a room.
user.joineduserId, participantsSomeone joined.
user.leftuserId, reason, participantsSomeone left. reason: closed (left or connection dropped), error, replaced, kicked, room_closed, insufficient_credit, app_disabled.
room.endedstartedAtThe last person left.
balance.lowbalanceSeconds, thresholdSecondsThe account balance dropped below the low-balance alert set in Billing. Sent once per crossing, by the room that crossed it.
POST https://your-server.com/opera/callback
x-opera-event: user.joined
x-opera-timestamp: 1760965200
x-opera-signature: sha256=5d1c…

{ "event": "user.joined", "appId": "app_…", "roomId": "room-42",
  "at": "2026-10-20T13:00:00.000Z", "userId": "42", "participants": 3 }

Verify the signature

The signature is sha256= + hex(HMAC-SHA256(callback secret, timestamp + "." + raw body)). Always verify it on the raw body, and reject old timestamps to block replays.

import crypto from 'node:crypto'

app.post('/opera/callback', express.raw({ type: 'application/json' }), (req, res) => {
  const ts = req.header('x-opera-timestamp')
  const expected = 'sha256=' + crypto.createHmac('sha256', process.env.OPERA_CALLBACK_SECRET)
    .update(ts + '.' + req.body).digest('hex')
  const got = req.header('x-opera-signature') ?? ''
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300
  if (!fresh || got.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected)))
    return res.sendStatus(401)
  const event = JSON.parse(req.body)
  // …
  res.sendStatus(200)
})

Answer with any 2xx within 5 seconds. Deliveries are not retried; every delivery (status and time) is listed in Project → Callbacks → Recent deliveries. Treat callbacks as notifications and use the REST API as the source of truth.

Device profiles

Some phones play voice too low or route audio oddly. Opera Signal keeps a table of audio adjustments per device. The most specific match wins: exact model › model prefix (moto g*) › brand › default. The app asks once at startup; profiles change without an app update.

GET/v1/device-profile?brand=motorola&model=moto%20g54%205G&sdk=35&app=your-app
{ "ok": true, "profile": { "id": 1, "name": "Motorola", "settings": { "playbackGain": 1.25 } } }   // or "profile": null
SettingMeaning
playbackGainMultiplier for the volume of the people you hear (apply it to outputGain).
minCallVolumePctMinimum call volume to set when joining (native).
route, source, hwEchoAudio route, microphone source and hardware echo cancellation (native, applied on the next join).
micGainDb, noiseStrength, forceSpeakerMicrophone gain, noise-reduction strength, force loudspeaker (native).

Public endpoint, no credentials. Missing settings mean "use the default".

Error codes

Errors reach the app through the error event. When the server ends a session for a business reason, the WebSocket closes with code 4001 and the error code as the reason. All codes below are terminal: the SDK does not reconnect.

CodeMeaningWhat to do
invalid_tokenMissing, expired, wrongly signed token, or a token for another room.Get a fresh token from your server.
insufficient_creditThe account ran out of credit. Rooms in progress are closed.Top up. Set a low-balance alert to see it coming.
app_disabledProject revoked or offline, or account suspended.Check the project status in the console.
room_fullThe room reached the account's maximum size.Ask us to raise the limit.
replacedThe same userId joined again from another device or connection.Expected on reconnect from a second device; show "connected elsewhere".
kickedYour server removed this person (/kick).Leave the room screen.
room_closedYour server closed the room (/close).Leave the room screen.

Non-terminal errors (network, internal) are reported too; with autoReconnect the SDK reconnects by itself and fires connectionStateChanged('reconnecting').

Limits

ItemLimit
roomId[0-9A-Za-z_-], 1–80 characters
userId[0-9A-Za-z_-], 1–40 characters
Token lifetime60 s to 7 days (default 24 h)
People per room50 by default; raised per account
One connection per userJoining again with the same userId replaces the previous connection
Keep-aliveThe SDK pings every 20 s
Callback timeout5 s, no retries

Billing

Opera Signal is prepaid. The account balance is kept in participant-minutes: each person connected to a room for one minute consumes one, whether they speak or listen. Rooms are metered continuously; when the balance reaches zero, open rooms close with insufficient_credit and new joins are refused until the next top-up.

  • Console → Usage: any period, grouped by day, project or room, with CSV export.
  • Console → Billing: balance, average per day, how long the balance lasts, monthly statements, and the low-balance alert (also sent as the balance.low callback).
  • GET /v1/balance: the same balance from your server.

WebSocket protocol

You don't need this if you use the SDK; it is documented for other platforms and for debugging. Connect to:

WSwss://api.operasignal.com/v1/rooms/{roomId}?token={userToken}

Messages are JSON with a type field t. Audio goes over WebRTC to the Cloudflare SFU (STUN stun:stun.cloudflare.com:3478); the socket carries signaling only. Your microphone always publishes in a session of its own, separate from the session that receives the others.

Client → server

MessageFieldsPurpose
joinname?First message after connecting.
publishsdp, mid, trackNameOffer to publish my microphone. Track name: {roomId}-{userId}.
unpublishmidStop publishing.
subscribetracks: [{ userId, trackName }]Receive these voices.
answersdpAnswer to a renegotiate.
unsubscribemidsStop receiving these tracks.
micopenTell the room my microphone opened/closed.
broadcastmessageText to the others.
ping—Keep-alive (every 20 s).

Server → client

MessageFieldsMeaning
welcomeme, people[]Joined; who is already there (with their tracks and mic state).
publishedmid, sdpAnswer to my publish.
renegotiatesdp, mids[]Offer for the receiving session. mids maps each local mid to its owner; always use the latest map (the SFU reuses mids).
user-joined / user-leftuserId, name?Presence.
track-added / track-removeduserId, trackNameSomeone went on/off stage.
micuserId, openSomeone's microphone opened/closed.
broadcastfrom, messageText from someone.
errorcode, messageSee error codes.
pong—Reply to ping.

Protocol version: 2.

Changelog

DateChange
2026-10-20balance.low callback and low-balance alert; Usage with CSV; monthly statements; team members (admin / read-only); browser test room; browser SDK build at /sdk/opera-signal.js; device profiles endpoint.
2026-10-20Projects, signed callbacks (room.started, user.joined, user.left, room.ended), test-token tools.
2026-10-20Protocol v2, all in English. Muted microphone sends zero packets; muteUser and setReceiving unsubscribe; isConnectionAlive / resume; server moderation (state, kick, close).