Using the official 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; 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:

{
  "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>.

The PHP and Python examples below assume a $client / client already constructed as shown on the SDKs page (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.

curl -H "Authorization: Bearer $TOKEN" \
     https://<your-panel-domain>/panel-api/v1/packages
$packages = $client->catalog->packages();  // plain array of Package objects
foreach ($packages as $p) {
    echo $p->id, ' ', $p->packageName, ' (', $p->officialCredits, " credits)\n";
}
packages = client.catalog.packages()  # plain list of Package objects
for p in packages:
    print(p.id, p.package_name, p.official_credits)

Response 200:

{
  "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).

curl -H "Authorization: Bearer $TOKEN" \
     https://<your-panel-domain>/panel-api/v1/bouquets
$bouquets = $client->catalog->bouquets();  // plain array of Bouquet objects
foreach ($bouquets as $b) {
    echo $b->id, ' ', $b->name, PHP_EOL;
}
bouquets = client.catalog.bouquets()  # plain list of Bouquet objects
for b in bouquets:
    print(b.id, b.name)

Response 200:

{
  "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.

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
$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
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:

{
  "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.

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.

curl -H "Authorization: Bearer $TOKEN" \
     https://<your-panel-domain>/panel-api/v1/streams/214
$stream = $client->catalog->stream(214);
echo $stream->name, PHP_EOL;
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.

curl -G -H "Authorization: Bearer $TOKEN" \
     --data-urlencode "limit=100" \
     --data-urlencode "q=matrix" \
     https://<your-panel-domain>/panel-api/v1/vods
$page = $client->catalog->vods(limit: 100, q: 'matrix');
foreach ($page->items as $vod) {
    echo $vod->id, ' ', $vod->name, ' ', $vod->year, PHP_EOL;
}
page = client.catalog.vods(limit=100, q="matrix")
for vod in page.items:
    print(vod.id, vod.name, vod.year)

Response 200:

{
  "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.

curl -H "Authorization: Bearer $TOKEN" \
     https://<your-panel-domain>/panel-api/v1/vods/8801
$vod = $client->catalog->vod(8801);
echo $vod->name, PHP_EOL;
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.

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:

# `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:

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:

# 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.

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 — create, list, update, enable/disable, renew, and delete subscriber lines. The consumer of everything the catalog returns.
  • Resellers — manage resellers and adjust their credits or quotas.
  • Errors — full table of error slugs (insufficient_scope, not_found, rate_limited, ...).
  • Overview — what the Panel API covers and how the dialects fit together.