Bản lưu ngày / Snapshot of 2026-10-20 · Xem bản mới nhất / See the latest version
WanduraDocsBeta
BetaThese docs are in beta and change often. Something wrong, unclear or missing? Tell us.
Guides

Playback flow and paywall

Markdown
On this page

TL;DR — Your app sends your backend "play episode N" with the two device values. Your backend applies your paywall, then sends Wandura one request: the episode with "intent": "play" plus up to two unlocked next episodes as prefetches, a stable hashed viewer_ref, and the device values. It checks protection, returns the tickets to the app, and maps any error to one of four viewer messages.

Wandura Delivery APIYour backendYour app (Player SDK)alt[N is locked][N may be watched]ViewerTaps episode NPOST /v1/play (your session, film, N, device values)Paywall for N, N+1, N+2locked - show your paywallPOST /playback: N (play) + unlocked next (prefetch)items[]: a ticket or an error per episodeprotection == private-encrypted?current ticket + prefetched ticketsPlay N, preload N+1, N+2

1. Check your paywall first

Wandura checks only that your organisation may stream the film; it issues a ticket for any episode of a licensed film. Keeping locked episodes locked is your job: never ask for a ticket for an episode the viewer has not unlocked, prefetches included.

Episode Decision
episode_no ≤ free_until_episode Free
Unlocked by your paywall (coins, subscription, VIP…) May watch
Placeholder (id null), stream_ready: false, or withdrawn Not playable: do not call Wandura
Anything else Locked: show your paywall

2. One request for the episode and the next ones

  • Item 0: the tapped episode, "intent": "play". Always send it, even when the app already starts the episode with a prefetched ticket: it is what counts the view.
  • Items 1–2: the following episodes the viewer may watch, "intent": "prefetch". Stop at the first locked one. Use episodes (an explicit list) rather than next when some following episodes are locked.
  • viewer_ref: stable per viewer and device, hashed, no personal data. A retry then returns the same tickets instead of new ones.
  • union_info, drm_kek: exactly as the Player SDK produced them.

Ticket lifetime, reuse and batch rules: Canonical API behaviour · Playback tickets.

3. Check protection and answer the app

Sell paid episodes only when protection is private-encrypted. Return the current ticket and the prefetched ones to the app; a prefetch that came back with an error is simply left out.

4. Viewer messages

Map Wandura's answer to one of four messages (Unavailable, Not yet, Busy, Broken). The full table per status and code is on Errors; keep a default branch for codes you do not know. Never show Wandura's error text.

5. Your endpoint's own protection

Your play endpoint is public to your app. Require a signed-in viewer, and limit tickets per viewer and per device: Wandura's limit is per organisation. See Security.

Code

TypeScript (Node)

The viewer_ref:

/** Stable per viewer AND device, meaningless outside your systems (no raw user id, no personal data). */
export function viewerRef(userId: string, installId: string, secret: string): string {
  const h = (msg: string, n: number) => createHmac('sha256', secret).update(msg).digest('base64url').slice(0, n);
  return `${h(`user:${userId}`, 22)}:${h(`device:${installId}`, 11)}`;
}

The paywall decision:

/** Free when episode_no <= free_until_episode (null = no free episodes); otherwise your paywall decides. */
export async function mayWatch(d: PlayDeps, userId: string, filmId: string, episodeNo: number): Promise<'ok' | 'locked' | 'not_playable'> {
  const film = await d.film(filmId);
  const ep = await d.episode(filmId, episodeNo);
  // Placeholder rows (id null), episodes not stream_ready and withdrawn rows never reach Wandura.
  if (!film || !ep || !ep.id || !ep.stream_ready || !ep.available) return 'not_playable';
  const free = film.free_until_episode !== null && episodeNo <= film.free_until_episode;
  return free || (await d.hasUnlocked(userId, filmId, episodeNo)) ? 'ok' : 'locked';
}

The endpoint:

