# Request playback

Issues short-lived playback tickets: one episode, or a batch of up to 3 (the episode a viewer starts plus up to two prefetches). Your backend calls this each time a viewer starts an episode and passes the result to the Player SDK on the viewer's device. Lifetime, reuse and batch rules: [Canonical API behaviour](https://docs.wandura.ai/developers/reference/behaviour#tickets).

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

**Scope:** `playback` (**Developers** → **Stream films in an app**; see [Authentication](https://docs.wandura.ai/developers/authentication#scopes))

## 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. |
| `Wandura-Version` | header | string | No | Optional. A dated API version (YYYY-MM-DD) to use for this request instead of the version pinned to your API key. Unknown or retired values answer 400 with `error_code` `unsupported_version`. |
| `Wandura-Simulate` | header | string | No | Optional, test organisations only. Asks Wandura to answer with a simulated error so you can test how your app handles it, for example `rate_limited`, `service_unavailable` or `not_entitled:outside_window`. The simulated response has the same status, body and headers as the real one, plus a `Wandura-Simulated` header naming the scenario. It issues no ticket and does not count against your rate limits, except `partial_batch`, whose first item is a real ticket. Simulated responses have their own limit of 300 per minute per organisation. Keys of production organisations ignore this header completely. An unknown value, or a scenario this endpoint does not support, is ignored and the request runs normally. See the Testing guide for the list of scenarios. |
| `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`. Rejected with 400 `invalid_device_info` when, after trimming spaces, it is shorter than 8 characters or is one of `(null)`, `null`, `nil`, `undefined`, `nan`, `<null>` or `none` (any letter case). |
| `drm_kek` | body | string | Yes, for encrypted episodes | Device public key generated by the Player SDK. Send together with `union_info`. Same validation as `union_info`. The matching private key never leaves the device. |
| `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 (up to 256 characters). Do not include personal data. When you send it, asking again for the same episode with the same `viewer_ref`, intent (`play` or `prefetch`) and device values while your previous ticket still has at least 5 minutes left returns that same ticket (same `expires_at`, not extended) and does not count against your rate limit. |
| `episodes` | body | array | No | Batch form A — up to 3 episodes in one call (typically the one being watched + the next 2). Cannot be combined with top-level `episode_id` / `film_ref` / `episode_no` or with `next`. Item 0 takes the top-level `intent`; later items default to `prefetch` unless they carry their own. |
| `episodes[].episode_id` | body | string (UUID) | No | Episode ID. Or send `film_ref` + `episode_no`. |
| `episodes[].film_ref` | body | string | No | Film `id` or `slug`, with `episode_no`. |
| `episodes[].episode_no` | body | integer | No | Episode number (whole number from 1), with `film_ref`. |
| `episodes[].intent` | body | string | No | Intent of this item. |
| `next` | body | integer | No | Batch form B — with an anchor episode (`episode_id`, or `film_ref` + `episode_no`), also issue tickets for the next N episodes of the same film. Following episodes are recorded as `prefetch`. An episode past the end of the film, or not published yet, is a per-item 404 `not_ready`. Sending `next` (even 0) switches the response to the batch shape. |

## Request example

```bash
curl -s -X POST https://www.wandura.ai/api/delivery/v1/playback \
  -H "Authorization: Bearer $WANDURA_API_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. Present with `play_auth_token`. |
| `play_auth_token` | string | Play token to hand to the Player SDK. Keep it secret and do not log it. |
| `manifest_url` | string | Signed stream link, present instead of `vid` + `play_auth_token` only for content Wandura delivers to your organisation without encryption, by agreement (then `protection` is `signed-url-only`). Valid until `expires_at`. |
| `expires_at` | string (ISO 8601, UTC) | When the ticket expires: 30 minutes after it was issued (15 minutes for `manifest_url`). |
| `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, in `lang` order. 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`. |
| `items` | array | Batch only — one entry per requested episode, in request order. On `status` 200 the entry also has `episode_id`, `episode_no` and the same ticket fields as a single response; otherwise `error`, `error_code`, optional `reason` (403, and 429 `prefetch_shed`) and `retry_after` (503 and 429), and no episode data. |
| `items[].request` | object | Echo of what was asked for this item. |
| `items[].status` | integer | This item's own HTTP-equivalent status. |
| `items[].subtitles[].lang` | string | Language code, for example `en`, `vi` or `pt-BR`. |
| `items[].subtitles[].url` | string | URL of the subtitle file. Not tied to the ticket's expiry. |
| `items[].subtitles[].format` | string | Always `webvtt`. |
| `items[].retry_after` | integer | Seconds to wait before retrying this item (503 and 429). |

## 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" }
  ]
}
```

## Response headers
| Header | Responses | Description |
|---|---|---|
| `Wandura-API-Status` | all | API status. `beta` today, on every response, success or error. |
| `Wandura-Version` | all | The dated API version (YYYY-MM-DD) this response was produced in: the version pinned to your API key, or the one you sent in the `Wandura-Version` request header. Keys created before dated versions existed use `2026-10-02`. |
| `Wandura-Request-Id` | all | An opaque id for this request (`req_` followed by 26 characters). Every response carries it, errors included, and error bodies repeat it as `request_id`. Log it, and quote it when you contact Wandura about a request: it finds the exact request in our logs. Do not parse it. |
| `Cache-Control` | all | Always `no-store`. Do not cache these responses in shared caches. |
| `Deprecation` | 200 | Present only when this endpoint, or the dated version serving this response, is deprecated (RFC 9745): `@<unix seconds>` of the deprecation date. Log it in your monitoring. |
| `Sunset` | 200 | Present only when a deprecated endpoint or dated version has a date when it stops working (RFC 8594), as an HTTP date. |
| `Link` | 200 | Present with `Deprecation`: `<https://docs.wandura.ai/…>; rel="deprecation"` (and `rel="sunset"`) pointing to the page that explains what to use instead. |
| `Retry-After` | 429, 503 | Seconds to wait before retrying. Sent with 429 and with retryable 503 responses. |
| `X-RateLimit-Limit` | 429 | Requests (for playback, tickets) allowed per window for this endpoint group and your organisation. |
| `X-RateLimit-Window` | 429 | Window length in seconds. |

## 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, or a malformed batch (`episodes` empty or longer than 3, an item without an episode, `episodes` mixed with top-level episode fields or with `next`, `next` outside 0–2 or without an anchor). |
| 400 | `invalid_device_info` | A device value looks empty or is too short. The `fields` array names it. |
| 400 | `unsupported_version` | The `Wandura-Version` header, or the version pinned to your key, is unknown or retired. The body adds `supported_versions` and `latest_version` (and `upgrade_to` when your pin was retired). |
| 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, or (with `reason` `prefetch_shed`) prefetch is paused because your playback traffic is close to it. 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. |
| 503 | `service_unavailable` | Wandura could not check the licence or the episode in time (`Could not check the licence or episode right now — retry`). This says nothing about your licence or the episode: keep selling, and retry after `Retry-After` seconds, with backoff. |

Example `403`:

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

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