# List licensed catalog

Returns every film and episode your organisation may stream right now. The response is metadata only and never contains a video URL; request playback per episode with [Request playback](https://docs.wandura.ai/v1/developers/reference/playback).

```
GET /api/delivery/v1/catalog
```

**Scope:** `metadata:read`

## Description

A film is included when your organisation has an active streaming licence for it, today (UTC) is inside the licence window, and the film is published. For each included film, all published episodes are returned, plus a placeholder row for each planned episode that is not published yet. Films arrive sorted by `display_order`. Without `limit` and `cursor` you receive the whole catalog in one response; with `limit` you receive pages of films, each with all their episodes, until `next_cursor` is `null`. Pages are not one consistent snapshot: see [Canonical API behaviour · Pagination](https://docs.wandura.ai/v1/developers/reference/behaviour#pagination) before you page. See [Catalog](https://docs.wandura.ai/v1/developers/concepts/catalog).

## Request parameters

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `Authorization` | header | string | Yes | `Bearer <API key>` for a key with the `metadata:read` scope. |
| `limit` | query | integer | No | Optional. Films per page, 1 to 200. Omit it (and `cursor`) to receive the whole catalog in one response. |
| `cursor` | query | string | No | Optional. The `next_cursor` (or the cursor inside `links.next`) of the previous page. Opaque; pass it back unchanged. With a cursor and no `limit` the page size is 50. |
| `Wandura-Version` | header | string | No | Optional. A dated API version (YYYY-MM-DD) to use for this request instead of the version pinned to your API key. Unknown or retired values answer 400 with `error_code` `unsupported_version`. |
| `Wandura-Simulate` | header | string | No | Optional, test organisations only. Asks Wandura to answer with a simulated error so you can test how your app handles it, for example `rate_limited`, `service_unavailable` or `not_entitled:outside_window`. The simulated response has the same status, body and headers as the real one, plus a `Wandura-Simulated` header naming the scenario. It issues no ticket and does not count against your rate limits, except `partial_batch`, whose first item is a real ticket. Simulated responses have their own limit of 300 per minute per organisation. Keys of production organisations ignore this header completely. An unknown value, or a scenario this endpoint does not support, is ignored and the request runs normally. See the Testing guide for the list of scenarios. |

## Request example

```bash
curl -s https://www.wandura.ai/api/delivery/v1/catalog \
  -H "Authorization: Bearer $WANDURA_API_KEY"
```

## Response

`200 OK` with an object containing two arrays.

### Film fields (`films[]`)

| Field | Type | Description |
|---|---|---|
| `id` | string (UUID) | Film ID. It never changes: use it as your primary key, and as `film_ref` in playback. |
| `title` | string | Film title. |
| `slug` | string | URL-safe film identifier, unique across films. Wandura does not change it after the film is created. Also accepted as `film_ref` in playback. |
| `synopsis_short` | string or null | Short description for cards. Length varies. |
| `synopsis_full` | string or null | Full description for detail pages. |
| `genres` | array of strings | Genre labels. Can be empty. |
| `language` | string or null | Language of the film, as free text written by Wandura's content team (for example `en` or `English`). It is not a fixed list of codes: display it, do not parse it. |
| `poster_url` | string or null | Film poster URL. |
| `status` | string | Always `published`. |
| `display_order` | integer | Display position, smallest first. The array is already sorted by it. |
| `planned_episodes` | integer or null | Total number of episodes planned. `null` when not set: then the film gets no placeholder rows, and `episodes` lists only its published episodes. |
| `free_until_episode` | integer or null | Episodes with `episode_no` up to and including this number are free. `0` means no free episode. `null` means no number is set for the film: treat every episode as paid, and ask Wandura if you expected free episodes. |
| `audience` | string or null | `women`, `men`, `lgbtq`, or `null` if unclassified. New values may be added. |
| `form` | string or null | `live_action`, `anime`, or `null` if unclassified. New values may be added. |
| `setting` | string or null | `modern`, `city`, `fantasy`, `campus`, `palace`, or `null` if unclassified. New values may be added. |
| `code` | string or null | Wandura series code, for example `WD000016`. Stable and shared across every Wandura system (the playlist in your media space has this name). Use it with GET /api/delivery/v1/series/{code}. |
| `title_i18n` | object | Text by language: keys are BCP-47 codes, values are the text, for example `{"en": "The Heiress Returns"}`. Today Wandura fills `en`; more languages may appear. A missing key means no text in that language: fall back to `en`. Empty object when there is no text. |
| `synopsis_short_i18n` | object | Text by language: keys are BCP-47 codes, values are the text, for example `{"en": "The Heiress Returns"}`. Today Wandura fills `en`; more languages may appear. A missing key means no text in that language: fall back to `en`. Empty object when there is no text. |
| `synopsis_i18n` | object | Text by language: keys are BCP-47 codes, values are the text, for example `{"en": "The Heiress Returns"}`. Today Wandura fills `en`; more languages may appear. A missing key means no text in that language: fall back to `en`. Empty object when there is no text. |
| `content_rating` | string or null | Age rating: `all`, `13+`, `16+` or `18+`. `null` when not rated yet. |
| `is_completed` | boolean or null | `true` when every planned episode is released, `false` while more are coming, `null` when the planned count is unknown. |
| `release_date` | string or null | Original release date, `YYYY-MM-DD`, or `null` when unknown. |
| `default_lang` | string or null | Audio language of the film as a BCP-47 code (`en`, `vi`, `pt-BR`), or `null` when unknown. Prefer it over the free-text `language`. |
| `tags` | array | Coded genre and theme tags. At most one has `is_main` `true` (the main genre, shown under the title). Can be empty. |
| `people` | array | Main cast or characters, leads first. Can be empty. |
| `trailer` | object or null | The trailer video, or `null`. `vid` is the video ID in Wandura's media space. |
| `metadata_version` | string or null | Hash of the film's metadata (titles, synopses, rating, tags, people, artwork, episodes and their subtitles). Unchanged hash = nothing to update on your side. |
| `metadata_updated_at` | string (ISO 8601, UTC) or null | When `metadata_version` last changed. |
| `images` | array | Artwork for the film, one entry per kind Wandura has. A kind Wandura does not have is left out (never a `null` entry), so the array can be empty. Every `url` is an absolute `https` URL on a Wandura host that you can load directly. `poster_url` stays as it is. |

### Episode fields (`episodes[]`)

| Field | Type | Description |
|---|---|---|
| `id` | string (UUID) or null | Episode ID. Use it as `episode_id` in playback. `null` on placeholder rows. |
| `film_id` | string (UUID) | ID of the film the episode belongs to. |
| `episode_no` | integer | Episode number, starting at 1. |
| `title` | string or null | Episode title. |
| `synopsis` | string or null | Episode description. Often `null`. |
| `duration_ms` | integer or null | Duration in milliseconds. |
| `poster_uri` | string or null | Episode thumbnail reference. Not guaranteed to be a directly loadable URL — use `poster_url`. |
| `poster_url` | string or null | The episode image as an absolute `https` URL you can load directly, or `null` (then use the film's `poster_url`). |
| `status` | string | `published`, or `planned` for a placeholder row. |
| `stream_ready` | boolean | `true` when a ticket can be issued for the episode. Always `false` on placeholder rows. |
| `title_i18n` | object | Text by language: keys are BCP-47 codes, values are the text, for example `{"en": "The Heiress Returns"}`. Today Wandura fills `en`; more languages may appear. A missing key means no text in that language: fall back to `en`. Empty object when there is no text. |
| `subtitles` | array | Subtitle languages available for the episode (the files come with the playback ticket). Empty on placeholder rows. |
| `subtitles[].lang` | string | BCP-47 code, for example `en`. |

Within a film, each `episode_no` appears once, so `film_id` + `episode_no` identifies an episode. Episodes are grouped by film ID and sorted by `episode_no` within each film. Take the film order from `films`.

If your organisation has no current licences, both arrays are empty.

## Response example

```json
{
  "films": [
    {
      "id": "3f6c1a52-8d0e-4b7a-9c21-5e4f2a7b9d10",
      "title": "The Heiress Returns",
      "slug": "the-heiress-returns",
      "synopsis_short": "Cast out on her wedding day, a disowned heiress comes back to take everything.",
      "synopsis_full": "Cast out on her wedding day, Mia loses her family, her name and her fortune. Five years later she returns with a new identity and one goal.",
      "genres": ["romance", "revenge"],
      "language": "en",
      "poster_url": "https://cdn.example.com/posters/the-heiress-returns.jpg",
      "status": "published",
      "display_order": 10,
      "planned_episodes": 3,
      "free_until_episode": 1,
      "audience": "women",
      "form": "live_action",
      "setting": "modern",
      "code": "WD000016",
      "title_i18n": { "en": "The Heiress Returns" },
      "synopsis_short_i18n": { "en": "Cast out on her wedding day, a disowned heiress comes back to take everything." },
      "synopsis_i18n": { "en": "Cast out on her wedding day, Mia loses her family, her name and her fortune. Five years later she returns with a new identity and one goal." },
      "content_rating": "13+",
      "is_completed": false,
      "release_date": null,
      "default_lang": "en",
      "tags": [
        { "slug": "revenge", "name": "Revenge", "name_i18n": { "en": "Revenge" }, "type": "genre", "is_main": true },
        { "slug": "romance", "name": "Romance", "name_i18n": { "en": "Romance" }, "type": "genre", "is_main": false },
        { "slug": "heiress", "name": "Heiress", "name_i18n": { "en": "Heiress" }, "type": "theme", "is_main": false }
      ],
      "people": [{ "name": "Mia", "role": "lead", "description": "A disowned heiress back for what is hers.", "avatar_url": null }],
      "trailer": null,
      "metadata_version": "9a0b1c2d3e4f5061728394a5b6c7d8e9",
      "metadata_updated_at": "2026-10-03T09:00:00.000Z",
      "images": [
        { "kind": "poster_3_4", "lang": null, "url": "https://cdn.example.com/posters/the-heiress-returns-3x4.jpg", "width": 1080, "height": 1440 },
        { "kind": "cover_9_16", "lang": null, "url": "https://cdn.example.com/posters/the-heiress-returns.jpg", "width": 1080, "height": 1920 }
      ]
    }
  ],
  "episodes": [
    {
      "id": "b2a4d6e8-1c3f-4a5b-8d7e-9f0a1b2c3d4e",
      "film_id": "3f6c1a52-8d0e-4b7a-9c21-5e4f2a7b9d10",
      "episode_no": 1,
      "title": "Episode 1",
      "synopsis": null,
      "duration_ms": 94000,
      "poster_uri": null,
      "status": "published",
      "stream_ready": true,
      "title_i18n": { "en": "Episode 1" },
      "subtitles": [{ "lang": "en" }]
    },
    {
      "id": "c7d9e1f3-5a6b-4c8d-9e0f-1a2b3c4d5e6f",
      "film_id": "3f6c1a52-8d0e-4b7a-9c21-5e4f2a7b9d10",
      "episode_no": 2,
      "title": "Episode 2",
      "synopsis": null,
      "duration_ms": 101000,
      "poster_uri": null,
      "status": "published",
      "stream_ready": false
    },
    {
      "id": null,
      "film_id": "3f6c1a52-8d0e-4b7a-9c21-5e4f2a7b9d10",
      "episode_no": 3,
      "title": null,
      "synopsis": null,
      "duration_ms": null,
      "poster_uri": null,
      "status": "planned",
      "stream_ready": false
    }
  ]
}
```

## Response headers
| Header | Responses | Description |
|---|---|---|
| `Wandura-API-Status` | all | API status. `beta` today, on every response, success or error. |
| `Wandura-Version` | all | The dated API version (YYYY-MM-DD) this response was produced in: the version pinned to your API key, or the one you sent in the `Wandura-Version` request header. Keys created before dated versions existed use `2026-10-02`. |
| `Wandura-Request-Id` | all | An opaque id for this request (`req_` followed by 26 characters). Every response carries it, errors included, and error bodies repeat it as `request_id`. Log it, and quote it when you contact Wandura about a request: it finds the exact request in our logs. Do not parse it. |
| `Cache-Control` | all | Always `no-store`. Do not cache these responses in shared caches. |
| `Deprecation` | 200 | Present only when this endpoint, or the dated version serving this response, is deprecated (RFC 9745): `@<unix seconds>` of the deprecation date. Log it in your monitoring. |
| `Sunset` | 200 | Present only when a deprecated endpoint or dated version has a date when it stops working (RFC 8594), as an HTTP date. |
| `Link` | 200 | Present with `Deprecation`: `<https://docs.wandura.ai/…>; rel="deprecation"` (and `rel="sunset"`) pointing to the page that explains what to use instead. |
| `Retry-After` | 429, 503 | Seconds to wait before retrying. Sent with 429 and with retryable 503 responses. |
| `X-RateLimit-Limit` | 429 | Requests (for playback, tickets) allowed per window for this endpoint group and your organisation. |
| `X-RateLimit-Window` | 429 | Window length in seconds. |

## Errors
| Status | `error_code` | When |
|---|---|---|
| 400 | `invalid_request` | `limit` is not a whole number from 1 to 200, or `cursor` is not a value this endpoint returned. |
| 400 | `unsupported_version` | The `Wandura-Version` header, or the version pinned to your key, is unknown or retired. The body adds `supported_versions` and `latest_version` (and `upgrade_to` when your pin was retired). |
| 401 | none | The `Authorization: Bearer` header is missing, or the key is unknown or revoked. |
| 403 | none | The key lacks the `metadata:read` scope. |
| 403 | none (`error` is `org_suspended`) | Your organisation's access is suspended. |
| 429 | `rate_limited` | The rate limit for the catalog was reached. |
| 503 | `service_unavailable` | Wandura could not verify the API key or read the catalog in time. Nothing is wrong on your side: retry after `Retry-After` seconds, with backoff. |

The two `403` responses carry no `error_code`. Tell them apart by `error`: it is exactly `org_suspended` when your organisation is suspended, and any other text means the key lacks `metadata:read`. This is the one case where you compare `error`; compare the whole string.

What to do in each case: [Errors](https://docs.wandura.ai/v1/developers/concepts/errors#catalog-errors).
