# Playback flow and paywall

> **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.

```mermaid
sequenceDiagram
    actor Viewer
    participant App as Your app (Player SDK)
    participant Backend as Your backend
    participant API as Wandura Delivery API
    Viewer->>App: Taps episode N
    App->>Backend: POST /v1/play (your session, film, N, device values)
    Backend->>Backend: Paywall for N, N+1, N+2
    alt N is locked
        Backend-->>App: locked - show your paywall
    else N may be watched
        Backend->>API: POST /playback: N (play) + unlocked next (prefetch)
        API-->>Backend: items[]: a ticket or an error per episode
        Backend->>Backend: protection == private-encrypted?
        Backend-->>App: current ticket + prefetched tickets
        App->>App: Play N, preload N+1, N+2
    end
```

## 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](https://docs.wandura.ai/developers/reference/behaviour#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](https://docs.wandura.ai/developers/concepts/errors#viewer-messages); 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](https://docs.wandura.ai/developers/concepts/security#trust-boundary).

## Code
### TypeScript (Node)
The `viewer_ref`:

```ts
/** 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:

```ts
/** 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:

```ts
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:

```ts
/** 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
```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}
```

```python
_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](https://docs.wandura.ai/developers/player-sdk/ios#code), [on Android](https://docs.wandura.ai/developers/player-sdk/android#code).

## 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](https://docs.wandura.ai/developers/concepts/errors#playback-errors) maps to a screen, with a default branch.
- [ ] A ticket with any `protection` other than `private-encrypted` is never played.

## Related
- [Smooth playback under load](https://docs.wandura.ai/developers/guides/smooth-playback)
- [Playback and tickets](https://docs.wandura.ai/developers/concepts/playback)
