# Streams quickstart

In about twenty minutes you will create a live stream, give one customer access, open a viewing session from a browser and play a test pattern. Part 1 uses test keys and costs nothing. Part 2 uses live keys to show real video.

You need:

- A tenant with Streams switched on. If a call returns `403 product_not_enabled`, ask your Vabu contact to switch it on.
- `curl`, Node 18 or later, and [ffmpeg](https://ffmpeg.org/download.html) for part 2.
- A secret key and a publishable key. See [environments and keys](https://docs.vabu.live/get-started/environments-and-keys.md).

Set your host and your test secret key:

```sh
export API="https://api.vabu.live"
export SK="vk_test_..."
```

## Part 1: build and check with test keys

A test key never reaches a real video network, so it never bills. Streams you create are `simulated`: they stay `idle`, nothing accepts a broadcast and the playback link does not play. Everything else is real, so you can build and check your whole flow.

### 1. Create a publishable key

The browser uses a publishable key. Create one with your secret key. The full key is shown once.

```sh
curl -X POST "$API/v1/keys" \
  -H "Authorization: Bearer $SK" \
  -H "Idempotency-Key: quickstart-key-1" \
  -H "Content-Type: application/json" \
  -d '{ "type": "publishable", "name": "Quickstart page" }'
```

Copy `key` from the response into `PK`:

```sh
export PK="vpk_test_..."
```

### 2. Create a live stream

```sh
curl -X POST "$API/v1/streams/live_streams" \
  -H "Authorization: Bearer $SK" \
  -H "Idempotency-Key: quickstart-stream-1" \
  -H "Content-Type: application/json" \
  -d '{ "external_id": "quickstart-venue", "metadata": { "venue": "Kasarani" } }'
```

```json
{
  "data": {
    "id": "ls_01J9ZQ4M8T3K6Y2E5W7N0B1C9D",
    "object": "live_stream",
    "provider": "simulated",
    "status": "idle",
    "latency_mode": "low",
    "reconnect_window": 60,
    "stream_key": "sim-...",
    "ingest": { "rtmp_url": "...", "srt_url": "..." },
    "playback_ids": [{ "id": "pb_...", "policy": "signed" }],
    "livemode": false
  }
}
```

Keep the `id`:

```sh
export STREAM="ls_01J9ZQ4M8T3K6Y2E5W7N0B1C9D"
```

### 3. Give a customer access

Your own checkout would make this call after a payment. Use your own id for the customer. Here it is `user_42`.

```sh
curl -X POST "$API/v1/access/entitlements" \
  -H "Authorization: Bearer $SK" \
  -H "Idempotency-Key: quickstart-grant-1" \
  -H "Content-Type: application/json" \
  -d '{
    "customer": "user_42",
    "resource": { "type": "ls", "id": "'"$STREAM"'" },
    "source": "tenant_grant",
    "source_ref": "quickstart-grant-1",
    "limits": { "max_devices": 2, "max_concurrent_sessions": 1, "concurrency_policy": "replace_oldest" }
  }'
```

You get `201` and an entitlement with `"status": "active"`. The resource type for a live stream is `ls`, and for a video asset it is `ast`.

### 4. Create a customer token key

Your server signs a short token for each viewer. The key that signs it is made once:

```sh
curl -X POST "$API/v1/access/customer_token_keys" \
  -H "Authorization: Bearer $SK" \
  -H "Content-Type: application/json" \
  -d '{ "name": "quickstart" }'
```

The response holds `id` (starts `ctk_`) and `secret` (starts `cts_`). The secret is shown once. Keep it on your server.

Sign a token for `user_42`. This needs no packages:

```js
// sign-token.mjs
import { createHmac } from 'node:crypto'

const [keyId, secret, customer] = process.argv.slice(2)
const b64 = (value) => Buffer.from(JSON.stringify(value)).toString('base64url')
const now = Math.floor(Date.now() / 1000)

const head = b64({ alg: 'HS256', typ: 'JWT', kid: keyId })
const body = b64({ sub: customer, aud: 'vabu', iat: now, exp: now + 600 })
const signature = createHmac('sha256', secret).update(`${head}.${body}`).digest('base64url')

console.log(`${head}.${body}.${signature}`)
```

```sh
export TOKEN=$(node sign-token.mjs ctk_... cts_... user_42)
```

The token lives for 10 minutes. Real apps sign one each time the page loads. See [customer tokens](https://docs.vabu.live/concepts/customer-tokens.md) for PHP, Python and Go.

### 5. Open a session from the browser

The browser sends the publishable key and the customer token. It never sees your secret key. Paste this into the console of any web page, after setting the three values:

```js
const res = await fetch('https://api.vabu.live/v1/streams/playback_tokens', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Vabu-Publishable-Key': 'vpk_test_...',
    Authorization: 'Bearer <TOKEN>',
  },
  body: JSON.stringify({ resource: 'ls_...', device_id: 'browser-1' }),
})
console.log(await res.json())
```

```json
{
  "data": {
    "id": "as_01J9ZQ7P2H5V8X1A4D6F9G3JKM",
    "object": "access_session",
    "customer": "user_42",
    "device_id": "browser-1",
    "state": "active",
    "heartbeat_interval": 30,
    "grant": {
      "sources": [{ "provider": "simulated", "url": "https://.../....m3u8?token=..." }],
      "failover": { "max_errors": 3, "window_seconds": 20 },
      "token_expires_at": "2026-10-12T20:11:30Z"
    }
  }
}
```

That is the whole viewer path: access checked, device counted, a playback link issued. Run it again with another `device_id` and, with `replace_oldest`, the first session ends at its next heartbeat, which answers `403 session_ended` with the reason `replaced_by_newer_session`. Remove the entitlement with `DELETE /v1/access/entitlements/{id}` and the next call is refused with `403 access_denied`.

## Part 2: see real video with live keys

Live keys use a real video network, so you can watch a picture. Stop ffmpeg when you are done.

### 6. Repeat steps 1 to 4 with live keys

Use your `vk_live_...` secret key. Test and live data never mix, so the publishable key, the stream, the entitlement and the token key all need to be made again, and the new publishable key starts with `vpk_live_`. This time the live stream comes with a real ingest address:

```json
{
  "data": {
    "id": "ls_...",
    "status": "idle",
    "stream_key": "...",
    "ingest": {
      "rtmp_url": "rtmps://...",
      "rtmp_backup_url": "rtmp://...",
      "srt_url": "srt://...",
      "whip_url": null
    }
  }
}
```

### 7. Send a test pattern with ffmpeg

This command builds a moving test picture and a tone, encodes them the way a venue encoder would and sends them to your stream. Put the `rtmp_url` and the `stream_key` from step 6 in the two variables.

```sh
RTMP_URL="rtmps://..."
STREAM_KEY="..."

ffmpeg -re \
  -f lavfi -i "testsrc2=size=1280x720:rate=30" \
  -f lavfi -i "sine=frequency=440:sample_rate=48000" \
  -c:v libx264 -preset veryfast -tune zerolatency -pix_fmt yuv420p \
  -b:v 2500k -maxrate 2500k -bufsize 5000k \
  -g 60 -keyint_min 60 -sc_threshold 0 \
  -c:a aac -b:a 128k -ar 48000 \
  -f flv "$RTMP_URL/$STREAM_KEY"
```

`-g 60` at 30 frames a second gives a keyframe every 2 seconds, which is what the player needs. Leave it running.

Within about 30 seconds the stream moves from `idle` to `connecting` to `active`:

```sh
curl "$API/v1/streams/live_streams/$STREAM" -H "Authorization: Bearer $SK"
```

You can also hear about it by webhook: `streams.live_stream.active`. See [stream events](https://docs.vabu.live/concepts/stream-events.md).

### 8. Play it with hls.js

Save this as `watch.html` and serve it from your own site (any static server works). It opens a session, plays the link and keeps the session alive. The page gets its customer token from your own server: `/customer-token` is a route you write that signs a token for the signed-in viewer, as in step 4.

```html
<!doctype html>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Watch</title>
<video id="video" controls playsinline style="width: 100%; max-width: 720px; background: #000"></video>
<p id="status">Starting...</p>

<script
  src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.17/hls.min.js"
  integrity="sha384-9v3HcdYrO3D+OPDTjZ40RXocgE4GtXVCd3/mCS62JsM93JXgI1afJVuwjFvsu6ni"
  crossorigin="anonymous"
></script>
<script>
  const API = 'https://api.vabu.live'
  const KEY = 'vpk_live_...'
  const STREAM = 'ls_...'

  const video = document.getElementById('video')
  const status = document.getElementById('status')
  const deviceId = localStorage.deviceId ?? (localStorage.deviceId = 'web-' + crypto.randomUUID())

  // Your own route: it signs a customer token for whoever is signed in.
  async function customerToken() {
    return (await fetch('/customer-token', { method: 'POST' })).text()
  }

  async function call(path, body = {}) {
    const res = await fetch(API + path, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Vabu-Publishable-Key': KEY,
        Authorization: 'Bearer ' + (await customerToken()),
      },
      body: JSON.stringify(body),
    })
    const json = await res.json()
    if (!res.ok) throw new Error(json.error.code + ': ' + json.error.message)
    return json.data
  }

  async function start() {
    const session = await call('/v1/streams/playback_tokens', { resource: STREAM, device_id: deviceId })
    let link = session.grant.sources[0].url
    let hls = null

    if (window.Hls && Hls.isSupported()) {
      hls = new Hls()
      hls.loadSource(link)
      hls.attachMedia(video)
    } else {
      video.src = link // Safari and iOS play HLS natively
    }
    video.play().catch(() => {})
    status.textContent = 'Playing'

    // Every heartbeat returns a fresh link. Keep the newest one.
    setInterval(async () => {
      try {
        const next = await call(`/v1/access/sessions/${session.id}/heartbeat`)
        link = next.grant.sources[0].url
      } catch (error) {
        status.textContent = 'Stopped: ' + error.message
        hls?.destroy()
      }
    }, session.heartbeat_interval * 1000)

    // The link in use expires after about 11 minutes. Swap to the newest one every 5 minutes.
    setInterval(() => {
      if (hls) hls.loadSource(link)
      else video.src = link
    }, 5 * 60 * 1000)
  }

  start().catch((error) => (status.textContent = error.message))
</script>
```

You should see the test pattern within a few seconds. Close the tab and the session lapses within 90 seconds. Open the page on a second device and, with `replace_oldest`, the first one stops at its next heartbeat.

If you get an error, read the code in the message. The usual ones are `access_denied` (no active entitlement for that customer), `invalid_customer_token` (see the `reason` in the details) and `stream_disabled`. All codes are on the [error codes](https://docs.vabu.live/errors.md) page.

## What you built

You created a stream, granted access after a payment of your own, signed a customer token on your server and played a protected link in the browser. The same calls run a real night. Next:

- [Going live](https://docs.vabu.live/guides/streams/going-live.md) covers encoder settings and the checklist before showtime.
- [Paid access](https://docs.vabu.live/guides/streams/paid-access.md) covers device limits, revoking and replays.
