# Request playback

Issues a short-lived playback ticket for one episode. Your backend calls this each time a viewer starts an episode and passes the result to the Player SDK on the viewer's device.

```
POST /api/delivery/v1/playback
```

**Scope:** `playback` (create it yourself on **Developers** → **Stream films in an app**; if it leaks, anyone can stream your licensed films, so revoke it immediately)

## Description

Wandura checks, on every request, that your organisation is active and that your streaming licence for the episode's film is active and inside its window. It then returns a play token bound to the viewer's device. Licensed episodes are encrypted and play only through the Player SDK, so you must send the device values `union_info` and `drm_kek`. See [Playback](https://docs.wandura.ai/developers/concepts/playback).

Call it only from your backend. The endpoint sends no CORS headers.

## Request parameters

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `Authorization` | header | string | Yes | `Bearer <API key>` for a key with the `playback` scope. |
| `Content-Type` | header | string | Recommended | `application/json`. The body must be JSON. |
| `episode_id` | body | string (UUID) | One of `episode_id` or `film_ref` + `episode_no` | The episode's `id` from the catalog. Takes precedence if you also send `film_ref`. |
| `film_ref` | body | string | With `episode_no`, if no `episode_id` | The film's `id` or `slug`. |
| `episode_no` | body | integer | With `film_ref`, if no `episode_id` | Episode number, starting at 1. Must be a JSON number, not a string. |
| `union_info` | body | string | Yes, for encrypted episodes | Device ID generated by the Player SDK on the viewer's device. Send together with `drm_kek`. |
| `drm_kek` | body | string | Yes, for encrypted episodes | Device public key generated by the Player SDK. Send together with `union_info`. |
| `intent` | body | string | No | `play` when the viewer tapped the episode, `prefetch` when your app loads it ahead of a tap. Prefetch requests are not counted as plays. A missing or other value is recorded as unknown, never rejected, and counted as a play. |
| `viewer_ref` | body | string | No | Your own opaque session or device identifier, stored with the ticket for support lookups. Do not include personal data. |

`union_info` and `drm_kek` are rejected with `400` `invalid_device_info` when, after trimming spaces, a value is shorter than 8 characters or is one of `(null)`, `null`, `nil`, `undefined`, `nan`, `<null>` or `none` (in any letter case).

## Request example

```bash
curl -s -X POST https://www.wandura.ai/api/delivery/v1/playback \
  -H "Authorization: Bearer $WANDURA_PLAYBACK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "film_ref": "the-heiress-returns",
    "episode_no": 1,
    "union_info": "<device ID from the Player SDK>",
    "drm_kek": "<device public key from the Player SDK>",
    "intent": "play",
    "viewer_ref": "u_48213:dev_9a7c"
  }'
```

## Response

`200 OK`

| Field | Type | Description |
|---|---|---|
| `vid` | string | Video ID to load in the Player SDK. |
| `play_auth_token` | string | Play token to hand to the Player SDK. Keep it secret and do not log it. |
| `expires_at` | string (ISO 8601, UTC) | When the ticket expires: 30 minutes after it was issued. |
| `protection` | string | Protection level of what you received. `private-encrypted` for licensed episodes. Only sell paid episodes when it is `private-encrypted`. |
| `subtitles` | array | Subtitles attached to the episode. Empty when none are attached. |
| `subtitles[].lang` | string | Language code, for example `en`, `vi` or `pt-BR`. |
| `subtitles[].url` | string | URL of the subtitle file. Not tied to the ticket's expiry. |
| `subtitles[].format` | string | Always `webvtt`. |

Subtitles are returned in `lang` order.

## Response example

```json
{
  "vid": "v0d2f8c1a9e4b7",
  "play_auth_token": "eyJhbGciOi...placeholder...",
  "expires_at": "2026-10-01T12:30:00.000Z",
  "protection": "private-encrypted",
  "subtitles": [
    { "lang": "en", "url": "https://cdn.example.com/subtitles/heiress-ep1-en.vtt", "format": "webvtt" },
    { "lang": "vi", "url": "https://cdn.example.com/subtitles/heiress-ep1-vi.vtt", "format": "webvtt" }
  ]
}
```

## Errors

| Status | `error_code` | When |
|---|---|---|
| 400 | none | The body is not valid JSON, or has neither `episode_id` nor `film_ref` with a numeric `episode_no`. |
| 400 | `invalid_request` | Only one of `union_info` and `drm_kek` was sent. |
| 400 | `invalid_device_info` | A device value looks empty or is too short. The `fields` array names it. |
| 401 | none | The `Authorization: Bearer` header is missing, or the key is unknown or revoked. |
| 403 | none | The key lacks the `playback` scope. |
| 403 | `not_entitled` | Your organisation may not stream this film right now. `reason` is one of `org_suspended`, `entitlement_not_found`, `entitlement_suspended`, `streaming_disabled`, `outside_window`. |
| 404 | `not_ready` | The film or episode is unknown, not published, or not ready to stream. |
| 409 | `sdk_required` | The episode is encrypted and the request had no device values. The body also carries `protection`. |
| 429 | `rate_limited` | The playback rate limit was reached. See `Retry-After`. |
| 502 | none | The stream could not be prepared or the play token could not be signed. Retry with backoff. |
| 503 | `not_ready` | No playable version of the episode is available right now. |
| 503 | `service_unavailable` | Wandura could not verify the API key in time. The key is not wrong: retry after `Retry-After` seconds, with backoff. |
| 503 | `service_unavailable` | The video provider did not answer in time (`The video provider did not answer in time — retry`). The episode is not missing: retry after `Retry-After` seconds, with backoff. |

Example `403`:

```json
{
  "error": "outside_window",
  "error_code": "not_entitled",
  "reason": "outside_window"
}
```

See [Errors](https://docs.wandura.ai/developers/concepts/errors#playback-errors) for what to do in each case.
