On this page
TL;DR — Install
TTSDKFramework1.52.100.4-standardwith CocoaPods (iOS 11+, Xcode 14+, Bitcode off), add the licence file Wandura sends you, start the SDK, and read the two device values after the licence check passes. Your app sends them to your backend, gets a ticket back, and gives the ticket to the SDK. On an SDK error, ask for one fresh ticket and resume.
Prerequisites
| Licence | The .lic file and App ID from Wandura, bound to your final bundle ID. See Player SDK. |
| Toolchain | Xcode 14 or later, iOS 11.0 or later, Bitcode disabled, CocoaPods (always open the .xcworkspace). |
| Devices | A real iPhone for encrypted playback. |
| Your backend | A ticket endpoint: Playback flow and paywall. |
The exact SDK calls (start-up, licence notification, device values, playback, subtitles) are in the Player SDK guide on partner.wandura.ai → Developers → Player SDK.
The flow in your app
- Start the SDK at launch with your App ID and licence file, and wait for the licence result before anything else.
- Read the device values (device ID →
union_info, device public key →drm_kek). Never sendnil,"(null)"or an empty string: throw instead. - Ask your backend to play the episode. Your backend applies your paywall and asks Wandura; your app never holds an API key.
- Play: give
vidandplay_auth_tokento the SDK; add the subtitles. - Preload the next episodes with the prefetched tickets your backend returns.
- On any SDK error (an expired ticket included): request one fresh ticket and resume at the same position. Do not branch on SDK error codes, and do not loop.
- Back from the background: if the current ticket has less than a minute left, replace it before resuming.
Code
The file below is the app side of that flow, in Swift. Only PlayerSDK touches the SDK: fill it in with the calls from the portal guide. Your backend's response shape matches the Node and Python backend examples.
The SDK behind one protocol
struct DeviceValues {
let unionInfo: String // device ID generated by the Player SDK
let drmKek: String // device public key generated by the Player SDK
}
protocol PlayerSDK: AnyObject {
/// Read only after the SDK has started and its licence check succeeded. Throws instead of returning nil:
/// never send "(null)", "nil" or an empty string to your backend.
func deviceValues() throws -> DeviceValues
func play(vid: String, playAuthToken: String, startAt seconds: Double)
func preload(vid: String, playAuthToken: String)
func addSubtitles(_ subtitles: [Subtitle])
var currentTime: Double { get }
/// A playback error, including an expired ticket. Do not branch on SDK error codes.
var onError: ((Error) -> Void)? { get set }
}Calling your backend
final class Backend {
private let baseURL: URL
private let session: URLSession
private let sessionToken: () -> String // YOUR viewer session, never a Wandura key
init(baseURL: URL, sessionToken: @escaping () -> String) {
self.baseURL = baseURL
self.sessionToken = sessionToken
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15 // your backend may retry Wandura for a few seconds
session = URLSession(configuration: config)
}
func play(filmId: String, episodeNo: Int, device: DeviceValues) async throws -> PlayResponse {
var req = URLRequest(url: baseURL.appendingPathComponent("v1/play"))
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("Bearer \(sessionToken())", forHTTPHeaderField: "Authorization")
req.httpBody = try JSONSerialization.data(withJSONObject: [
"film_id": filmId, "episode_no": episodeNo,
"union_info": device.unionInfo, "drm_kek": device.drmKek,
])
let (data, _) = try await session.data(for: req)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { d in
let s = try d.singleValueContainer().decode(String.self)
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = f.date(from: s) ?? ISO8601DateFormatter().date(from: s) { return date }
throw DecodingError.dataCorrupted(.init(codingPath: d.codingPath, debugDescription: "bad date \(s)"))
}
return try decoder.decode(PlayResponse.self, from: data)
}
}Start, prefetch, expiry, errors
enum ScreenState {
case loading
case playing
case paywall
case message(String, canRetry: Bool)
}
@MainActor
final class PlaybackController {
private let sdk: PlayerSDK
private let backend: Backend
private var prefetched: [Int: Ticket] = [:] // episode number → ticket for THIS device only
private var current: (filmId: String, episodeNo: Int, ticket: Ticket)?
private var refreshedAfterError = false
var onState: ((ScreenState) -> Void)?
init(sdk: PlayerSDK, backend: Backend) {
self.sdk = sdk
self.backend = backend
sdk.onError = { [weak self] _ in Task { @MainActor in await self?.recoverFromPlayerError() } }
}
/// The viewer tapped an episode.
func start(filmId: String, episodeNo: Int) async {
say(.loading)
refreshedAfterError = false
let device: DeviceValues
do { device = try sdk.deviceValues() } catch {
return say(.message("Couldn't load this episode", canRetry: false)) // SDK setup bug: alert, don't loop
}
// A prefetched ticket starts the episode at once. The play request is still sent: it records the view.
if let t = prefetched.removeValue(forKey: episodeNo), t.isFresh {
begin(filmId: filmId, episodeNo: episodeNo, ticket: t, at: 0)
}
do {
let r = try await backend.play(filmId: filmId, episodeNo: episodeNo, device: device)
if r.ok, let ticket = r.current {
if current?.episodeNo != episodeNo { begin(filmId: filmId, episodeNo: episodeNo, ticket: ticket, at: 0) }
for n in r.next ?? [] { // a prefetch missing from `next` is not an error
prefetched[n.episodeNo] = n.ticket
sdk.preload(vid: n.ticket.vid, playAuthToken: n.ticket.playAuthToken)
}
} else if current?.episodeNo != episodeNo {
show(r.outcome ?? .broken)
}
} catch {
if current?.episodeNo != episodeNo { say(.message("Taking longer than usual", canRetry: true)) }
}
}
/// Expired ticket or any SDK error: ONE fresh ticket, resume at the same position. Never a loop.
private func recoverFromPlayerError() async {
guard let c = current, !refreshedAfterError, let device = try? sdk.deviceValues() else {
return say(.message("Couldn't load this episode", canRetry: true))
}
refreshedAfterError = true
let position = sdk.currentTime
guard let r = try? await backend.play(filmId: c.filmId, episodeNo: c.episodeNo, device: device),
r.ok, let ticket = r.current
else { return say(.message("Couldn't load this episode", canRetry: true)) }
begin(filmId: c.filmId, episodeNo: c.episodeNo, ticket: ticket, at: position)
}
/// Back from the background: a ticket with less than a minute left is replaced before resuming.
func willEnterForeground() async {
guard let c = current, !c.ticket.isFresh else { return }
refreshedAfterError = false
await recoverFromPlayerError()
}
private func begin(filmId: String, episodeNo: Int, ticket: Ticket, at seconds: Double) {
current = (filmId, episodeNo, ticket)
sdk.play(vid: ticket.vid, playAuthToken: ticket.playAuthToken, startAt: seconds)
sdk.addSubtitles(ticket.subtitles) // empty list = no subtitles yet, not an error
say(.playing)
}
private func say(_ state: ScreenState) { onState?(state) }
private func show(_ outcome: Outcome) {
switch outcome {
case .locked: say(.paywall)
case .unavailable: say(.message("This title is no longer available", canRetry: false))
case .notReady: say(.message("Not available yet", canRetry: false))
case .busy: say(.message("Busy, try again in a moment", canRetry: true))
case .broken: say(.message("Couldn't load this episode", canRetry: true))
}
}
}Checklist
- The licence result is checked before device values are read.
- Device values are never
nil,"(null)"or empty when sent. - The app holds no
wnd_live_key (search the built.ipa). -
play_auth_tokenis kept in memory only: not inUserDefaults, logs or crash reports. - An SDK error leads to one fresh ticket and a resume, never a loop.
- Resuming after more than 29 minutes in the background replaces the ticket first.
- An episode without subtitles plays without an error.
Related
Updated 2 Oct 2026 · Change history (2)