---
title: "Panel API Catalog"
description: "Read-only Panel API endpoints for browsing packages, bouquets, live streams, and VOD entries. The building blocks for line-creation UIs, storefronts, and catalog explorers."
---

> [!TIP]
> Using the official [SDKs](/docs/?page=panel-api-sdks)? Every endpoint below is a one-line typed method call — the HTTP details on this page are handled for you.

## The catalog is read-only

The catalog endpoints let your integration list what already exists in the panel: which packages are on sale, which bouquets can be assigned, which live channels and VOD entries the panel is streaming. **Provisioning happens elsewhere.** To create a subscriber you POST to [Lines](/docs/?page=panel-api-lines); to create a package, bouquet, channel, or VOD entry you use the panel UI.

Every catalog endpoint is a `GET`. There are no writes and no side effects. You can hit them as often as your rate limit allows and cache the responses on your side as aggressively as your product needs.

## Scopes

Each resource is gated by its own read scope. A key without the scope receives `403 insufficient_scope`.

| Endpoint | Scope |
|---|---|
| `GET /packages` | `packages:read` |
| `GET /bouquets` | `bouquets:read` |
| `GET /streams`, `GET /streams/{id}` | `streams:read` |
| `GET /vods`, `GET /vods/{id}` | `vods:read` |

## Admin vs reseller visibility

Admin and reseller keys hit the exact same URLs and get the exact same response shape. What changes silently is *which rows* the caller sees:

- **`/packages`** — Admin sees every package. A reseller sees only packages whose `groups` list contains their `member_group_id` (i.e. the packages the admin has authorized their group to sell).
- **`/bouquets`** — Admin sees every bouquet. A reseller sees the **union** of `bouquets` across the packages they can sell, cached server-side for five minutes.
- **`/streams`** and **`/vods`** — Not filtered by reseller. Both admin and reseller keys see the full catalog of the panel. This is intentional: a reseller building a storefront needs to show what content exists, even for packages/bouquets they don't personally sell.

No error is raised when a reseller's visible set is empty. The response is `{"items": []}`.

## Pagination

`/streams` and `/vods` use **cursor keyset pagination**. `/packages` and `/bouquets` return the full list in one call (there are typically fewer than a few hundred of each).

| Query param | Effect |
|---|---|
| `limit` | Page size. Default `50`, min `1`, max `100`. Values above `100` are silently clamped. |
| `cursor` | Numeric id of the last item you saw. The server returns items with `id > cursor`. Omit for the first page. |
| `q` | Case-insensitive substring match on the name. |
| `category_id` | Filter by category id. Categories are typed: `/streams` accepts categories of type `live`, `/vods` accepts categories of type `movie`. |

Each response includes a `next_cursor` field:

```json
{
  "items": [ ... ],
  "next_cursor": 4287
}
```

When the last page is reached, `next_cursor` is `null`. Do **not** attempt offset/page pagination (`?page=2` or `?offset=100`) — those params are ignored and you will re-fetch page one forever. Feed the previous response's `next_cursor` value back into the next request as `?cursor=<value>`.

> [!NOTE]
> The PHP and Python examples below assume a `$client` / `client` already constructed as shown on the [SDKs page](/docs/?page=panel-api-sdks) (base URL plus API key), so each endpoint shows only the call itself. All catalog methods live under `$client->catalog` / `client.catalog` and are read-only.

## `GET /packages`

Lists every package the caller can use to create a line. Not paginated — packages are ordered alphabetically by `package_name`.

```bash
curl -H "Authorization: Bearer $TOKEN" \
     https://<your-panel-domain>/panel-api/v1/packages
```

```php
$packages = $client->catalog->packages();  // plain array of Package objects
foreach ($packages as $p) {
    echo $p->id, ' ', $p->packageName, ' (', $p->officialCredits, " credits)\n";
}
```

```python
packages = client.catalog.packages()  # plain list of Package objects
for p in packages:
    print(p.id, p.package_name, p.official_credits)
```

**Response 200:**

```json
{
  "items": [
    {
      "id": 3,
      "package_name": "Gold 12 months",
      "is_trial": false,
      "is_official": true,
      "official_credits": 12.0,
      "official_duration": 12,
      "official_duration_in": "months",
      "trial_credits": 0.0,
      "trial_duration": 0,
      "trial_duration_in": "hours",
      "max_connections": 2,
      "is_restreamer": false,
      "forced_country": ""
    }
  ]
}
```

### `official_*` vs `trial_*`

A single package row carries **two subscription profiles**: a paid one and (optionally) a trial one. The pair of `official_*` fields describes the paid subscription; the pair of `trial_*` fields describes the trial version of the same package.

- `is_trial: true` means the package is **only usable as a trial**. Its paid fields will be zero.
- `is_trial: false` with `trial_duration > 0` means the package is normally paid, and the caller can request `"is_trial": true` when creating a line to spend a much shorter trial rather than the full paid duration.
- `is_official: false` marks the package as internal or hidden from public storefronts.

