> ## Documentation Index
> Fetch the complete documentation index at: https://www.seam.co/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Stream Live Video

> Play a camera's live feed in the browser over WebRTC using a client session and the /cameras/live_views endpoints.

You can show a camera's live feed in your user's browser. Your backend creates a [client session](/docs/core-concepts/authentication/client-session-tokens) for the user. The browser then uses that client session to open a live view session and exchanges a WebRTC offer and answer with Seam. The video flows straight to the browser, and your servers never handle it.

<Warning>
  Live video is in **beta**. Seam turns on camera events, event media, and live video for each workspace separately, so [contact Seam](mailto:support@seam.co) and ask for live video to be turned on for yours. The request and response shapes on this page may change before general availability.
</Warning>

***

## Supported Providers

Check a camera's `can_stream_live_video` capability flag before you offer live video for it. The flag is `true` only for cameras whose provider supports live video and that are currently online. Ring cameras and doorbells report `false`.

| Provider | Live video |
| - | - |
| [TP-Link Tapo](/docs/device-and-system-integration-guides/tapo-cameras) | ✅ Through the paired [Seam Bridge](/docs/capability-guides/seam-bridge). The Bridge must be online. |
| [Arlo](/docs/device-and-system-integration-guides/arlo-cameras) | ✅ Through the Arlo cloud. |
| [Ring](/docs/device-and-system-integration-guides/ring-cameras) | Not available. For Ring, use [event media](./retrieve-event-media) instead. |

***

## How It Works

```mermaid theme={"dark"}
sequenceDiagram
  participant Backend as Your backend
  participant Browser as User's browser
  participant Seam
  Backend->>Seam: /client_sessions/create
  Seam-->>Backend: client session token
  Backend-->>Browser: client session token
  Browser->>Seam: /cameras/live_views/create (device_id)
  Seam-->>Browser: camera_live_view_session_id, token, expires_at
  Browser->>Browser: RTCPeerConnection.createOffer()
  Browser->>Seam: /cameras/live_views/offer (sdp_offer)
  Seam-->>Browser: camera_live_view_answer (sdp_answer)
  Browser->>Browser: setRemoteDescription(answer), play video
  Browser->>Seam: /cameras/live_views/stop
```

All three `/cameras/live_views` endpoints accept only a client session token. Your API key never reaches the browser.

***

## Before You Begin

To stream live video, you need:

* A workspace with live video turned on.
* A [connected account](/docs/core-concepts/connected-accounts) with a Tapo or Arlo camera whose `can_stream_live_video` is `true`.
* A backend that can create [client sessions](/docs/core-concepts/authentication/client-session-tokens) with your API key.
* A browser that supports WebRTC (`RTCPeerConnection`).

***

## Step 1: Create a Client Session

On your backend, create a client session that has access to the camera's connected account, and send its token to the browser. The live view can't outlast the client session, so give the session a short lifetime.

<Info>
  Live video only works with client sessions created from `connected_account_ids`, `connect_webview_ids`, `user_identifier_key`, or `user_identity_id`. Client sessions created with `customer_key` can't open a live view.
</Info>

**Request:**

<CodeGroup>
  ```javascript JavaScript theme={"dark"}
  const client_session = await seam.clientSessions.create({
    connected_account_ids: ['c9d3f2a1-7b4e-4f0a-8e2d-5a6b7c8d9e0f'],
    expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
  })

  // Send this token to the browser.
  console.log(client_session.token)
  ```

  ```python Python theme={"dark"}
  from datetime import datetime, timedelta, timezone

  client_session = seam.client_sessions.create(
      connected_account_ids=["c9d3f2a1-7b4e-4f0a-8e2d-5a6b7c8d9e0f"],
      expires_at=(datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
  )

  # Send this token to the browser.
  print(client_session.token)
  ```

  ```ruby Ruby theme={"dark"}
  client_session = seam.client_sessions.create(
    connected_account_ids: ["c9d3f2a1-7b4e-4f0a-8e2d-5a6b7c8d9e0f"],
    expires_at: (Time.now.utc + 3600).iso8601
  )

  # Send this token to the browser.
  puts client_session.token
  ```

  ```php PHP theme={"dark"}
  $client_session = $seam->client_sessions->create(
    connected_account_ids: ["c9d3f2a1-7b4e-4f0a-8e2d-5a6b7c8d9e0f"],
    expires_at: gmdate("c", time() + 3600)
  );

  // Send this token to the browser.
  echo $client_session->token;
  ```

  ```csharp C# theme={"dark"}
  var clientSession = seam.ClientSessions.Create(
    connectedAccountIds: new List<string> { "c9d3f2a1-7b4e-4f0a-8e2d-5a6b7c8d9e0f" },
    expiresAt: DateTime.UtcNow.AddHours(1).ToString("o")
  );

  // Send this token to the browser.
  Console.WriteLine(clientSession.Token);
  ```

  ```java Java theme={"dark"}
  var clientSession = seam.clientSessions().create(
    ClientSessionsCreateRequest.builder()
      .connectedAccountIds(List.of("c9d3f2a1-7b4e-4f0a-8e2d-5a6b7c8d9e0f"))
      .expiresAt(Instant.now().plus(Duration.ofHours(1)).toString())
      .build()
  );

  // Send this token to the browser.
  System.out.println(clientSession.getToken());
  ```

  ```bash cURL theme={"dark"}
  curl -X 'POST' \
    'https://connect.getseam.com/client_sessions/create' \
    -H 'accept: application/json' \
    -H "Authorization: Bearer ${SEAM_API_KEY}" \
    -H 'Content-Type: application/json' \
    -d '{
    "connected_account_ids": ["c9d3f2a1-7b4e-4f0a-8e2d-5a6b7c8d9e0f"],
    "expires_at": "2026-09-25T18:00:00.000Z"
  }'
  ```
