guides

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:

  1. 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.
  2. 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/v1 with a production key, or https://api-dev.traxstreaming.live/v1 with a dev key. Only the host differs.

Before you start

  • An API key holding sources:ingest, sources:write and viewer: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. Add sources:read and studios:read if 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 deviceId your 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 yet
  • GET /v1/studios/{id}/sourcesdid my input show up
  • GET /v1/studios/{id}/stream-statusis 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 needs stream: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

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.