# KAVODA TV API: developer guide

This is the human guide to the KAVODA TV backend API. The full reference is the OpenAPI document
next to this file:

- [`openapi.json`](openapi.json): the reference, generated from the code. Every endpoint, body,
  response, error code and example is in it.
- [`index.html`](index.html): the same document rendered with Redoc. Open it in a browser.
- Live, on the running API: this guide at [`/docs/guide`](https://api.kavoda.tv/docs/guide), [`/docs`](https://api.kavoda.tv/docs) (Swagger UI),
  [`/redoc`](https://api.kavoda.tv/redoc) and [`/openapi.json`](https://api.kavoda.tv/openapi.json).

This folder is the source of truth for the API contract. If this guide and `openapi.json` ever
disagree, `openapi.json` wins (a unit test keeps it equal to the code).

## Hosts

| Host | What |
|---|---|
| `https://api.kavoda.tv` | The API. JSON over HTTPS. |
| `https://media.kavoda.tv` | Media: HLS playlists, AES-128 segments, keys, hooks and art. A Cloudflare Worker in front of R2. Every URL carries a media token: `https://media.kavoda.tv/t/<token>/<object key>`. |

Apps only ever talk to these two hosts. They never call Supabase directly.

## Get started in five calls

All examples use fake tokens. Every request with a body sends `Content-Type: application/json`.

**1. Start an anonymous session.** No token needed.

```http
POST /auth/anon
{"device_id": "roku-3c1f9a"}
```

```json
{"access_token": "eyJ...", "refresh_token": "fake-refresh-token", "expires_in": 3600,
 "user_id": "4f9c2b1e-8d7a-4c3b-9e2f-1a6b5c4d3e2f"}
```

Store both tokens and the `user_id`. Send `Authorization: Bearer <access_token>` from now on.
Refresh before `expires_in` runs out. The `user_id` stays the same until the viewer signs in on
the TV (see below).

**2. Get the feed.** Needs the bearer and `X-Device-Id`.

```http
GET /feed
Authorization: Bearer eyJ...
X-Device-Id: roku-3c1f9a
```

```json
{"recommendation_id": "9b2e...", "algorithm_version": "bandit-1", "entitled": false,
 "media_token": "eyJ...", "media_base_url": "https://media.kavoda.tv/t/eyJ.../",
 "items": [{"position": 0, "series_id": "blv-001", "title": "...",
            "hook_path": "hooks/blv/blv-001/blv-001-hook/master.m3u8",
            "artwork_path": "art/blv/blv-001/keyart_en.jpg", "...": "..."}]}
```

Play the hook at `media_base_url + hook_path`. Show art at `media_base_url + artwork_path`. Keep
`recommendation_id`, `algorithm_version` and each item's `position` for your events.

**3. List the episodes of the chosen series.**

```http
GET /series/blv-001/episodes
Authorization: Bearer eyJ...
```

```json
[{"episode_id": "blv-001-e001", "episode_number": 1,
  "playlist_path": "hls/blv/blv-001/blv-001-e001/master.m3u8",
  "thumbnail_path": "art/blv/blv-001/blv-001-e001/thumb.jpg", "...": "..."}]
```

Only packaged episodes are listed. Playback always starts at episode 1, whatever the hook showed.

**4. Mint a playback token for the series.**

```http
POST /playback/token
Authorization: Bearer eyJ...
{"device_id": "roku-3c1f9a", "series_id": "blv-001"}
```

```json
{"token": "eyJ...", "expires_at": "2026-10-11T20:25:00Z",
 "base_url": "https://media.kavoda.tv/t/eyJ.../", "entitled": false}
```

`entitled: false` means show ads. It never blocks playback.

**5. Build the stream URL and play it.**

```
stream URL = base_url + playlist_path
           = https://media.kavoda.tv/t/eyJ.../hls/blv/blv-001/blv-001-e001/master.m3u8
```

The player fetches playlists, segments, WebVTT subtitles and the AES-128 key from that tree on
its own. Auto-advance with `GET /series/{series_id}/next?after=<episode_number>`.

## TV sign-in (device flow): quickstart

The viewer never types a password on the TV. Three calls on the TV, one page on the phone.

```http
POST /device/code
{"device_id": "roku-3c1f9a"}
```

```json
{"device_code": "fake-device-code", "user_code": "KX7PQ4", "verification_url": "https://api.kavoda.tv/login",
 "qr_url": "/device/qr/KX7PQ4.png", "interval": 5, "expires_in": 600}
```

```http
POST /device/poll
{"device_code": "fake-device-code"}
```

```json
{"status": "pending"}
{"status": "approved", "exchange_token": "fake-exchange-token"}
```

```http
POST /auth/exchange
{"exchange_token": "fake-exchange-token"}
```

```json
{"access_token": "eyJ...", "refresh_token": "fake-refresh-token", "expires_in": 3600,
 "user_id": "7d2c0f1a-3b4e-4c5d-8e9f-0a1b2c3d4e5f"}
```

Step by step:

1. TV: `POST /device/code` with `{"device_id"}`. You get `device_code` (keep it on the TV),
   `user_code`, `verification_url`, `qr_url`, `interval` and `expires_in`.
2. TV: show `user_code` and the QR image from `GET <qr_url>` (a PNG).
3. Phone or laptop: the viewer scans the QR or opens `verification_url` (`GET /login?code=...`,
   an HTML page) and signs in by magic link or OAuth.
4. The page calls `POST /device/approve` with the viewer's session. Nothing for the TV to do.
5. TV: `POST /device/poll` with `{"device_code"}` every `interval` seconds. `status` is
   `pending`, then `approved` with a one-time `exchange_token`. Stop on `expired` (start again)
   or `consumed` (the token was already handed out).
6. TV: `POST /auth/exchange` with `{"exchange_token"}` within two minutes. You get a normal
   session, same shape as `/auth/anon`.
7. TV sign-in always gives a different `user_id` (the viewer's account), never the TV's
   anonymous one. So if the TV had an anonymous session, call `POST /auth/merge` with the new
   bearer and `{"anonymous_access_token": "<the old anonymous access token>"}`. This moves favorites,
   history and any subscription to the signed-in account. Safe to repeat.

## Tokens and headers

| Token | Where from | Lifetime | Used for |
|---|---|---|---|
| Access token (JWT) | `/auth/anon`, `/auth/refresh`, `/auth/exchange` | `expires_in` seconds (typically 1 hour) | `Authorization: Bearer` on the API |
| Refresh token | same | until used once | `POST /auth/refresh` |
| Playback token | `POST /playback/token` with `series_id` | 10 minutes | one series' HLS tree (`hls/...`) |
| Browse token | `GET /feed` (`media_token`); or `/playback/token` without `series_id` | 2 hours from the feed; 10 minutes from `/playback/token` | hooks and art (`hooks/`, `art/`) |

Rules:

- **Refresh rotates.** `POST /auth/refresh` returns a new refresh token every time. Store it. On
  401 `invalid_refresh_token`, start a new session with `POST /auth/anon`.
- **Paths are not URLs.** Every `*_path` field is an object key. Add it to the right base URL:
  `base_url` (playback token) for `playlist_path`; `media_base_url` (browse token) for
  `hook_path`, `artwork_path`, `artwork_paths`, `thumbnail_path` and `ambient_path`.
- **403 from the media host means refresh.** An expired or out-of-scope token gets 403. Mint a
  new token (or refetch the feed) and retry once.
- **Headers.** `Authorization` on every non-public route. `X-Device-Id` on `GET /feed` (at most
  128 characters). `Content-Type: application/json` on bodies. `CF-IPCountry` is set by
  Cloudflare; never send it yourself.

Public routes (no bearer): `/health`, `/auth/anon`, `/auth/refresh`, `/auth/exchange`,
`/auth/magic-link`, `/device/code`, `/device/poll`, `/device/qr/{user_code}.png`, `/login`.

## Gateway and limits

- **Geo: US and UK only.** Cloudflare blocks every request from outside the US and GB at the
  edge, on both hosts. The API also checks `CF-IPCountry` itself and answers 403
  `territory_not_allowed` if it is missing or not `US`/`GB`.
- **Rate limits: planned, not live yet.** Per IP: `/events` 60 requests per 10 s, `/feed` 30 per
  10 s, `/auth/*` 10 per 10 s. Build in backoff on 429 now.
- **TLS** everywhere (Cloudflare Full strict).
- **Caching.** `GET /feed` is `Cache-Control: private, no-store`. Keys, the QR and the login page
  are `no-store`.

| Limit | Value |
|---|---|
| Events per batch | 1 to 100 |
| `POST /events` body | 256 KiB (262,144 bytes) |
| Device ids, device codes, exchange tokens | 128 characters |
| `series_id` in `POST /playback/token` | 64 characters |
| Device code | expires in 10 minutes; poll every 5 s |
| Exchange token | 2 minutes, single use |

The exact values are in the `info.description` of `openapi.json`, generated from the code.

## Errors

Every error the API raises itself has the same shape:

```json
{"error": "territory_not_allowed", "detail": null}
```

Switch on `error`. `detail` is extra context, or null: a list of `{field, message}` for
`validation_error`, `{max, got}` for `batch_too_large`, and so on. The full list of codes with
their status and meaning is in `openapi.json` (`info.description`, "Errors"), and each operation
lists the codes it can return with an example.

Not every error body has that shape. An unknown path or wrong method gets
`{"detail": "Not Found"}` or `{"detail": "Method Not Allowed"}`. An unexpected server fault is a
plain-text 500. The media host and the Cloudflare gateway (geo block, rate limits) answer in
plain text or HTML. Handle those by status code.

The codes apps handle most:

| Status | `error` | What to do |
|---|---|---|
| 401 | `invalid_token` | Refresh the session; if that fails, start a new one. |
| 401 | `invalid_refresh_token` | `POST /auth/anon`. |
| 403 | `territory_not_allowed` | Show the "not available in your country" screen. |
| 404 | `series_not_found` | Drop the series from the UI. |
| 413 / 422 | event errors | Fix the batch; do not retry it unchanged. |
| 502 | `auth_upstream_unavailable` | Retry with backoff. |

## Events

`POST /events` takes a JSON array of 1 to 100 events. Each event follows the frozen schema
[`schema/events-1.0.json`](../../schema/events-1.0.json) (embedded in `openapi.json` as
`EventV1`). The rules:

- `user_id` must be the bearer's user (anonymous users too).
- `recommendation_id` must be one `GET /feed` served to this user; copy `algorithm_version` too.
- `event_id` is a fresh UUID per event; `client_seq` counts up from 1 per `session_id`.
- One bad event rejects the whole batch (422 `invalid_events`, with indexes); nothing is written.
- Re-sending a batch is safe. The response counts `accepted`, `duplicates` (event ids already
  stored) and `conflicts` (a repeated `session_id` + `client_seq`).

A complete batch: the feed impression, the play, and the 10-second milestone.

```json
[
  {"schema_version": "1.0", "event_id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
   "event_type": "impression", "occurred_at": "2026-10-11T20:15:01.004Z",
   "user_id": "4f9c2b1e-8d7a-4c3b-9e2f-1a6b5c4d3e2f", "session_id": "s-20261011-2015",
   "client_seq": 1, "platform": "roku", "app_version": "1.0.0", "series_id": "blv-001",
   "recommendation_id": "9b2e4c6a-1f3d-4e5b-8a7c-0d9e8f7a6b5c", "algorithm_version": "bandit-1",
   "position": 0, "items_shown_alongside": ["blv-002", "blv-003"],
   "properties": {"source": "feed"}},
  {"schema_version": "1.0", "event_id": "0b7d1c2e-3f4a-4b5c-8d6e-7f8091a2b3c4",
   "event_type": "play", "occurred_at": "2026-10-11T20:15:03.120Z",
   "user_id": "4f9c2b1e-8d7a-4c3b-9e2f-1a6b5c4d3e2f", "session_id": "s-20261011-2015",
   "client_seq": 2, "platform": "roku", "app_version": "1.0.0", "series_id": "blv-001",
   "episode_id": "blv-001-e001", "recommendation_id": "9b2e4c6a-1f3d-4e5b-8a7c-0d9e8f7a6b5c",
   "algorithm_version": "bandit-1", "position": 0, "properties": {"source": "feed"}},
  {"schema_version": "1.0", "event_id": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f",
   "event_type": "milestone", "occurred_at": "2026-10-11T20:15:13.150Z",
   "user_id": "4f9c2b1e-8d7a-4c3b-9e2f-1a6b5c4d3e2f", "session_id": "s-20261011-2015",
   "client_seq": 3, "platform": "roku", "app_version": "1.0.0", "series_id": "blv-001",
   "episode_id": "blv-001-e001", "recommendation_id": "9b2e4c6a-1f3d-4e5b-8a7c-0d9e8f7a6b5c",
   "algorithm_version": "bandit-1", "milestone": "10s", "position_seconds": 10.0}
]
```

Response: `{"accepted": 3, "duplicates": 0, "conflicts": 0}`.

## Every endpoint

Links open the operation in the Redoc page ([`index.html`](index.html)). "Bearer" is the user's
access token; "Playback token" is the media token, sent only by the media Worker.

| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | `/docs/guide` | public | This guide as Markdown, from the running API ([`get_developer_guide`](index.html#operation/get_developer_guide)) |
| GET | `/health` | public | Liveness and database check ([`get_health`](index.html#operation/get_health)) |
| POST | `/auth/anon` | public | Start an anonymous session ([`create_anonymous_session`](index.html#operation/create_anonymous_session)) |
| POST | `/auth/refresh` | public | Refresh a session; rotates the refresh token ([`refresh_session`](index.html#operation/refresh_session)) |
| POST | `/auth/exchange` | public | Device-flow exchange token -> session ([`exchange_device_token`](index.html#operation/exchange_device_token)) |
| POST | `/auth/merge` | bearer | Merge an anonymous user into this account ([`merge_anonymous_user`](index.html#operation/merge_anonymous_user)) |
| POST | `/device/code` | public | Start TV sign-in ([`request_device_code`](index.html#operation/request_device_code)) |
| GET | `/device/qr/{user_code}.png` | public | QR code PNG ([`get_device_qr_png`](index.html#operation/get_device_qr_png)) |
| GET | `/login` | public | Sign-in page, HTML ([`get_login_page`](index.html#operation/get_login_page)) |
| POST | `/auth/magic-link` | public | Email a sign-in link ([`send_magic_link`](index.html#operation/send_magic_link)) |
| POST | `/device/approve` | bearer (signed in) | Approve a TV code ([`approve_device_code`](index.html#operation/approve_device_code)) |
| POST | `/device/poll` | public | Poll a TV code ([`poll_device_code`](index.html#operation/poll_device_code)) |
| GET | `/feed` | bearer | Ranked vertical feed + browse token ([`get_feed`](index.html#operation/get_feed)) |
| GET | `/series/{series_id}` | bearer | One series ([`get_series`](index.html#operation/get_series)) |
| GET | `/series/{series_id}/episodes` | bearer | A series' packaged episodes ([`list_series_episodes`](index.html#operation/list_series_episodes)) |
| GET | `/series/{series_id}/next` | bearer | Next episode after N ([`get_next_episode`](index.html#operation/get_next_episode)) |
| POST | `/playback/token` | bearer | Mint a playback or browse token ([`mint_media_token`](index.html#operation/mint_media_token)) |
| GET | `/playback/key/{episode_id}` | playback token | HLS key, media Worker only ([`get_episode_key`](index.html#operation/get_episode_key)) |
| POST | `/favorites` | bearer | Add to My List ([`add_favorite`](index.html#operation/add_favorite)) |
| DELETE | `/favorites/{series_id}` | bearer | Remove from My List ([`remove_favorite`](index.html#operation/remove_favorite)) |
| GET | `/me/favorites` | bearer | My List ([`list_my_favorites`](index.html#operation/list_my_favorites)) |
| GET | `/me/entitlement` | bearer | Subscription state ([`get_my_entitlement`](index.html#operation/get_my_entitlement)) |
| POST | `/roku/receipt` | bearer | Record a Roku Pay purchase ([`submit_roku_receipt`](index.html#operation/submit_roku_receipt)) |
| POST | `/events` | bearer | Post a batch of events ([`post_events`](index.html#operation/post_events)) |

## Changing the API

The document is generated from the code; never edit `openapi.json` by hand.

1. Change the route, and its `summary`, `description`, `responses` and examples (shared texts,
   error codes and examples live in `src/kavoda/api/openapi_docs.py`).
2. Run `make openapi` and commit `docs/api/openapi.json` with the change.
3. `make test`. `tests/unit/api/test_openapi_document.py` fails if the committed file is stale,
   an operation lacks a summary, description or tags, a raised error code is undocumented, or a
   response schema could carry a stream key or `stream_path`.

`info.version` in the committed file may lag the deployed version (the release workflow bumps it
on every merge to `main`); the live `/openapi.json` always reports the running version.
