On this page
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.
POST /api/delivery/v1/playbackScope: playback (Developers → Stream films in an app; see Authentication)
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.
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
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
{
"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:
{
"error": "outside_window",
"error_code": "not_entitled",
"reason": "outside_window"
}What to do in each case: Errors.
Updated 2 Oct 2026 · Change history (8)