</CodeGroup>

**Response:**

```json theme={"dark"}
{
  "client_session": {
    "client_session_id": "5e1b3f0c-2a4d-4c6e-8f0a-1b2c3d4e5f60",
    "token": "seam_cst1891oqCmE_6dBwV8PJ2Ffoe9dWYVyMfVHq",
    "connected_account_ids": ["c9d3f2a1-7b4e-4f0a-8e2d-5a6b7c8d9e0f"],
    "expires_at": "2026-09-25T18:00:00.000Z"
  }
}
```

The response includes more client session fields. See the [client session object](/docs/api/client_sessions/object).

Before you offer live video for a camera, [get the device](/docs/api/devices/get) and check that `can_stream_live_video` is `true`:

<CodeGroup>
  ```javascript JavaScript theme={"dark"}
  const device = await seam.devices.get({
    device_id: 'a83690b2-2b70-409a-9a94-426699b84c97',
  })

  if (device.can_stream_live_video) {
    // Show the live view button.
  }
  ```

  ```python Python theme={"dark"}
  device = seam.devices.get(device_id="a83690b2-2b70-409a-9a94-426699b84c97")

  if device.can_stream_live_video:
      # Show the live view button.
      pass
  ```

  ```ruby Ruby theme={"dark"}
  device = seam.devices.get(device_id: "a83690b2-2b70-409a-9a94-426699b84c97")

  if device.can_stream_live_video
    # Show the live view button.
  end
  ```

  ```php PHP theme={"dark"}
  $device = $seam->devices->get(device_id: "a83690b2-2b70-409a-9a94-426699b84c97");

  if ($device->can_stream_live_video) {
    // Show the live view button.
  }
  ```

  ```csharp C# theme={"dark"}
  var device = seam.Devices.Get(deviceId: "a83690b2-2b70-409a-9a94-426699b84c97");

  if (device.CanStreamLiveVideo == true)
  {
    // Show the live view button.
  }
  ```

  ```java Java theme={"dark"}
  var device = seam.devices().get(
    DevicesGetRequest.builder()
      .deviceId("a83690b2-2b70-409a-9a94-426699b84c97")
      .build()
  );

  if (device.getCanStreamLiveVideo().orElse(false)) {
    // Show the live view button.
  }
  ```

  ```bash cURL theme={"dark"}
  curl -X 'POST' \
    'https://connect.getseam.com/devices/get' \
    -H "Authorization: Bearer ${SEAM_API_KEY}" \
    -H 'Content-Type: application/json' \
    -d '{
    "device_id": "a83690b2-2b70-409a-9a94-426699b84c97"
  }'
  # The response includes "can_stream_live_video": true for a camera that can stream.
  ```
</CodeGroup>

The flag is `false` while the camera is offline, so check it again right before you open a live view.

***

## Step 2: Create a Live View Session

In the browser, call [`/cameras/live_views/create`](/docs/api/cameras/live_views/create) with the client session token in the `seam-client-session-token` header. A live view session reserves the camera for this viewer.

**Request:**