When you POST to `/lines` with `"is_trial": true`, the API bills using `trial_credits` and extends the account by `trial_duration` in `trial_duration_in` units. Otherwise it uses the `official_*` triplet.

`duration_in` is always one of `"hours"`, `"days"`, `"weeks"`, `"months"`, `"years"`. `forced_country` is a two-letter ISO code that forces every line created under this package to that country (empty string means no restriction).

## `GET /bouquets`

Lists bouquets available to the caller. Not paginated — bouquets are ordered by their internal `order` field (the same order they appear in the panel UI).

```bash
curl -H "Authorization: Bearer $TOKEN" \
     https://<your-panel-domain>/panel-api/v1/bouquets
```

```php
$bouquets = $client->catalog->bouquets();  // plain array of Bouquet objects
foreach ($bouquets as $b) {
    echo $b->id, ' ', $b->name, PHP_EOL;
}
```

```python
bouquets = client.catalog.bouquets()  # plain list of Bouquet objects
for b in bouquets:
    print(b.id, b.name)
```

**Response 200:**

```json
{
  "items": [
    {"id": 1, "name": "USA - Premium", "order": 1},
    {"id": 4, "name": "Sports - International", "order": 2},
    {"id": 9, "name": "Kids", "order": 3}
  ]
}
```

Bouquets are groupings of live channels and VODs. When you create a line, you attach a list of bouquet ids to it and those become the subscriber's playlist. If you skip `bouquets` at line-creation time, the panel copies the package's default bouquet list.

## `GET /streams`

Lists live channels. Paginated. Filterable by category and by name substring.

```bash
curl -G -H "Authorization: Bearer $TOKEN" \
     --data-urlencode "limit=50" \
     --data-urlencode "cursor=0" \
     --data-urlencode "category_id=17" \
     https://<your-panel-domain>/panel-api/v1/streams
```

```php
$page = $client->catalog->streams(limit: 50, categoryId: 17);
foreach ($page->items as $stream) {
    echo $stream->id, ' ', $stream->name, PHP_EOL;
}
$next = $page->nextCursor;  // pass back as the cursor argument for the next page
```

```python
page = client.catalog.streams(limit=50, category_id=17)
for stream in page.items:
    print(stream.id, stream.name)
next_cursor = page.next_cursor  # pass back as cursor= for the next page
```

**Response 200:**

```json
{
  "items": [
    {
      "id": 214,
      "name": "ESPN HD",
      "icon": "https://cdn.example.com/logos/espn.png",
      "categories": [
        {"id": 17, "name": "Sports"},
        {"id": 22, "name": "USA"}
      ]
    }
  ],
  "next_cursor": 4287
}
```

A channel can belong to more than one category. The `categories` field returns the full list.

> [!NOTE]
> The catalog **never exposes source URLs, primary origins, DRM keys, or FFmpeg command flags** for a stream. Those are operator-only fields visible in the panel UI. The Panel API is a management surface, not a streaming surface. If your integration needs to play a channel, use the subscriber's XC playlist (`/get.php?username=...&password=...`) after creating a line.

## `GET /streams/{id}`

Fetches one live channel by id. Same shape as an item in the listing.

```bash
curl -H "Authorization: Bearer $TOKEN" \
     https://<your-panel-domain>/panel-api/v1/streams/214
```

```php
$stream = $client->catalog->stream(214);
echo $stream->name, PHP_EOL;
```

```python
from xtream_ai_panel_api.exceptions import NotFoundException

try:
    stream = client.catalog.stream(214)
    print(stream.name)
except NotFoundException:
    print("stream not found")
```

**Response 200:** identical to a `/streams` list item.

**Errors:** `404 not_found` if the stream id does not exist in the panel.

## `GET /vods`

Lists on-demand movies and series. Paginated. Filterable by category and by name substring. Rows with `hide_vod = 1` (hidden by the panel operator) are excluded automatically.

```bash
curl -G -H "Authorization: Bearer $TOKEN" \
     --data-urlencode "limit=100" \
     --data-urlencode "q=matrix" \
     https://<your-panel-domain>/panel-api/v1/vods
```

```php
$page = $client->catalog->vods(limit: 100, q: 'matrix');
foreach ($page->items as $vod) {
    echo $vod->id, ' ', $vod->name, ' ', $vod->year, PHP_EOL;
}
```

```python
page = client.catalog.vods(limit=100, q="matrix")
for vod in page.items:
    print(vod.id, vod.name, vod.year)
```

**Response 200:**

```json
{
  "items": [
    {
      "id": 8801,
      "name": "The Matrix",
      "icon": "https://image.tmdb.org/t/p/w500/f89U3ADr1oiB1s9GkdPOEpXUk5H.jpg",
      "year": 1999,
      "rating": 8.7,
      "is_serie": false,
      "categories": [
        {"id": 44, "name": "Action"},
        {"id": 51, "name": "Sci-Fi"}
      ]
    }
  ],
  "next_cursor": 8807
}
```

