# Web Quickstart — Live Video in a Web Page

> Join a live Synento session from the browser with two-way audio and video, using a short-lived connection token minted by your server.

Join a live Synento session from the browser with two-way audio and video. It
takes two pieces: a small server route that mints a short-lived connection
token, and a page that uses `@synento/client` to publish and receive media.

## Never put your API key in the browser

Your `sk_live_…` key can create sessions, read recordings, and change billing.
It must stay on your server. The browser only ever receives a **connection
token**: a short-lived credential scoped to one session and one user.

```
browser  ──POST /api/connection──▶  your server  ──sk_live_…──▶  Synento API
   ◀── { wsUrl, token } ──────────────────────────────────────────┘
```

## 1. Install

```bash
npm install @synento/client     # browser
npm install @synento/node       # your server
```

## 2. Mint a token on your server

This example is a Next.js route handler; any server framework works the same
way. Create the session once (or look up an existing one), then mint a token
for the user who is joining.

```ts
// app/api/connection/route.ts
import { createSynentoClient, resolveWsUrl } from '@synento/node'

// Reads SYNENTO_API_KEY and SYNENTO_API_URL from the environment.
const synento = createSynentoClient()

// Without this, Next may statically optimise the route and serve a stale token.
export const dynamic = 'force-dynamic'

export async function POST(request: Request) {
  const { sessionId, userId } = await request.json()

  // Authenticate the caller here — whoever gets a token can join this session.
  const token = await synento.createConnectionToken(sessionId, { userId })

  return Response.json({
    expiresIn: token.expiresIn,
    token: token.accessToken,
    // Let the SDK build this. Assembling the URL by hand works in production
    // and then fails on localhost, because the browser and the API commonly
    // disagree about how to spell loopback — and the rejection arrives as a
    // close(1008) *after* a successful upgrade, so it surfaces as a call that
    // silently never connects rather than as an error.
    wsUrl: resolveWsUrl({
      apiBaseUrl: process.env.SYNENTO_API_URL ?? 'https://api.synento.com',
      requestOrigin: request.headers.get('origin'),
      sessionId,
      userId,
    }),
  })
}
```

## 3. Join from the browser

`SynentoRoom` handles encoding, transport, decoding, backpressure, and
reconnection. Note that `getToken` is a *function*: the SDK calls it again on
every reconnect, so tokens rotate without any work from you.

```ts
import { SynentoRoom } from '@synento/client'

const support = SynentoRoom.checkSupport()
if (!support.supported) {
  throw new Error(support.reason) // e.g. this browser lacks WebCodecs
}

const userId = 'alice'
const sessionId = 'YOUR_SESSION_ID'

const room = new SynentoRoom({
  userId,
  getToken: async () => {
    const res = await fetch('/api/connection', {
      body: JSON.stringify({ sessionId, userId }),
      headers: { 'Content-Type': 'application/json' },
      method: 'POST',
    })
    if (!res.ok) throw new Error('could not mint a connection token')
    return res.json() // { wsUrl, token }
  },
})

// Render each remote participant into a canvas you provide.
room.on('participantsChanged', (participants) => {
  for (const id of participants) {
    if (document.querySelector(`canvas[data-user="${id}"]`)) continue
    const canvas = document.createElement('canvas')
    canvas.width = 640
    canvas.height = 480
    canvas.dataset.user = id
    document.body.append(canvas)
    room.attachParticipant(id, canvas)
  }
})

await room.connect()

// Publish camera and microphone. The <video> element is your local preview.
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true })
const preview = document.querySelector('#preview')
preview.srcObject = stream
await room.publishCamera(stream, preview)
```

Open the page in two browser tabs with **different** user ids and you will see
and hear both participants.

## Other things you can do

```ts
room.setCameraEnabled(false)     // mute video, keep audio flowing
await room.publishScreen()       // share the screen (prompts the user)
room.unpublishScreen()
room.sendChat('hello everyone')  // chat is persisted with the recording
room.on('chat', (senderId, message) => console.log(senderId, message.message))
room.close()                     // leave and release devices
```

The `chat` event fires on the sender too — the server broadcasts the stored
record to everyone in the session, so `senderId` is your own user id for your
own messages. Render from the event alone; appending the text locally when you
call `sendChat` shows your messages twice.

Connection state is observable, which is what you want for a status indicator:

```ts
room.on('connected', () => setStatus('Connected'))
room.on('reconnecting', (attempt) => setStatus(`Reconnecting (${attempt})…`))
room.on('disconnected', (reason) => setStatus(`Disconnected: ${reason}`))
room.on('error', (err) => console.error(err))
```

## Using React

`@synento/react` wraps the same client:

```tsx
import { SynentoProvider, VideoTile, useParticipants } from '@synento/react'

function Call() {
  const participants = useParticipants()
  return participants.map((id) => <VideoTile key={id} participantId={id} />)
}

export default function App() {
  return (
    <SynentoProvider getToken={getToken} userId="alice">
      <Call />
    </SynentoProvider>
  )
}
```

## Browser support

Synento encodes with WebCodecs, so it needs Chrome/Edge 94+, Safari 16.4+, or
Firefox 133+. `SynentoRoom.checkSupport()` tells you before you try, so you can
show a clear message rather than failing at connect time.

## When the session ends

Nothing else to do — the session's durable streams *are* the recording. It is
immediately replayable and exportable to MP4, with no encoding step. See
[Recordings & Replay](https://synento.com/docs/guides/recordings-replay.md).
