# Playback and access sessions

A viewer needs two things to watch a protected stream: an **entitlement** (the right to watch) and a **session** (one viewer on one device, watching now). You create the entitlement after a payment. The session is opened every time someone presses play.

## Open a session

Open a session for a live stream or an asset with `POST /v1/streams/playback_tokens`. It does the same as `POST /v1/access/sessions` and adds the playback link.

From your server, name the customer:

```http
POST /v1/streams/playback_tokens
Authorization: Bearer vk_live_...

{ "resource": "ls_...", "customer": "user_42", "device_id": "living-room-tv" }
```

From a browser, send a publishable key and the [customer token](https://docs.vabu.live/concepts/customer-tokens.md), and leave `customer` out. The customer is the token's `sub`.

```http
POST /v1/streams/playback_tokens
Vabu-Publishable-Key: vpk_live_...
Authorization: Bearer <customer token>

{ "resource": "ls_...", "device_id": "browser-1" }
```

Vabu checks the entitlement and its limits, applies the device rules and returns the session:

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

`device_id` is a string your app makes up and keeps for each device, for example one saved in local storage. The same `device_id` opening again replaces its own earlier session.

## The grant

`grant.sources` is an ordered list of playback links. Play the first. If the player reports `failover.max_errors` fatal errors within `failover.window_seconds`, switch to the next. There is one source today, so the list has one item. Code the switch anyway, so more sources work without a change.

A `public` stream has a plain link with no token, and `token_expires_at` is `null`.

## Keep the session alive

Send a heartbeat every `heartbeat_interval` seconds (30):

```http
POST /v1/access/sessions/as_.../heartbeat
```

A session that misses two heartbeats ends, about 90 seconds after the last one. A heartbeat returns the session with a fresh `grant`.

**The link in your player expires.** Its token lasts for the session's length plus 10 minutes, so about 11 minutes. Each heartbeat returns a new link with a new token. Swap the player to the newest link every 5 minutes or so. With hls.js that is `hls.loadSource(newUrl)`. The [quickstart](https://docs.vabu.live/guides/streams/quickstart.md#_8-play-it-with-hls-js) shows it. A viewer who is watching a live stream rejoins at the live edge when you swap.

End a session when the viewer leaves:

```http
DELETE /v1/access/sessions/as_...
```

## Device and session limits

The entitlement's `limits` decide what a second device does. See [paid access](https://docs.vabu.live/guides/streams/paid-access.md#limit-devices).

| Limit | Effect |
|---|---|
| `max_devices` | The number of different `device_id` values that may be used. A new device beyond it gets `403 device_limit_reached`. |
| `max_concurrent_sessions` with `concurrency_policy: "reject_new"` | The new session is refused with `409 concurrency_limit_reached`. |
| `max_concurrent_sessions` with `concurrency_policy: "replace_oldest"` | The new session opens. The oldest one ends, and its next heartbeat gets `403 session_ended`. The latest device wins. |
| `max_views` | The number of sessions the customer may open in total. Beyond it, `403 view_limit_reached`. |
| `allowed_countries` | Viewers must be in these countries. |
| `allowed_domains` | The page must be on one of these hosts. `example.com` does not cover `www.example.com`. |

A browser cannot state its own country or domain. With a publishable key, the domain is the host of the request's `Origin` header and the country is the `ctry` claim your server put in the customer token. An entitlement that limits countries or domains refuses a browser request that carries neither.

## When a session ends

A heartbeat on an ended session returns `403 session_ended`, and `error.details.reason` says why.

| Reason | Meaning |
|---|---|
| `replaced_by_newer_session` | Another device took over under `replace_oldest`. |
| `missed_heartbeats` | The page stopped sending heartbeats, for example the tab was closed or went offline. |
| `entitlement_inactive` | You revoked or suspended access, or it expired. |
| `client` | The viewer or your app ended it. |

Show a clear message for each. "You are watching on another device" is much kinder than a frozen picture.

## Errors when opening a session

| Code | HTTP | Meaning |
|---|---|---|
| `access_denied` | 403 | No active entitlement. `error.details.reason` says why. |
| `device_limit_reached` | 403 | `max_devices` is used up. |
| `view_limit_reached` | 403 | `max_views` is used up. |
| `concurrency_limit_reached` | 409 | Another session is open and the policy is `reject_new`. |
| `stream_disabled` | 409 | The live stream is switched off. |
| `asset_not_ready` | 409 | The asset is still processing. |
| `no_playback_id` | 409 | The stream or asset has no way to be played yet. |
| `invalid_customer_token` | 401 | The browser's token was rejected. See [customer tokens](https://docs.vabu.live/concepts/customer-tokens.md). |
| `product_not_enabled` | 403 | Streams is not switched on for your account. |

A refused open does not use a view or a device slot. All codes are on the [error codes](https://docs.vabu.live/errors.md) page.
