# Playback

A short-lived, signed URL a player loads to watch a stream live or, once
it has ended, its recording. Mint one per viewer session from your
server; never cache one for everyone.

Base URL: `https://api.cardinalstreaming.com`. Authenticate with `Authorization: Bearer <your API key>`.

## Get a playback URL

`GET /v1/streams/{playback_id}/playback`

A signed HLS URL for a player, valid for six hours: the live stream
while it is on air, and the recording once it has ended.

Call it from **your server** for each viewer session. For a private
stream, pass `viewer=1` with the viewer's access code (and their IP
address in `X-Viewer-IP`, so wrong codes are limited per viewer rather
than against your server). A call without `viewer=1` is your own server
and is trusted.

When the live room is on, the response also carries a viewer pass for
it (`room`), and, when captions are on, the caption tracks.

### Path parameters

| Name | Type | Required | Description |
|---|---|---|---|
| `playback_id` | string | yes | The stream's `playback_id`. |

### Query parameters

| Name | Type | Required | Description |
|---|---|---|---|
| `viewer` | string | no | `1` when this pass is for a viewer and should be checked against the stream's access code. Left out, the call is trusted as your own. One of `"1"`, `"true"`. |
| `access_code` | string | no | The viewer's access code for a private stream (or send `X-Access-Code`). |
| `viewer_ip` | string | no | The viewer's IP address (or send `X-Viewer-IP`). |

### Headers

| Name | Type | Required | Description |
|---|---|---|---|
| `X-Viewer-Name` | string | no | The name of a viewer you have signed in, percent-encoded UTF-8, 1-40 characters. Shown in the room. Send it only for a viewer whose identity you have verified. |
| `X-Viewer-Member` | string | no | `1` to mark the named viewer as a member of the customer's community. Only with `X-Viewer-Name`. One of `"1"`. |
| `X-Access-Code` | string | no | The viewer's access code for a private stream. |
| `X-Viewer-IP` | string | no | The viewer's IP address, so wrong access codes are limited per viewer. |

### Examples

cURL:

```bash
curl "https://api.cardinalstreaming.com/v1/streams/1846bdac2d8c0ce0e846d4a3817012c9/playback" \
  -H "Authorization: Bearer $CARDINAL_API_KEY"
```

Python:

```python
import os

import requests

response = requests.get(
    "https://api.cardinalstreaming.com/v1/streams/1846bdac2d8c0ce0e846d4a3817012c9/playback",
    headers={
        "Authorization": f"Bearer {os.environ['CARDINAL_API_KEY']}",
    },
    timeout=30,
)
print(response.status_code, response.text)
```

JavaScript:

```js
const response = await fetch('https://api.cardinalstreaming.com/v1/streams/1846bdac2d8c0ce0e846d4a3817012c9/playback', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${process.env.CARDINAL_API_KEY}`,
  },
});
const data = await response.json();
console.log(response.status, data);
```

TypeScript:

```ts
const response: Response = await fetch('https://api.cardinalstreaming.com/v1/streams/1846bdac2d8c0ce0e846d4a3817012c9/playback', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${process.env.CARDINAL_API_KEY}`,
  },
});
const data: unknown = await response.json();
console.log(response.status, data);
```

PHP:

```php
<?php
require 'vendor/autoload.php'; // composer require guzzlehttp/guzzle

$client = new GuzzleHttp\Client();
$response = $client->request('GET', 'https://api.cardinalstreaming.com/v1/streams/1846bdac2d8c0ce0e846d4a3817012c9/playback', [
    'headers' => [
        'Authorization' => 'Bearer ' . getenv('CARDINAL_API_KEY'),
    ],
    'http_errors' => false,
]);
echo $response->getStatusCode(), ' ', $response->getBody(), PHP_EOL;
```

Ruby:

```ruby
require 'json'
require 'net/http'

uri = URI("https://api.cardinalstreaming.com/v1/streams/1846bdac2d8c0ce0e846d4a3817012c9/playback")
request = Net::HTTP::Get.new(uri)
request['Authorization'] = "Bearer #{ENV.fetch('CARDINAL_API_KEY')}"

response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
puts response.code, response.body
```

Go:

```go
package main

import (
	"fmt"
	"io"
	"net/http"
	"os"
)

func main() {
	req, err := http.NewRequest("GET", "https://api.cardinalstreaming.com/v1/streams/1846bdac2d8c0ce0e846d4a3817012c9/playback", nil)
	if err != nil {
		panic(err)
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("CARDINAL_API_KEY"))

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	data, _ := io.ReadAll(resp.Body)
	fmt.Println(resp.StatusCode, string(data))
}
```

Java:

```java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class Example {
    public static void main(String[] args) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://api.cardinalstreaming.com/v1/streams/1846bdac2d8c0ce0e846d4a3817012c9/playback"))
            .header("Authorization", "Bearer " + System.getenv("CARDINAL_API_KEY"))
            .method("GET", HttpRequest.BodyPublishers.noBody())
            .build();
        HttpResponse<String> response = HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.ofString());
        System.out.println(response.statusCode() + " " + response.body());
    }
}
```

C#:

```csharp
using System.Net.Http.Headers;
using System.Text;

using var client = new HttpClient();
using var request = new HttpRequestMessage(new HttpMethod("GET"), "https://api.cardinalstreaming.com/v1/streams/1846bdac2d8c0ce0e846d4a3817012c9/playback");
request.Headers.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("CARDINAL_API_KEY"));

using var response = await client.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
```

### Response 200

The playback URL, when it expires, and the room pass and caption tracks when they apply.

| Name | Type | Required | Description |
|---|---|---|---|
| `playback_url` | string | yes | The HLS URL a player loads. A credential; mint one per viewer session. |
| `expires_at` | string (date-time) | yes |  |
| `room` | object | no |  |
| `room.url` | string | yes | The room's WebSocket URL. |
| `room.token` | string | yes | The pass. A credential. |
| `room.expires_at` | string (date-time) | yes |  |
| `captions` | array of object | no |  |
| `captions[].lang` | string | yes |  |
| `captions[].name` | string | yes |  |
| `captions[].kind` | string | yes |  One of `"source"`, `"translation"`. |
| `captions[].beta` | boolean | no |  |

```json
{
  "playback_url": "https://media.cardinal.stream/1846bdac2d8c0ce0e846d4a3817012c9/master.m3u8?t=v1.eyJw...",
  "expires_at": "2026-09-20T20:41:07Z"
}
```

### Errors

| Status | Code | Meaning |
|---|---|---|
| 400 | `invalid_viewer` | `viewer` is not `1` or `true`. |
| 400 | `invalid_viewer_name` | `X-Viewer-Name` is not a 1-40 character name, percent-encoded. |
| 400 | `invalid_viewer_member` | `X-Viewer-Member` is not `1`, or was sent without `X-Viewer-Name`. |
| 403 | `access_code_required` | The stream is private and no access code was given. |
| 403 | `access_code_invalid` | That access code is not this stream's. |
| 404 | `not_found` | No such stream in your organization. |
| 409 | `not_published_yet` | No video has arrived for this stream yet. |
| 410 | `recording_deleted` | The stream has ended and its recording was deleted. |
| 410 | `recording_expired` | The stream has ended and its recording is past its 30 days. |
| 429 | `too_many_attempts` | Too many wrong access codes from this viewer; try again after `Retry-After` seconds. |
| 503 | `busy` | Too many viewers are being checked at once; retry after `Retry-After` seconds. |
| 503 | `playback_unconfigured` | Playback is not available on this deployment. |