<CodeGroup>
  ```javascript JavaScript theme={"dark"}
  const response = await fetch(
    'https://connect.getseam.com/cameras/live_views/create',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'seam-client-session-token': clientSessionToken,
      },
      body: JSON.stringify({
        device_id: 'a83690b2-2b70-409a-9a94-426699b84c97',
        include_audio: false,
        duration_seconds: 300,
      }),
    },
  )
  const { camera_live_view_session } = await response.json()
  ```

  ```bash cURL theme={"dark"}
  curl -X 'POST' \
    'https://connect.getseam.com/cameras/live_views/create' \
    -H 'accept: application/json' \
    -H "seam-client-session-token: ${SEAM_CLIENT_SESSION_TOKEN}" \
    -H 'Content-Type: application/json' \
    -d '{
    "device_id": "a83690b2-2b70-409a-9a94-426699b84c97",
    "include_audio": false,
    "duration_seconds": 300
  }'
  ```
</CodeGroup>

**Response:**

```json theme={"dark"}
{
  "camera_live_view_session": {
    "camera_live_view_session_id": "7f3c2a1b-4d5e-4f60-8a9b-0c1d2e3f4a5b",
    "device_id": "a83690b2-2b70-409a-9a94-426699b84c97",
    "expires_at": "2026-09-25T17:08:12.000Z",
    "token": "3q2-7wEXAMPLEtokenOnlyForThisLiveViewSession"
  }
}
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `device_id` | string (UUID) | Yes | The camera to stream. The client session must have access to it. |
| `include_audio` | boolean | No | Whether to include the camera's audio. Defaults to `false`. |
| `duration_seconds` | integer | No | How long the session can last, from 1 to 600 seconds. Defaults to `600`. |

* The session ends at `expires_at`: `duration_seconds` after creation, or when the client session expires, whichever is sooner.
* The response's `token` authorizes the offer and stop calls for this session only. Keep it in memory, and don't log it.
* Seam sends `Cache-Control: no-store` on all live view responses.
* See the [camera live view objects](/docs/api/cameras/live_views/object) for the complete `camera_live_view_session` and `camera_live_view_answer` reference.

***

## Step 3: Send a WebRTC Offer

Create an `RTCPeerConnection`, add a receive-only video transceiver, and create an offer. Send the offer's SDP to [`/cameras/live_views/offer`](/docs/api/cameras/live_views/offer), along with the session ID and token.

```javascript JavaScript theme={"dark"}
const peerConnection = new RTCPeerConnection()
peerConnection.addTransceiver('video', { direction: 'recvonly' })

const offer = await peerConnection.createOffer()
await peerConnection.setLocalDescription(offer)

const offerResponse = await fetch(
  'https://connect.getseam.com/cameras/live_views/offer',
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'seam-client-session-token': clientSessionToken,
    },
    body: JSON.stringify({
      camera_live_view_session_id:
        camera_live_view_session.camera_live_view_session_id,
      token: camera_live_view_session.token,
      sdp_offer: offer.sdp,
    }),
  },
)
const { camera_live_view_answer } = await offerResponse.json()
```

**Response:**

```json theme={"dark"}
{
  "camera_live_view_answer": {
    "sdp_answer": "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=-\r\n..."
  }
}
```

`camera_live_view_answer.sdp_answer` is Seam's WebRTC SDP answer, at most 64 KiB.

Seam connects to the camera while handling this request, so the response can take several seconds.

* **One offer per session.** Once you send an offer, you can't send another one on the same session. To reconnect, create a new live view session.
* **Send the offer promptly.** If the stream hasn't started within 30 seconds of creating the session, Seam closes the session.
* **Match audio to `include_audio`.** If `include_audio` is `false`, the offer must not contain an audio section, so add only a video transceiver. If it's `true`, also call `peerConnection.addTransceiver('audio', { direction: 'recvonly' })`.
* **No ICE trickling.** Seam ignores the ICE candidates in your offer and puts its own candidates in the answer. You don't need to wait for ICE gathering, and there's no endpoint for sending more candidates.
* **Size limit.** `sdp_offer` can be at most 64 KiB.

***

## Step 4: Play the Stream

Apply the answer, then attach the incoming track to a `<video>` element.

```javascript JavaScript theme={"dark"}
const video = document.querySelector('video')

peerConnection.ontrack = (event) => {
  video.srcObject = event.streams[0] ?? new MediaStream([event.track])
}