export async function handlePlay(r: PlayRequest, d: PlayDeps): Promise<PlayResponse> {
  // 1. Your paywall. A locked episode never reaches Wandura.
  const may = await mayWatch(d, r.userId, r.filmId, r.episodeNo);
  if (may !== 'ok') return { ok: false, outcome: may === 'locked' ? 'locked' : 'not_ready' };

  // 2. Up to two following episodes the viewer may also watch. Stop at the first one that is not.
  const following: number[] = [];
  for (let k = 1; k <= 2 && (await mayWatch(d, r.userId, r.filmId, r.episodeNo + k)) === 'ok'; k++) following.push(r.episodeNo + k);

  // 3. One batch: item 0 is the play, the rest are prefetches. Always send the play — it is what counts the view —
  //    even if the app already started the episode with a prefetched ticket.
  const viewer_ref = viewerRef(r.userId, r.installId, d.viewerRefSecret);
  const body = {
    episodes: [
      { film_ref: r.filmId, episode_no: r.episodeNo, intent: 'play' },
      ...following.map((n) => ({ film_ref: r.filmId, episode_no: n, intent: 'prefetch' })),
    ],
    union_info: r.unionInfo,
    drm_kek: r.drmKek,
    viewer_ref, // stable per viewer + device: a retry gets the SAME ticket back, not a second one
  };
  let items: BatchItem[];
  try {
    items = await requestBatch(body, d.call);
  } catch (e) {
    if (e instanceof WanduraApiError) return { ok: false, outcome: handleTicketError(e).outcome };
    throw e;
  }

  const [anchor, ...rest] = items;
  if (!anchor || anchor.status !== 200) {
    const err = new WanduraApiError({ message: anchor?.error ?? 'no item', status: anchor?.status ?? null, errorCode: anchor?.error_code, reason: anchor?.reason });
    const h = handleTicketError(err);
    log('ticket_refused', { status: err.status, error_code: err.errorCode, reason: err.reason, viewer_ref, alert: h.alert });
    return { ok: false, outcome: h.outcome };
  }
  // 4. Only `private-encrypted` may be sold. Anything else: do not play it, tell Wandura.
  const current = asTicket(anchor);
  if (!current || current.protection !== 'private-encrypted') {
    log('ticket_unexpected_protection', { protection: anchor.protection, viewer_ref });
    return { ok: false, outcome: 'unavailable' };
  }
  // A refused or failed prefetch is not an error: the app asks again when the viewer gets there.
  const next = rest.flatMap((it, i) => {
    const t = it.status === 200 ? asTicket(it) : null;
    const n = following[i];
    return t && n !== undefined && t.protection === 'private-encrypted' ? [{ episode_no: n, ticket: t }] : [];
  });
  return { ok: true, current, next };
}

/** POST /playback. If only item 0 failed with a retryable status, retry once after its own `retry_after`. */
async function requestBatch(body: object, o: CallOptions = {}): Promise<BatchItem[]> {
  for (let attempt = 0; ; attempt++) {
    const { data } = await call<{ items: BatchItem[] }>('POST', '/api/delivery/v1/playback', { policy: INTERACTIVE, ...o, body });
    const a = data.items[0];
    if (!a || a.status === 200 || attempt >= 1 || !isRetryable(a.status, a.error_code)) return data.items;
    const wait = (a.retry_after ?? 1) * 1_000 + Math.random() * 1_000;
    await (o.sleep ?? ((ms: number) => new Promise<void>((res) => setTimeout(res, ms))))(wait);
  }
}

function asTicket(it: BatchItem): Ticket | null {
  if (!it.vid || !it.play_auth_token || !it.expires_at || !it.protection) return null;
  return { vid: it.vid, play_auth_token: it.play_auth_token, expires_at: it.expires_at, protection: it.protection, subtitles: it.subtitles ?? [] };
}

Error → outcome for your app:

/** YOUR codes for your app. Never pass Wandura's `error` text to viewers. */
export type AppOutcome =
  | 'unavailable' // licence not active — "This title is no longer available"
  | 'not_ready' // not playable yet — "Not available yet"
  | 'busy' // retried already — "Busy, try again in a moment"
  | 'broken'; // a bug on your side — "Couldn't load this episode"

/** `alert` = page your engineers. `confirm: 'catalog'` = re-check with the catalog sync before an irreversible step. */
export type Handling = { outcome: AppOutcome; alert: boolean; confirm?: 'catalog' };

export function handleTicketError(e: WanduraApiError): Handling {
  switch (e.errorCode) {
    case 'not_entitled': return { outcome: 'unavailable', alert: false, confirm: 'catalog' };
    case 'not_ready': return { outcome: 'not_ready', alert: false };
    case 'rate_limited':
    case 'service_unavailable': return { outcome: 'busy', alert: false };
    case 'sdk_required':
    case 'invalid_device_info':
    case 'invalid_request':
    case 'unsupported_version': return { outcome: 'broken', alert: true };
  }
  // Unknown or missing `error_code`: decide by status class.
  const s = e.status;
  if (s === null || s === 429 || s >= 500) return { outcome: 'busy', alert: s !== null && s >= 500 };
  return { outcome: 'broken', alert: true };
}

