Go live from a phone over /v1
A phone is the hardest client this API has. It gets used in a car park, on a rooftop, in a basement, at a festival where forty thousand other people are also holding phones. It is also the client with the least room to be clever: it has one radio, a battery, and a user who is about to point it at something that only happens once.
So this guide is short on purpose. Two calls put a phone on air, and both of them are built to survive a bad link:
POST /v1/studios/{id}/contribute— provisions this device's input on the studio and hands back the credential to publish into it, in one round trip.POST /v1/studios/{id}/monitor— the return feed, so whoever is holding the camera can see what the show is actually sending.
The first one is the one that matters. If it succeeds you can push video; if it fails there is no stream, and nothing else on this API changes that.
Which host.
https://api.traxstreaming.live/v1with a production key, orhttps://api-dev.traxstreaming.live/v1with a dev key. Only the host differs.
Before you start
- An API key holding
sources:ingest,sources:writeandviewer:tokens. Create it under Developer → API keys, or use the user's own OIDC bearer if your app signs people in — see Build a client. Addsources:readandstudios:readif you also want to poll (you do; see Polling on cellular). - The studio id the phone is joining.
- A device id your app mints once. Read the next section before you pick one.
Why contribute needs three scopes
Each one is a different power, and none of them implies another:
| Scope | What it lets this call do |
|---|---|
sources:write |
Create an input on the studio |
sources:ingest |
Hand back a live publish credential for it |
viewer:tokens |
Mint the return feed (monitor only) |
sources:write reconfigures what a studio listens for. sources:ingest occupies
the wire and puts pixels on the broadcaster's program. Those are different enough
that holding one has never granted the other, and POST /contribute genuinely
does both, so it asks for both.
The monitor's scope surprises people, so here is the reasoning rather than just
the rule. It is viewer:tokens, not stream:read. stream:read is a
default scope — every key created without an explicit list carries it forever —
and this endpoint hands back a credential that plays the program. That is not
a power a key should acquire by not thinking about it. viewer:tokens is the
scope this platform already uses for minting media playback credentials, it is
opt-in by name, and a key holding it can already mint a video.play token for
the same studio through POST /v1/viewer-tokens. Requiring it here therefore
grants nothing your key could not already do — it just refuses to give the
program feed away to a key that never asked for it.
The device id is the whole design
deviceId is a value your app mints once and keeps in the Keychain (iOS) or
Keystore (Android). It is opaque to TRaX and never resolved to a person: send
an identifier for the device, not for whoever is holding it.
It is also the idempotency key, and that is what makes everything else in
this guide safe. The same deviceId always resolves to the same input on the
same studio — the same media path, the same credential, the same canvas tile.
That has two consequences worth internalising:
- A phone that reconnects lands back on its own tile. The operator's layout survives a lift ride.
- A
deviceIdyour app regenerates each launch produces a new input each launch. The operator watches their source list fill up with duplicates of one phone, and nothing in the API will stop you: from the server's side, a new device id is a new device.
Mint it once. Persist it. Never derive it from something that changes — not the session, not the login, not the network.
Step 1 — put the phone on the studio
curl -sS https://api.traxstreaming.live/v1/studios/$STUDIO/contribute \
-H "Authorization: Bearer $TRAX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "deviceId": "5C1B9E77-...-KEYCHAIN", "deviceLabel": "EJ'\''s iPhone" }'
HTTP/1.1 201 Created
{
"sourceId": "mc-9f21c4a7e0b3d6...",
"streamName": "inputs/mobile-contrib-mc-9f21c4a7e0b3d6...",
"deviceId": "5C1B9E77-...-KEYCHAIN",
"autoSeat": true,
"ingest": {
"srt": {
"complete": "srt://ingest.traxstreaming.live:8890?streamid=publish:inputs/mobile-contrib-mc-9f21...:sk_stream_...:sk_stream_...&latency=200",
"base": "srt://ingest.traxstreaming.live:8890"
}
},
"granted": ["srt"],
"streamKey": "sk_stream_...",
"srtStreamId": "publish:inputs/mobile-contrib-mc-9f21...:sk_stream_...:sk_stream_...",
"ingestHost": "ingest.traxstreaming.live",
"ingestMetro": "auto"
}
Dial ingest.srt.complete verbatim. Do not rebuild it from base +
streamKey: the credential form is the server's to decide and it has changed
before, and a client that reassembles the URL from a remembered shape keeps
sending the old one long after the server moved on.
Everything in that response except sourceId, streamName, deviceId and
ingestHost is a live publish credential. Keychain, not log line, not analytics
event, not a crash report.
201 the first time, 200 every time after
Send the same deviceId again and you get 200 with the same sourceId,
the same streamKey, the same URLs. Nothing is created and nothing is
rotated.
That is the contract this whole guide rests on. Retrying is safe, including after a timeout where you never saw the response. Nothing on this path rotates a credential, so a retry cannot invalidate the key the timed-out attempt already issued.
created is deliberately not in the body — the status code already says it, and
two answers to one question is how a client ends up trusting the wrong one.
If you want to deliberately invalidate a credential — it got pasted into a
support chat, the phone walked off — that is
POST /v1/studios/{id}/sources/{sourceId}/ingest/rotate, by name, on purpose.
See Stream to a live input.
autoSeat
autoSeat: true means this input takes a seat on the canvas by itself when the
studio is already live. It never starts the broadcast — going live is the
operator's call and nothing on this path can make it happen. Read it to tell your
user what to expect when they hit publish: a tile that appears on air, or a
source the operator still has to place.
ingestHost and ingestMetro
ingestHost is there so you can show "publishing to ingest.traxstreaming.live"
without parsing — or displaying — a string that is also a secret.
ingestMetro is "auto", and that is a measurement rather than a placeholder.
Nearest-point-of-presence selection happens in DNS, when your device resolves
the ingest hostname — not when this call mints the URL. The server genuinely does
not know which PoP you will reach, and guessing from your IP address would be
wrong for every VPN, every carrier-grade NAT and most mobile subscribers, which
is exactly the population this endpoint exists for.
Display it; do not branch on it. A later release may name a real metro here, and a client that special-cased the string would break on the improvement.
Asking for RTMP
Send protocols to get more than the SRT default:
{ "deviceId": "...", "protocols": ["srt", "rtmp"] }
protocols is a request, not a guarantee. Read granted off the response
and configure from that. Ask for exactly what you will dial — every extra entry
is another copy of the same live secret sitting in your app's memory, logs and
crash reports.
SRT is the right default for a phone. It recovers from packet loss without retransmitting the whole world, which is what a cellular uplink does to you.
Step 2 — the return feed
curl -sS -X POST https://api.traxstreaming.live/v1/studios/$STUDIO/monitor \
-H "Authorization: Bearer $TRAX_API_KEY"
No body required.
HTTP/1.1 200 OK
{
"whep": {
"url": "https://media.traxstreaming.live/s/5b2e9d41-.../pp/whep",
"token": "eyJhbGciOiJFUzI1NiIs..."
},
"srt": null,
"streamName": "s/5b2e9d41-.../pp",
"expiresAt": "2026-08-23T16:05:00Z"
}
POST your SDP offer to whep.url with Authorization: Bearer <whep.token>.
That token is not your API key and not the user's login token: it
authorises exactly this studio's program path, for a few minutes, and nothing
else — which is what makes it safe to put on a signaling request your API key has
no business appearing on.
Take the WHEP path. WebRTC degrades on a lossy cellular uplink far better than anything else here: it drops frames and keeps going where a stricter transport would stall.
The expiry is not what you think
The credential lasts 300 seconds by default (900 maximum, clamped rather than
refused if you ask for more). That sounds impossibly short for a two-hour show.
It is not, because the media server authorises a read once, at session
establishment, and never re-checks. A monitor that is already playing keeps
playing long past expiresAt. You need a fresh mint to (re)connect, not to
keep watching.
So: mint when you open the monitor, mint again if it drops, and schedule from
expiresAt — never from the TTL you asked for, which may have been clamped.
When srt is null
srt is an alternative for clients that cannot speak WHEP, and null is a real
answer rather than a missing field. The read credential rides inside SRT's
streamid, which libsrt caps at 512 bytes and rejects locally — before anything
reaches the network — when exceeded. When the composed URL would be over that
cap, or no SRT endpoint is configured, the server returns null instead of a URL
that cannot dial.
Handle it by falling back to WHEP, which is the path you should be on anyway.
A black monitor is a state, not an error
The credential is always issued when you are allowed to hold it. Whether frames flow depends on the studio being live with its program preview published. A monitor opened off-air simply shows nothing yet. Render that; do not report it as a failure.
Building for a bad connection
Everything above is designed around the assumption that your requests will sometimes not arrive. Here is what to do about it.
Timeouts
10 seconds per request. Long enough to survive a slow radio handover, short enough that a user staring at a spinner gets an answer rather than a hang.
Both endpoints here are single round trips by design, so a 10-second budget is a real budget rather than an aspiration.
Retries
Retry contribute freely. It is idempotent, it cannot double-provision, and it
cannot rotate the credential a previous attempt issued. Retry monitor freely
too — a spare monitor token costs nothing and expires on its own.
Back off exponentially with jitter: 1s, 2s, 4s, 8s, capped, with a random ±30%. The jitter matters more than it looks. Phones at an event all lose signal at the same moment and all come back at the same moment; without jitter they retry in lockstep and turn one outage into two.
Honour Retry-After
Two responses carry it, and both mean what they say:
- 429 — you are over the credential-mint budget (roughly 5 per minute per caller, with a small burst). Wait the interval; do not guess.
- 503
unavailable— the studio service is briefly unreachable. Wait the interval and retry; this is the transient one.
One 503 does not carry Retry-After, on purpose:
- 503
contribute_disabled— the device-contribute path is switched off on this deployment. There is no interval after which this succeeds. Surface it to the user; do not retry it.
Branch on error.code, never on the status alone. Two 503s that mean opposite
things is exactly why the codes exist.
Polling on cellular
Poll every 15–30 seconds, not faster. The answer you are usually waiting for — did my input come up, is the show live — is push-driven on the operator's side; your poll is a fallback, and a fallback that runs every two seconds is just a battery drain.
The three reads a phone polls support conditional requests:
GET /v1/studios/{id}/sources/{sourceId}— is my input publishing yetGET /v1/studios/{id}/sources— did my input show upGET /v1/studios/{id}/stream-status— is the show on air
Each returns an ETag. Send it back as If-None-Match and an unchanged answer
costs you a 304 with no body:
# First poll
curl -sS -D- https://api.traxstreaming.live/v1/studios/$STUDIO/sources/$SOURCE \
-H "Authorization: Bearer $TRAX_API_KEY"
# → 200 OK
# ETag: "kZ3n8Qw2r1vB7yTcXsLmPQ"
# Every poll after
curl -sS -D- https://api.traxstreaming.live/v1/studios/$STUDIO/sources/$SOURCE \
-H "Authorization: Bearer $TRAX_API_KEY" \
-H 'If-None-Match: "kZ3n8Qw2r1vB7yTcXsLmPQ"'
# → 304 Not Modified (no body)
Keep the last ETag per URL and send it every time. The tag changes the moment
the answer does, so a 304 means nothing has happened, and a 200 means
something has — which is precisely the signal you were polling for.
On a metered cellular link this is the difference between a poll that completes and a poll that times out.
What to do when you are offline
Nothing clever. A contributed input is durable: it survives your app being
killed, the phone rebooting, and the network going away entirely. When you come
back, call contribute with the same deviceId and you get your input back with
the same credential.
Do not cache the credential and skip the call — call it and let the 200 confirm
what you already had. It is one round trip and it is the only way to learn that
an operator deleted your input while you were away.
What this guide does not cover
- Going live. Contributing a camera never starts a broadcast. That is
POST /v1/studios/{id}/go-live, it needsstream:golive, and it is the operator's decision — see Getting started. - The audio mixer. Your input arrives with a channel strip the operator can
reach; you can too, with
audio:read/audio:write— see Control the mixer. - The first-party SRT flow on the studio control plane. If you are building
against the studio API rather than
/v1, see Contribute a camera from a phone, which covers the participant model, the studio WebSocket and the QR walk-up flow.
Reference
POST /v1/studios/{id}/contributePOST /v1/studios/{id}/monitor- Stream to a live input from your app — the same credential shape, for an input you created yourself
- Monitor the program output (WHEP) — the WHEP playback details
Stop polling: the events stream
Everything above is a request you make. This is the one you don't.
A phone that polls listSources and stream-status every four seconds and
destinations every eight is spending about 37 requests a minute on this
API while it is also pushing video up the same cellular uplink. Those requests
compete with your own SRT stream for the scarce direction — and they do it
hardest exactly when the link is worst and you need the video most.
Open this instead, once:
curl -N -H "Authorization: Bearer $TRAX_API_KEY" \
https://api.traxstreaming.live/v1/studios/$STUDIO_ID/events
: connected
id: 9f2c1a4b7e3d-0
event: snapshot
data: {"sources":[…],"streamStatus":{"live":false,…},"destinations":[…],"canvas":{…},"audio":{…},"chatConnectors":[…]}
id: 9f2c1a4b7e3d-1
event: source
data: {"id":"…","name":"Phone","publishing":true,"publishedAt":"2026-08-23T22:04:11Z",…}
: heartbeat 2026-08-23T22:04:26Z
The first event is a snapshot of everything. Opening this stream IS your
initial read — you do not need to GET anything to prime yourself.
Every data: is the same JSON the equivalent GET returns. A source event
is a Source, exactly as listSources gives you one. Apply an event by
replacing the row it names; there is no partial-update form to learn.
event |
what to do |
|---|---|
snapshot |
replace your whole model — sources, stream status, destinations, the canvas, the audio mixer, and every chat connector, all at once |
source |
replace (or insert) that source — this is where publishing flips |
streamStatus |
replace the stream status |
destination |
replace (or insert) that destination |
canvas |
replace your whole canvas — layout mode, active preset/scene, and every tile's placement (a CanvasState, the same shape GET …/canvas returns) |
audio |
replace your whole mixer — the master strip plus one strip per source (a StudioAudio, the same shape GET …/audio returns) |
chatConnector |
replace (or insert) that platform's chat connector — its reconnect-pill state (a ChatConnector) |
sourceRemoved / destinationRemoved |
drop the row named by data.id |
chatConnectorRemoved |
a platform was unlinked; drop the connector named by data.platform |
A destination event fires no matter where the change came from — a toggle in
the web studio, another phone, or a /v1 call of your own. The same is true for
every row here: this stream is the studio's state, not your client's echo. The
canvas and audio events are whole-object replacements — reconcile each one
by swapping your entire canvas or mixer for what it carries, exactly as you do
for snapshot.
Reconnecting
Cellular drops. That is the normal case, not the exception, and the stream is built for it.
Keep the id of the last event you processed. On reconnect, send it back:
curl -N -H "Authorization: Bearer $TRAX_API_KEY" \
-H "Last-Event-ID: 9f2c1a4b7e3d-1" \
https://api.traxstreaming.live/v1/studios/$STUDIO_ID/events
The server replays what you missed. If it cannot honour the id — too old, or a
different server — it sends a fresh snapshot instead and carries on. That is
not an error. So always send your last id, never reason about whether it will
work, and never fall back to re-GETting the three collections. A browser's
EventSource sends this header for you; if you cannot set headers, use
?lastEventId=.
Treat the id as opaque. Do not parse it, do not compare two of them.
Keepalives
Lines beginning with : are SSE comments, not events. On a quiet studio you
will see : heartbeat <time> about every fifteen seconds; that is what stops a
cellular NAT or a proxy from reaping a working-but-silent connection. A
standard EventSource ignores comments for you. If you wrote your own client,
use them to reset a dead-stream timer — and if the timer fires, reconnect with
your last id.
Scopes and limits
Requires stream:read, sources:read and destinations:read — the same three
permissions the three reads it replaces require. No new scope: a key that could
already make those calls can open this stream today.
At most 5 concurrent event streams per credential (a 429 with
Retry-After beyond that) and 32 watchers on one studio. You need one.
The stream ends when your access does — if the studio is deleted or your key
is revoked it terminates rather than sitting open against something that is
gone. Reconnect on any disconnect; back off if you get a 429.
What to keep polling
Nothing. The canvas, the audio mixer and the chat connectors all ride this one
stream now — a phone that opens it never has to poll …/canvas, …/audio or
the connector list to stay current. And it costs no extra permission: the same
stream:read, sources:read and destinations:read that open the stream carry
every event on it.