Field notes:

- **`year`** is derived from the release date in the TMDB cache. `null` if the panel doesn't have a release date for the item.
- **`rating`** is the TMDB rating (0.0 to 10.0). `null` if not available.
- **`is_serie: true`** means the entry is a series (with seasons and episodes); `false` means a single movie. Series and movies share the same VOD id space in Xtream AI.
- **`icon`** is the poster URL. The panel pulls posters from TMDB and caches them locally.

## `GET /vods/{id}`

Fetches one VOD entry by id. Same shape as a `/vods` list item.

```bash
curl -H "Authorization: Bearer $TOKEN" \
     https://<your-panel-domain>/panel-api/v1/vods/8801
```

```php
$vod = $client->catalog->vod(8801);
echo $vod->name, PHP_EOL;
```

```python
from xtream_ai_panel_api.exceptions import NotFoundException

try:
    vod = client.catalog.vod(8801)
    print(vod.name)
except NotFoundException:
    print("vod not found")
```

**Response 200:** identical to a `/vods` list item.

**Errors:** `404 not_found` if the id does not exist.

> [!NOTE]
> VOD metadata (year, rating, poster) is enriched automatically from the panel's local TMDB cache. It is read-only over the Panel API. There is no endpoint to trigger a metadata refresh; that is a panel-admin action performed from the CMS. If a VOD looks stale, ask the panel operator.

## Building a line-creation UI

The most common integration flow is: build a signup or renewal form that lets a customer pick a package and optionally a set of bouquets, then create the line. The three catalog endpoints above are the read side of that form.

**Step 1: fetch packages.** Ask for the packages the caller can sell:

```python
# `client` is built as shown on the SDKs page.
packages = client.catalog.packages()
# Filter to what you actually want to show
sellable = [p for p in packages if p.is_official and not p.is_trial]
```

**Step 2: fetch bouquets.** Ask for the bouquets the caller can attach:

```python
bouquets = client.catalog.bouquets()
```

**Step 3: render the form.** Show the sellable packages as a dropdown (label with `package_name`, subtitle with `official_credits` credits for `official_duration` `official_duration_in`, and `max_connections` device slots). Show the bouquets as a multi-select. Pre-select the bouquets that come with the chosen package — you can fetch them by consulting the package's default set (or leaving `bouquets` empty at line-creation time so the server copies the package defaults).

**Step 4: POST to `/lines`.** Once the customer confirms, create the subscriber:

```python
# The SDK generates the Idempotency-Key for you.
line = client.lines.create(
    package_id=chosen_package_id,
    username=form["username"],
    password=form["password"],
    bouquets=chosen_bouquet_ids,   # or omit to inherit the package defaults
    member_id=reseller_member_id,  # only for admin keys; reseller keys use themselves
)
print("Created line", line.id, "for", line.username)
```

Full contract for line creation, including the trial toggle, custom expirations, and billing rules, is in [Lines](/docs/?page=panel-api-lines).

## Caching on your side

Package and bouquet lists change rarely. A reasonable integration caches them for 5 to 15 minutes and only refetches when the customer opens the signup form. Stream and VOD catalogs change more often (operators add and remove channels routinely), but they are still safe to cache for a few minutes if you are only showing them to your users, not driving playback decisions.

The catalog endpoints do not send cache-control headers, so the caching policy is entirely on your integration.

## What is not in the catalog

Some fields that are visible in the panel UI are deliberately not exposed by the catalog endpoints. This is by design:

- **Stream source URLs, primary origins, DRM keys, FFmpeg flags.** Operator-only. The API is for managing subscribers, not for republishing the panel's origins.
- **The full `movie_propeties` blob on VODs** (raw TMDB payload). Only the essential subset above is returned. If you need richer metadata (cast, plot, runtime, backdrops) for a UI, query TMDB directly on your side with the movie name and year.
- **Numeric counts** (channels-per-bouquet, VODs-per-bouquet). Not returned by the list endpoint — count them client-side after listing content by category if you need per-bouquet totals.
- **`epg_id` and EPG assignments.** Not exposed. The panel resolves EPG server-side when the subscriber plays.

If your integration needs a field the catalog does not currently return, tell us — that is how new fields get prioritized.

## See also

- [Lines](/docs/?page=panel-api-lines) — create, list, update, enable/disable, renew, and delete subscriber lines. The consumer of everything the catalog returns.
- [Resellers](/docs/?page=panel-api-resellers) — manage resellers and adjust their credits or quotas.
- [Errors](/docs/?page=panel-api-errors) — full table of error slugs (`insufficient_scope`, `not_found`, `rate_limited`, ...).
- [Overview](/docs/?page=panel-api-overview) — what the Panel API covers and how the dialects fit together.
