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.
https://api.operasignal.com. Video, recording and large live audiences are on the roadmap; voice rooms and voice calls are available today.Core concepts
| Concept | What it is |
|---|---|
| Account | Your company in Opera Signal. It owns the prepaid credit, the projects and the console logins. |
| Project | One 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 token | A 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. |
| Room | Identified 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. |
| Publishing | Being on stage: your microphone is sent to everyone in the room. Listeners don't publish and cost nothing to send. |
| Participant-minute | The 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.
| Claim | Type | Meaning |
|---|---|---|
app | string | Your App ID. |
sub | string | The user id in your system ([0-9A-Za-z_-], 1–40 chars). It is the userId everyone else sees. |
room | string? | If present, the token only opens this room. Without it, any room of the project. |
iat / exp | number | Issued at / expires at (Unix seconds). Lifetime between 60 s and 7 days; default 24 h. |
createUserToken(options)
| Option | Required | Description |
|---|---|---|
appId | yes | Project App ID. |
appSecret | yes | Project App Secret. Keep it on the server. |
userId | yes | Your user id. |
roomId | no | Lock the token to one room (recommended). |
expiresInSeconds | no | Default 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.
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)
| Option | Type | Description |
|---|---|---|
serverUrl | string | https://api.operasignal.com |
webrtc | WebRTCImpl? | WebRTC implementation. Browser: the global one (default). React Native: @livekit/react-native-webrtc. |
audio | Partial<AudioOptions>? | Initial audio options. |
hooks | AudioHooks? | Lets your app prepare the system audio session and plug native processing. See types. |
soundLevelIntervalMs | number? | How often soundLevels fires. Default 400. |
autoReconnect | boolean? | Reconnect by itself when the network drops. Default true. |
Methods
| Method | Description |
|---|---|
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. |
| Property | Description |
|---|---|
connectionState | 'disconnected' | 'connecting' | 'connected' | 'reconnecting' |
userId | My user id (the token's sub). |
isPublishing | Whether I am on stage. |
audioOptions | Current audio options. |
Events
| Event | Payload | When |
|---|---|---|
userJoined | RoomUser | Someone entered (also once per person already in the room when I join). |
userLeft | userId | Someone left. |
userUpdated | RoomUser | Someone's stage or microphone state changed. |
soundLevels | { [userId]: 0–100 } | Voice level of the others, every soundLevelIntervalMs. Use it for the speaking animation. |
localSoundLevel | 0–100 | My microphone level. |
roomMessage | fromUserId, message, topic | Someone called sendRoomMessage. |
connectionStateChanged | ConnectionState | Connection state changed. |
publishingStateChanged | boolean | I 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
| Option | Default | Description |
|---|---|---|
bitrateKbps | 64 | Opus bitrate. Voice doesn't benefit from more than about 64. |
stereo | false | Two channels (music). Voice is mono. |
dtx | false | Silence uses no bandwidth. Saves traffic but can clip the first syllable. |
echoCancellation | true | WebRTC echo cancellation. |
noiseSuppression | true | WebRTC noise suppression. Turn off if your native module already does it. |
autoGainControl | true | WebRTC automatic gain. Turn off if your native module already does it. |
outputGain | 1 | Volume 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.
muteUserstops 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, andsetReceiving(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
{
"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
{ "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
{ "ok": true, "roomId": "room-42", "startedAt": "2026-10-20T13:00:00Z",
"people": [{ "userId": "42", "name": "Ana", "publishing": true, "micOpen": false }] }
Remove a person
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
{ "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.
| Event | Extra fields | When |
|---|---|---|
room.started | — | The first person joined a room. |
user.joined | userId, participants | Someone joined. |
user.left | userId, reason, participants | Someone left. reason: closed (left or connection dropped), error, replaced, kicked, room_closed, insufficient_credit, app_disabled. |
room.ended | startedAt | The last person left. |
balance.low | balanceSeconds, thresholdSeconds | The 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.
{ "ok": true, "profile": { "id": 1, "name": "Motorola", "settings": { "playbackGain": 1.25 } } } // or "profile": null
| Setting | Meaning |
|---|---|
playbackGain | Multiplier for the volume of the people you hear (apply it to outputGain). |
minCallVolumePct | Minimum call volume to set when joining (native). |
route, source, hwEcho | Audio route, microphone source and hardware echo cancellation (native, applied on the next join). |
micGainDb, noiseStrength, forceSpeaker | Microphone 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.
| Code | Meaning | What to do |
|---|---|---|
invalid_token | Missing, expired, wrongly signed token, or a token for another room. | Get a fresh token from your server. |
insufficient_credit | The account ran out of credit. Rooms in progress are closed. | Top up. Set a low-balance alert to see it coming. |
app_disabled | Project revoked or offline, or account suspended. | Check the project status in the console. |
room_full | The room reached the account's maximum size. | Ask us to raise the limit. |
replaced | The same userId joined again from another device or connection. | Expected on reconnect from a second device; show "connected elsewhere". |
kicked | Your server removed this person (/kick). | Leave the room screen. |
room_closed | Your 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
| Item | Limit |
|---|---|
roomId | [0-9A-Za-z_-], 1–80 characters |
userId | [0-9A-Za-z_-], 1–40 characters |
| Token lifetime | 60 s to 7 days (default 24 h) |
| People per room | 50 by default; raised per account |
| One connection per user | Joining again with the same userId replaces the previous connection |
| Keep-alive | The SDK pings every 20 s |
| Callback timeout | 5 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.lowcallback). - 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:
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
| Message | Fields | Purpose |
|---|---|---|
join | name? | First message after connecting. |
publish | sdp, mid, trackName | Offer to publish my microphone. Track name: {roomId}-{userId}. |
unpublish | mid | Stop publishing. |
subscribe | tracks: [{ userId, trackName }] | Receive these voices. |
answer | sdp | Answer to a renegotiate. |
unsubscribe | mids | Stop receiving these tracks. |
mic | open | Tell the room my microphone opened/closed. |
broadcast | message | Text to the others. |
ping | — | Keep-alive (every 20 s). |
Server → client
| Message | Fields | Meaning |
|---|---|---|
welcome | me, people[] | Joined; who is already there (with their tracks and mic state). |
published | mid, sdp | Answer to my publish. |
renegotiate | sdp, 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-left | userId, name? | Presence. |
track-added / track-removed | userId, trackName | Someone went on/off stage. |
mic | userId, open | Someone's microphone opened/closed. |
broadcast | from, message | Text from someone. |
error | code, message | See error codes. |
pong | — | Reply to ping. |
Protocol version: 2.
Changelog
| Date | Change |
|---|---|
| 2026-10-20 | balance.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-20 | Projects, signed callbacks (room.started, user.joined, user.left, room.ended), test-token tools. |
| 2026-10-20 | Protocol v2, all in English. Muted microphone sends zero packets; muteUser and setReceiving unsubscribe; isConnectionAlive / resume; server moderation (state, kick, close). |