await peerConnection.setRemoteDescription({
  type: 'answer',
  sdp: camera_live_view_answer.sdp_answer,
})
```

Set `ontrack` before calling `setRemoteDescription`. Add `autoplay`, `muted`, and `playsinline` to the `<video>` element so that browsers start playback without a click. Unmute after a user gesture if you requested audio.

***

## Step 5: Stop the Stream

When the user closes the viewer, close the peer connection and call [`/cameras/live_views/stop`](/docs/api/cameras/live_views/stop). This frees the camera for the next viewer right away. Otherwise, Seam frees it when it notices the connection has closed or when the session expires.

```javascript JavaScript theme={"dark"}
peerConnection.close()

await fetch('https://connect.getseam.com/cameras/live_views/stop', {
  method: 'POST',
  keepalive: true,
  headers: {
    'Content-Type': 'application/json',
    'seam-client-session-token': clientSessionToken,
  },
  body: JSON.stringify({
    camera_live_view_session_id:
      camera_live_view_session.camera_live_view_session_id,
    token: camera_live_view_session.token,
  }),
})
```

`keepalive: true` lets the request finish when it's sent from a `pagehide` handler. Stopping a session that has already ended succeeds and returns `{}`.

***

## Limits and Session Lifetime

| Limit | Value |
| - | - |
| Maximum `duration_seconds` | 600 seconds (10 minutes). Create a new session to keep watching. |
| Time to start streaming | 30 seconds from creating the session. |
| Viewers per camera | One at a time. A second viewer gets `camera_live_view_limit` until the first session ends. |
| Offers per session | One. |
| `sdp_offer` size | 64 KiB. |

Seam keeps checking that the viewer still has access while the stream is open. A live view ends when any of the following happens:

* The session reaches `expires_at`.
* You call `/cameras/live_views/stop`.
* The browser closes the peer connection.
* The client session expires or is revoked, or it loses access to the camera.
* The camera is removed from Seam, or its connected account is deleted.

To keep watching past `expires_at`, create a new live view session and connect a new peer connection before the old one ends. Watch `peerConnection.connectionState` for `failed` or `closed` to detect the end of a stream.

***

## Errors

| HTTP status | `error.type` | Cause |
| - | - | - |
| `400` | `invalid_input` | A parameter is missing or out of range. For example, `duration_seconds` isn't between 1 and 600, or `sdp_offer` is larger than 64 KiB. |
| `404` | `camera_not_available` | The camera doesn't exist, the client session doesn't have access to it, the camera doesn't support live video, live video isn't turned on for the workspace, or the live view session ID or token is wrong or has expired. |
| `409` | `camera_live_view_limit` | Another live view is already open for this camera, Seam has no live view capacity available right now, or an offer was already sent for this session. |
| `503` | `camera_media_unavailable` | Seam couldn't start the stream. For example, the camera or Bridge is offline, the provider refused the stream, or the offer contains audio when `include_audio` is `false`. |

For `409` and `503`, wait a few seconds and create a new live view session. Retry with backoff, and stop after a few attempts.

***

## Complete Example

This example serves a web page that plays one camera's live feed. It uses Node.js 18 or later and the `seam` package.

1. Run `npm install seam`.
2. Set `SEAM_API_KEY`, `SEAM_CONNECTED_ACCOUNT_ID`, and `SEAM_DEVICE_ID` to your API key, the camera's connected account, and the camera's device ID.
3. Save both files in the same directory and run `node server.mjs`.
4. Open `http://localhost:3000` and click **Start**.

In production, authenticate your user before creating a client session, and only include the connected accounts that belong to that user.

<Accordion title="server.mjs">
  ```javascript JavaScript theme={"dark"}
  import { createServer } from 'node:http'
  import { readFile } from 'node:fs/promises'
  import { Seam } from 'seam'

  const { SEAM_API_KEY, SEAM_CONNECTED_ACCOUNT_ID, SEAM_DEVICE_ID } = process.env
  if (!SEAM_API_KEY || !SEAM_CONNECTED_ACCOUNT_ID || !SEAM_DEVICE_ID) {
    throw new Error(
      'Set SEAM_API_KEY, SEAM_CONNECTED_ACCOUNT_ID, and SEAM_DEVICE_ID.',
    )
  }

  const seam = new Seam({ apiKey: SEAM_API_KEY })

  createServer(async (req, res) => {
    if (req.method === 'GET' && req.url === '/') {
      res.writeHead(200, { 'Content-Type': 'text/html' })
      res.end(await readFile(new URL('./index.html', import.meta.url)))
      return
    }

    if (req.method === 'POST' && req.url === '/camera-session') {
      // Authenticate your user here before creating a client session.
      const client_session = await seam.clientSessions.create({
        connected_account_ids: [SEAM_CONNECTED_ACCOUNT_ID],
        expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
      })
      res.writeHead(200, {
        'Content-Type': 'application/json',
        'Cache-Control': 'no-store',
      })
      res.end(
        JSON.stringify({
          client_session_token: client_session.token,
          device_id: SEAM_DEVICE_ID,
        }),
      )
      return
    }

    res.writeHead(404).end()
  }).listen(3000, () => console.log('Open http://localhost:3000'))
  ```