Python

def handle_play(user_id: str, install_id: str, film: dict, episodes_by_no: Dict[int, dict], episode_no: int,
                union_info: str, drm_kek: str, unlocked: Callable[[int], bool], secret: bytes, **call_opts: Any) -> Dict[str, Any]:
    decision = may_watch(film, episodes_by_no.get(episode_no), lambda: unlocked(episode_no))
    if decision != "ok":
        return {"ok": False, "outcome": "locked" if decision == "locked" else "not_ready"}
    following: List[int] = []
    for n in (episode_no + 1, episode_no + 2):
        if may_watch(film, episodes_by_no.get(n), lambda n=n: unlocked(n)) != "ok":
            break
        following.append(n)

    ref = viewer_ref(user_id, install_id, secret)
    body = {  # item 0 = the play (counts the view), the rest = prefetches
        "episodes": [{"film_ref": film["id"], "episode_no": episode_no, "intent": "play"}]
        + [{"film_ref": film["id"], "episode_no": n, "intent": "prefetch"} for n in following],
        "union_info": union_info,
        "drm_kek": drm_kek,
        "viewer_ref": ref,  # stable per viewer + device: a retry gets the same ticket back
    }
    try:
        data, _ = call("POST", "/api/delivery/v1/playback", body=body, policy=INTERACTIVE, **call_opts)
    except WanduraApiError as e:
        return {"ok": False, **handle_ticket_error(e.status, e.error_code)}

    items = data.get("items") or []
    anchor = items[0] if items else {}
    if anchor.get("status") != 200:
        log("ticket_refused", status=anchor.get("status"), error_code=anchor.get("error_code"), reason=anchor.get("reason"), viewer_ref=ref)
        return {"ok": False, **handle_ticket_error(anchor.get("status"), anchor.get("error_code"))}
    if anchor.get("protection") != "private-encrypted":  # only private-encrypted may be sold
        log("ticket_unexpected_protection", protection=anchor.get("protection"), viewer_ref=ref)
        return {"ok": False, "outcome": "unavailable", "alert": True}
    keep = ("vid", "play_auth_token", "expires_at", "subtitles")
    nxt = [{"episode_no": n, "ticket": {k: it.get(k) for k in keep}}
           for n, it in zip(following, items[1:])
           if it.get("status") == 200 and it.get("protection") == "private-encrypted"]  # refused prefetch = not an error
    return {"ok": True, "current": {k: anchor.get(k) for k in keep}, "next": nxt}
_OUTCOMES = {
    "not_entitled": ("unavailable", False),  # confirm with the catalog sync before you stop selling
    "not_ready": ("not_ready", False),
    "rate_limited": ("busy", False),
    "service_unavailable": ("busy", False),
    "sdk_required": ("broken", True),
    "invalid_device_info": ("broken", True),
    "invalid_request": ("broken", True),
    "unsupported_version": ("broken", True),
}


def handle_ticket_error(status: Optional[int], error_code: Optional[str]) -> Dict[str, Any]:
    """Your app's outcome for a failed ticket. Never show Wandura's `error` text to viewers."""
    if error_code in _OUTCOMES:
        outcome, alert = _OUTCOMES[error_code]
    elif status is None or status == 429 or status >= 500:  # unknown code: decide by status class
        outcome, alert = "busy", status is not None and status >= 500
    else:
        outcome, alert = "broken", True
    return {"outcome": outcome, "alert": alert}

The app side of the same flow, in Swift and Kotlin: Player SDK on iOS, on Android.

Done when

  • A locked episode shows your paywall, and your logs show no Wandura request for it.
  • Starting an episode sends one request: the episode (play) plus the unlocked next ones (prefetch).
  • viewer_ref is stable per viewer and device and contains no personal data.
  • Every answer in Errors maps to a screen, with a default branch.
  • A ticket with any protection other than private-encrypted is never played.

Updated 2 Oct 2026 · Change history (1)

↑↓ selectEnter openEsc close

Send feedback

Your message goes straight to the Wandura team. It includes the address of this page.

0 / 4000