Troubleshooting
The issues people actually hit, with the fix. Turn on logging first — pass a ConsoleLogger and most of these announce themselves:
MeteredPeer(MeteredPeerOptions(apiKey: 'pk_live_…', logger: const ConsoleLogger()));
Connection
| Symptom | Likely cause | Fix |
|---|---|---|
connect() / join() throws SignallingConnectError, closeCode == 4001 | JWT signature / kid / signing secret wrong, or a bad pk_ key | Check the key; mint a fresh JWT with the right signing secret |
closeCode == 4003 | The channel isn't in the JWT's channels claim | Add the channel pattern at mint time |
closeCode == 4010 | At your plan's concurrent-connection cap | Look for leaked clients — call dispose()/close() when done; or raise the plan |
closeCode == 4012 | Account suspended (billing) | Resolve billing |
connect() hangs | tokenProvider() never resolves | It's capped by tokenProviderTimeoutMs (10 s default); check your mint endpoint |
State flips to reconnecting immediately and repeatedly | tokenProvider returning a stale JWT (4002 loop) | Return a FRESH token each call (it runs on every reconnect) |
"The call connects but I see no video"
This is almost always one of four things. Work down the list:
pk_key withoutSend. IfonPeerJoinedfires (you see the peer) butonStreamAddednever does (the tile stays black), your publishable key is missing theSendpermission —MeteredPeerneeds it to exchange SDP/ICE. Re-create the key withSendticked. See the pk_ gotcha. This is the #1 cause.- Permissions not declared.
getUserMediathrows or returns no frames. Re-check Platform Setup — the Android manifest entries, the iOSInfo.plistusage strings. - iOS Simulator. The iOS Simulator has no camera —
getUserMedia({'video': true})produces no frames there. Test video on a real device (Android emulators do have a virtual camera). - Renderer not bound. Make sure you assign
renderer.srcObject = (ev.stream as FlutterWebrtcMediaStream).nativeinside theonStreamAddedlistener — and that you callRTCVideoView(renderer)with the initialized renderer.
"Video freezes after a network blip"
You're not re-binding the renderer on reconnect. After a reconcile the SDK surfaces a fresh MediaStream object (same stream.id) — a renderer still holding the old one shows a frozen frame. Re-assign renderer.srcObject on every onStreamAdded, not just the first. The _RemoteView pattern does this by construction. See Reconnect Best Practices.
"Remote peers connect to each other but not to me" / "works on Wi-Fi, fails on cellular"
A TURN-relay problem. Direct P2P works on permissive networks; restrictive ones (cellular, corporate firewalls) need TURN.
- Confirm
ConnectedEvent.iceServersis non-empty (pk_keys rely on auto-injection; check it's enabled on the key). - On the JWT path, ensure auto-inject is on, or that you're supplying
metadata.iceServersyourself. - Without working TURN, a meaningful fraction of real-world calls fail to connect.
"The camera light stays on after the call ends"
peer.close() doesn't stop your capture tracks. In your cleanup, stop the tracks and dispose the stream first, then close:
_localRenderer.srcObject = null;
for (final t in _localStream?.getTracks() ?? const []) { await t.stop(); }
await _localStream?.dispose();
await _peer?.close();
await _localRenderer.dispose();
Platform / build
| Symptom | Fix |
|---|---|
Android build fails on minSdkVersion | flutter_webrtc needs API 23+ — set minSdk = maxOf(flutter.minSdkVersion, 23) |
| iOS build can't find WebRTC pods | cd ios && pod install; deployment target 12.0+ |
Web: navigator.mediaDevices is undefined | Serve over HTTPS (or localhost) — getUserMedia requires a secure context |
| Black tile only on web | Browser blocked autoplay or camera — check the permission prompt and console |
Messaging
| Symptom | Fix |
|---|---|
send() throws MeteredPeerSendError(SendErrorCode.notJoined) | Called before join() completed — await it, or queue behind onJoined |
send() throws MeteredPeerOversizedError | Payload over maxMessageSize — chunk it, or use a P2P DataChannel |
onData never fires for a peer's message | They're not in your channel (cross-channel directs are dropped on MeteredPeer — use SignallingClient for unscoped) |
onMessage fires but m.fromMetadata is null | Subscribe with includeSenderMetadata: true, and ensure the sender's JWT has peerMetadata |
| Subscribed but receive nothing | Check the publisher's key/JWT allows publishing on that exact channel name |
Leaks / lifecycle
- Streams leaking — cancel the
StreamSubscriptions you got from.listen()in your widget'sdispose(), and callclient.dispose()/peer.close(). - Renderers leaking native textures —
await renderer.dispose()for everyRTCVideoRendereryou created (local + per-remote), including inonPeerLeft.
Still stuck?
Capture a ConsoleLogger transcript of a failing run and the close code (onDisconnected.code on a SignallingClient, or the onError message on MeteredPeer), then check Errors & Codes for that code's meaning.