</Accordion>

<Accordion title="index.html">
  ```html HTML theme={"dark"}
  <!doctype html>
  <html>
    <body>
      <video autoplay muted playsinline controls width="640"></video>
      <p>
        <button id="start">Start</button>
        <button id="stop" disabled>Stop</button>
        <span id="status"></span>
      </p>
      <script type="module">
        const SEAM_API_URL = 'https://connect.getseam.com'
        const video = document.querySelector('video')
        const startButton = document.querySelector('#start')
        const stopButton = document.querySelector('#stop')
        const status = document.querySelector('#status')

        let clientSessionToken
        let liveViewSession
        let peerConnection

        async function seamPost(path, body, options = {}) {
          const response = await fetch(SEAM_API_URL + path, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              'seam-client-session-token': clientSessionToken,
            },
            body: JSON.stringify(body),
            ...options,
          })
          const json = await response.json().catch(() => ({}))
          if (!response.ok) {
            throw new Error(json.error?.type ?? `HTTP ${response.status}`)
          }
          return json
        }

        async function start() {
          startButton.disabled = true
          status.textContent = 'Connecting…'
          try {
            // 1. Get a client session token from your backend.
            const session = await fetch('/camera-session', { method: 'POST' })
            const { client_session_token, device_id } = await session.json()
            clientSessionToken = client_session_token

            // 2. Reserve the camera.
            const { camera_live_view_session } = await seamPost(
              '/cameras/live_views/create',
              { device_id, include_audio: false, duration_seconds: 300 },
            )
            liveViewSession = camera_live_view_session

            // 3. Create a receive-only offer.
            peerConnection = new RTCPeerConnection()
            peerConnection.addTransceiver('video', { direction: 'recvonly' })
            peerConnection.ontrack = (event) => {
              video.srcObject = event.streams[0] ?? new MediaStream([event.track])
            }
            peerConnection.onconnectionstatechange = () => {
              status.textContent = peerConnection.connectionState
              if (['failed', 'closed'].includes(peerConnection.connectionState)) {
                stop()
              }
            }
            const offer = await peerConnection.createOffer()
            await peerConnection.setLocalDescription(offer)

            // 4. Exchange the offer for Seam's answer.
            const { camera_live_view_answer } = await seamPost(
              '/cameras/live_views/offer',
              {
                camera_live_view_session_id:
                  liveViewSession.camera_live_view_session_id,
                token: liveViewSession.token,
                sdp_offer: offer.sdp,
              },
            )
            await peerConnection.setRemoteDescription({
              type: 'answer',
              sdp: camera_live_view_answer.sdp_answer,
            })
            stopButton.disabled = false
          } catch (error) {
            status.textContent = `Could not start: ${error.message}`
            await stop()
          }
        }

        async function stop() {
          stopButton.disabled = true
          startButton.disabled = false
          peerConnection?.close()
          peerConnection = undefined
          video.srcObject = null
          const session = liveViewSession
          liveViewSession = undefined
          if (session == null) return
          // 5. Free the camera for the next viewer.
          await seamPost(
            '/cameras/live_views/stop',
            {
              camera_live_view_session_id: session.camera_live_view_session_id,
              token: session.token,
            },
            { keepalive: true },
          ).catch(() => {})
        }

        startButton.addEventListener('click', start)
        stopButton.addEventListener('click', stop)
        window.addEventListener('pagehide', stop)
      </script>
    </body>
  </html>
  ```
</Accordion>

***

## Next Steps

* [Camera Live Views reference](/docs/api/cameras/live_views/object): Full request and response reference for `/cameras/live_views/create`, `/cameras/live_views/offer`, and `/cameras/live_views/stop`.
* [Retrieve event media](./retrieve-event-media): Get the clip and thumbnail for motion and doorbell events.
* [Client session tokens](/docs/core-concepts/authentication/client-session-tokens): Scope client sessions to each user's devices.
* [Seam Bridge](/docs/capability-guides/seam-bridge): Set up the Bridge that Tapo cameras need.
