# Canonical API behaviour

> **TL;DR** — This page is the contract. Every number and rule that more than one page relies on lives here once: authentication, pagination, ticket lifetime and reuse, how fresh each answer is, the retry policy, errors, rate limits and versioning. Other pages explain and link here; if two pages ever disagree, this page wins.

## Authentication
| Rule | Value |
|---|---|
| Header | `Authorization: Bearer <API key>` on every request. No cookies, no query-string keys. |
| Key format | `wnd_live_` followed by 32 characters. |
| Who it acts for | The whole organisation that owns the key. There is no per-viewer identity in the API. |
| Where it may live | Your servers only. The API sends no CORS headers. |
| Transport | `https://www.wandura.ai`, TLS 1.2 or later. |
| Order of checks | `Wandura-Version` header → key present → key valid → pinned version → scope → organisation → rate limit → request body. |
| Scope per endpoint | One table, on [Authentication](https://docs.wandura.ai/v1/developers/authentication#scopes). |

A bad `Wandura-Version` header is answered `400` before the key is even looked at. A key Wandura cannot check in time answers `503` `service_unavailable`: the key is fine, retry it. Key creation, rotation and revocation timings are on [Security](https://docs.wandura.ai/v1/developers/concepts/security#key-lifecycle).

## Pagination
Three endpoints return pages. Each follows the same rules: the cursor is opaque (copy it, never build or decode it), and you stop when the next cursor is `null`, never because a page is short or empty.

| Endpoint | Page size | Next page | Unit of a page |
|---|---|---|---|
| `GET /catalog` | Optional: `limit` 1–200. No `limit` and no `cursor` = the whole catalog in one response. A `cursor` without `limit` = 50. | `next_cursor` (present only when paging) and `links.next` | Films; each page carries **all** the episodes of its films, placeholders included. |
| `GET /creatives` | `limit` 1–100, default 50 | `data.next_cursor`, `links.next` | Creative records, oldest update first. |
| `GET /videos` | `limit` 1–200, default 50 | `next_cursor`, `links.next` | Open videos. A page can be short or empty while more follow. |

### Catalog pages are not one snapshot
Each catalog page is cut from the catalog as it is at the moment that page is served. If Wandura publishes or withdraws a film, or a licence starts or ends, while you walk the pages, a film can be **skipped or appear twice** across pages. Sync safely:

1. If your catalog is a few hundred films or fewer, call `GET /catalog` **without** `limit`: one response is one consistent snapshot.
2. If you page, de-duplicate by film `id` (the later copy wins).
3. Apply withdrawals only after a **complete** walk: every page answered `200`. A walk that failed part-way changes nothing.
4. Mark a film unavailable only when it was missing from **two complete walks in a row**. A film skipped once by a moving catalog comes back on the next walk.

The [catalog sync guide](https://docs.wandura.ai/v1/developers/guides/catalog-sync) implements exactly this.

The creatives feed has no such gap for unchanged records: it is ordered by update time then ID, so new updates land after the pages you have read. Records updated during your walk appear again on the next sync.

## Playback tickets
### Lifetime
| What you received | Lives for | Exact deadline |
|---|---|---|
| `play_auth_token` (encrypted episodes, Player SDK) | **1,800 s** (30 minutes) | `expires_at` |
| `manifest_url` (only content delivered without encryption, by agreement) | **900 s** (15 minutes) | `expires_at` |

A ticket is never extended, and once issued it cannot be recalled: it plays until `expires_at` even if the licence ends or the key is revoked a second later. `GET /config` gives both lifetimes for your key (`playback.grant_ttl_seconds`, `playback.manifest_url_ttl_seconds`).

### One ticket per device
A ticket is bound to the device values it was issued for (`union_info` and `drm_kek`). It decrypts only on that device. Never hand a ticket to another device, and never cache one under a key that does not include the device.

### Reuse
Reusing a ticket is allowed and encouraged, under these rules:

| Rule |  |
|---|---|
| Key of the reuse | viewer + **device** + episode + intent class (`play` or `prefetch`) |
| How long | Until **5 minutes** before `expires_at`. After that, request a new ticket. |
| On Wandura's side | When you send `viewer_ref`, a repeat request with the same `viewer_ref`, the same device values, the same episode (identified **the same way**: `episode_id`, or `film_ref` + `episode_no`) and the same intent class, while the earlier ticket has at least 5 minutes left, returns **that same ticket** (same token, same `expires_at`). It is not counted toward your rate limit and is not a new play. Wandura re-checks your licence before returning it. |
| On your side | You may also keep tickets on your backend under the same key, and hand them out again. |
| Best effort | Server-side reuse is an optimisation, not a promise: a repeat request can still return a fresh ticket. Never rely on getting the same token back. |
| Without `viewer_ref` | Every request mints a new ticket. |

**Views are counted from `play` requests.** When a viewer starts an episode, always send a request with `"intent": "play"` for it, even if your app starts the player with a ticket it prefetched earlier. Prefetched tickets are not views.

### Batches
One request can ask for up to **3** tickets: `episodes` (1–3 items) or an anchor episode plus `next` (0–2). Each ticket counts **1** toward the playback limit. Each item has its own `status`; items are admitted one by one, prefetch items last. The whole request is `429` only when no item can be issued. Details: [Request playback](https://docs.wandura.ai/v1/developers/reference/playback).

## Freshness: how old an answer can be
Wandura serves from short caches, so a change on Wandura's side reaches you after a bounded delay. These are the upper bounds.

| Change on Wandura's side | Visible in | Visible in ticket requests |
|---|---|---|
| Film or episode published or withdrawn, `stream_ready` changed | Catalog: ≤ 30 s | ≤ 60 s |
| Licence granted, suspended, switched off or past its window | Catalog: ≤ 30 s | ≤ 30 s |
| Organisation suspended | about 1 s (always ≤ 30 s) | about 1 s (always ≤ 30 s) |
| Key revoked or rotated | about 1 s (always ≤ 30 s) | about 1 s (always ≤ 30 s) |
| Something just created (a key, an episode, a licence) | ≤ 5 s | ≤ 5 s |
| Subtitle attached or replaced | — | ≤ 5 minutes |
| Rate limit raised for your organisation | about 1 minute | about 1 minute |
| Subtitle languages in `GET /config` | ≤ 5 minutes | — |

- **Several servers.** Each Wandura server keeps its own cache, so two calls a few seconds apart can return slightly different snapshots. That is normal.
- **During a Wandura database incident** the catalog and licence checks are served from the last good answer for up to **5 minutes** (stale-if-error), instead of failing. A change Wandura has already seen (a withdrawal, a suspension) is never undone by this.
- **Tickets already issued** are never recalled: they play until `expires_at` (≤ 30 minutes).

What to cache on your side, and for how long: [Caching](https://docs.wandura.ai/v1/developers/guides/caching).

## Retries
The one retry policy for every Wandura call. The [examples](https://docs.wandura.ai/v1/developers/guides/catalog-sync#code) implement it.

| Answer | Retry? | How |
|---|---|---|
| Network error, timeout | Yes | Backoff below |
| `429` (any endpoint, any `reason`) | Yes | After `Retry-After` |
| `503` `service_unavailable`, creatives `5030` / `5031` | Yes | After `Retry-After` if present, else backoff |
| `500`, `502` | Yes | Backoff |
| `503` `not_ready` | **Not now** | The episode has no playable version right now. Show "not available yet"; try again on the next play or the next catalog sync. |
| `400`, `401`, `403`, `404`, `405`, `409`, `422` | **No** | The same request will fail again: fix the request, the key or the licence. |
| A `4xx` you do not recognise | No |  |
| A `5xx` you do not recognise | Yes | Like `503` |

**Backoff.** When `Retry-After` is present, wait that many seconds plus a random 0–1 s. Otherwise wait a random time between 0 and `min(cap, base × 2^attempt)` (full jitter).

|  | Interactive (a viewer is waiting) | Background (sync jobs) |
|---|---|---|
| Base / cap | 0.5 s / 8 s | 1 s / 30 s |
| Timeout per attempt | 8 s (never below 5 s: Wandura may hold a `play` request up to 0.5 s) | 15 s |
| Retries after the first attempt | at most 3 | at most 5 |
| Total time | 15 s, then show **Retry** | 2 minutes, then give up and keep your previous data |

**Retry budget.** Across a process, retries may be at most **10 %** of the requests of the last minute (never fewer than 10 a minute). When the budget is spent, fail fast instead of adding load.

**Concurrency.** Walk pages one at a time, never in parallel. Retry only the failed items of a batch, each after its own `retry_after`. A retried ticket request with the same `viewer_ref` and device values usually returns the same ticket, so it does not count twice.

## Errors
- Two formats: a flat `{ "error", "error_code" }` on catalog, playback, config and videos; an envelope `{ "code", "message", "server_time" }` (no `data`) on creatives errors. Creatives `429` and `400` `unsupported_version` use the flat format.
- Branch on `error_code` (creatives: `code`). Unknown codes: decide by status class, as in the retry table above.
- `Retry-After` comes with `429` and with retryable `503`s.
- Every response carries a `Wandura-Request-Id` header, and every JSON error body a `request_id` field. Quote it to support.

Every status and code, per endpoint, with what to do: [Errors](https://docs.wandura.ai/v1/developers/concepts/errors).

## Rate limits
Limits are per **organisation** (not per key), per counter, with the unit shown.

| Counter | Endpoints | Limit per organisation | Unit | How it refills |
|---|---|---|---|---|
| `playback` | `POST /playback` | 10,800 per 60 s | **tickets**: a batch of 3 = 3; a malformed body = 1; a reused ticket = 0; a refused ticket = 0 | Continuously: 180 per second, up to 10,800 at once from idle |
| `catalog` | `GET /catalog` | 3,000 per 60 s | requests | Continuously: 50 per second |
| `creatives` | `GET /creatives`, `GET /creatives/{id}` (shared) | 3,000 per 60 s | requests | Clock minute (UTC) |
| `videos` | `GET /videos` | 3,000 per 60 s | requests | Clock minute (UTC) |
| `video` | `GET /videos/{id}` | 10,800 per 60 s | requests | Clock minute (UTC) |
| `config` | `GET /config` | 600 per 60 s | requests | Clock minute (UTC) |

- Only requests with a valid key are counted.
- Counting is approximate on the high side by up to about 2.5 %.
- Close to the playback limit, `prefetch` tickets are refused first (`429`, `"reason": "prefetch_shed"`, when less than about 20 % of the allowance is left), and a `play` request may be held up to 0.5 s instead of refused.
- `GET /config` reports your current limits for every counter except `video`.
- Wandura can raise any limit for your organisation on request.

How it behaves under load, and what to do: [Rate limits](https://docs.wandura.ai/v1/developers/concepts/rate-limits).

## Versioning
| Rule |  |
|---|---|
| Major version | In the path: `/v1`. |
| Dated version | Each key is pinned to the dated version current when it was created (today the only one: `2026-10-02`). Every response says which one it used in `Wandura-Version`. Send `Wandura-Version: <date>` to try another version for one request. |
| Rotation | Keeps the key's pin. A **new** key is pinned to the latest version. |
| Inside `/v1` | Only additive changes: new endpoints, optional parameters, fields, values and error codes. Ignore what you do not recognise. |
| Breaking change | A new dated or major version. The old major version keeps working **12 months** after the new one is announced; during the beta, a breaking change inside `/v1` is announced **30 days** ahead (urgent security fixes excepted). |
| Status header | `Wandura-API-Status: beta` on every response. |

Policy and deprecation headers: [Versioning and API evolution](https://docs.wandura.ai/v1/developers/concepts/versioning).

## Caching headers
Every Delivery API response, tickets included, carries `Cache-Control: no-store`, and sets no cookies. Never store any of them in a shared cache (a CDN or a proxy): tickets, signed links and your catalog belong to your organisation only.

## Related
- [API reference overview](https://docs.wandura.ai/v1/developers/reference/overview)
- [Errors](https://docs.wandura.ai/v1/developers/concepts/errors)
- [Security](https://docs.wandura.ai/v1/developers/concepts/security)
