---
title: "SDKs. PHP and Python"
description: "The official PHP and Python SDKs for the Panel API. Typed models, typed exceptions, automatic retry, automatic idempotency, cursor pagination handled for you, MIT licensed."
---

## What these SDKs are

The Panel API is a plain HTTP + JSON service. You can call it from any language that speaks HTTP, and the [Quickstart](/docs/?page=panel-api-quickstart) shows exactly that with raw `curl`. But for teams building against the API in PHP or Python, we ship two official SDKs so you do not have to reinvent the plumbing every panel does the same way.

Each SDK is a thin wrapper around the same JSON API described in the [Overview](/docs/?page=panel-api-overview). It gives you a proper client object, typed request and response models, typed exceptions for every documented failure mode, automatic retry with exponential backoff on transient errors, and automatic idempotency for writes. Both are MIT licensed and hosted on GitHub. PHP lives at [github.com/Xtream-AI/api-panel-php-sdk](https://github.com/Xtream-AI/api-panel-php-sdk) and Python at [github.com/Xtream-AI/api-panel-python-sdk](https://github.com/Xtream-AI/api-panel-python-sdk).

## What the SDKs give you

Compared to hand-rolling `curl` calls, the SDKs remove a class of chores that are always the same and always easy to get wrong. Here is the concrete list, with why each one matters in practice.

| Feature | What it does | What it saves you |
|---|---|---|
| **Typed models** | Responses come back as objects with typed fields (`$line->expDate`, `line.exp_date`) instead of raw associative arrays. | You get IDE autocompletion, static analysis, and a compile-time error the moment the API adds or renames a field. No more `$data['exp_date'] ?? null` scattered through your code. |
| **Typed exceptions** | Every documented error slug maps to a concrete exception class (`InsufficientCreditsException`, `RateLimitException`, `ValidationException`, and so on). | You `catch` exactly the failure you care about, and you do not have to read every response body looking for an `error.slug` key. |
| **Automatic retry** | 5xx responses and network errors are retried with exponential backoff. 429 honors the server's `Retry-After` header. | A one-second network hiccup does not become a failed invoice in your billing system. You do not implement retry loops in ten different places. |
| **Automatic idempotency** | Every write generates a UUID `Idempotency-Key` and reuses it across internal retries. You can also pass your own business key. | A retry that reaches the server twice never creates two lines or charges the reseller twice. |
| **Cursor pagination** | `list()` returns a `PaginatedResponse` with the items plus the opaque cursor for the next page. | You iterate pages with a clean `while` loop instead of parsing `next_cursor` out of raw JSON. |
| **MIT licensed** | Both repositories are MIT. Fork them, vendor them, ship them inside your own product. | No license review meeting. |

If you like the shape of what the SDKs give you but you work in a different language, the [Overview](/docs/?page=panel-api-overview) documents the wire format and every doc page ends with a `curl` example. You have all you need to write your own thin client.

## Install

### PHP

Requirements: PHP 8.1 or newer, the `ext-curl` extension, the `ext-json` extension. No other runtime dependencies. These apply to both install options below.

You can install the PHP SDK two ways. Pick based on how the rest of your project handles dependencies.

#### Option A. Manual install (no Composer required)

This is the simplest path if your codebase does not already use Composer. It is common in older projects, in one-off scripts, or in server-side integrations that just need to talk to the panel from a single file.

1. Download the release ZIP: [v1.0.0 archive](https://github.com/Xtream-AI/api-panel-php-sdk/archive/refs/tags/v1.0.0.zip).
2. Extract it next to your script. You will get a folder named `api-panel-php-sdk-1.0.0/`.
3. Require the bundled autoloader at the top of your file:

```php
<?php
require __DIR__ . '/api-panel-php-sdk-1.0.0/autoload.php';

use XtreamAI\PanelApi\PanelApiClient;

$client = new PanelApiClient(
    baseUrl: 'https://<your-panel-domain>',
    token:   '<your-api-key>',
);
```

That is the whole install. No terminal, no package manager, no lockfile.

The `__DIR__` in the require line matters. It resolves to the directory of the file being executed, so the require works no matter which working directory PHP is invoked from. If you drop the `__DIR__` and write just `require 'api-panel-php-sdk-1.0.0/autoload.php'`, a cron running from `/` will fail with a "file not found" error the first time it fires.

#### Option B. Composer

If your project already uses Composer, this is the natural choice. The package is distributed over its Git repository (it is not published on Packagist), so you register the VCS repository first, then require the package.

```bash
composer config repositories.xtream-ai vcs https://github.com/Xtream-AI/api-panel-php-sdk
composer require xtream-ai/api-panel-php-sdk
```

From then on your code uses the usual Composer autoloader:

```php
<?php
require 'vendor/autoload.php';

use XtreamAI\PanelApi\PanelApiClient;

$client = new PanelApiClient(
    baseUrl: 'https://<your-panel-domain>',
    token:   '<your-api-key>',
);
```

### Python

Requirements: Python 3.10 or newer, and the `requests` library (2.28 or newer). If you use a modern virtual environment, `requests` gets installed for you as a transitive dependency.

Install straight from GitHub. The package is not on PyPI yet, so pip pulls the tagged release from the repository:

```bash
pip install "git+https://github.com/Xtream-AI/api-panel-python-sdk.git@v1.0.0"
```

If you pin dependencies with `requirements.txt`, add this line:

```
xtream-ai-api-panel-sdk @ git+https://github.com/Xtream-AI/api-panel-python-sdk.git@v1.0.0
```

Then import the client the usual way:

```python
from xtream_ai_panel_api import PanelApiClient

client = PanelApiClient(
    base_url="https://<your-panel-domain>",
    token="<your-api-key>",
)
```

## Quickstart with the SDK

The smallest thing you can do with either SDK is ask the panel who you are. It is one HTTP call, it needs no data, and it tells you at a glance that your token works and that the panel is reachable.

If you installed with Composer, use `require 'vendor/autoload.php';` instead of the autoload line below.

```php
<?php
require __DIR__ . '/api-panel-php-sdk-1.0.0/autoload.php';

use XtreamAI\PanelApi\PanelApiClient;

$client = new PanelApiClient(
    baseUrl: 'https://<your-panel-domain>',
    token:   '<your-api-key>',
);

$me = $client->me->get();
echo "Authenticated as: ", $me->type, " (key ", $me->keyPrefix, ")", PHP_EOL;
echo "Scopes: ",           implode(', ', $me->scopes),           PHP_EOL;
```

```python
from xtream_ai_panel_api import PanelApiClient

client = PanelApiClient(
    base_url="https://<your-panel-domain>",
    token="<your-api-key>",
)

me = client.me.get()
print(f"Authenticated as: {me.type} (key {me.key_prefix})")
print("Scopes:", ", ".join(me.scopes))
```

Run either script and you should see something like this. Your key prefix and scopes will differ.

```
Authenticated as: admin (key pk_live_abc7fake123)
Scopes: lines:read, lines:write, packages:read, bouquets:read, streams:read, vods:read, resellers:read, resellers:write, subresellers:write
```

If that call returns without an exception, your credentials are correct, your key has at least the `me` endpoint reachable, and the panel is up. From here you can move on to real work in the [Quickstart guide](/docs/?page=panel-api-quickstart), which walks through creating your first line end to end.

## Resources

The client groups methods under one attribute per resource. PHP uses `camelCase` method names. Python uses `snake_case`. Everything else is identical. Below is the SDK-level reference. For the underlying HTTP shapes and error slugs, see the resource pages listed in [See also](#see-also).

### Lines

Everything you can do to a customer line: create, list, read, update, enable, disable, renew, reset the password, delete, and inspect active connections.

| Method | Returns | What it does |
|---|---|---|
| `lines.create(...)` | `Line` | Creates a new line and returns the persisted object. |
| `lines.list(...)` | `PaginatedResponse<Line>` | Lists lines. Filterable by username, password, enabled, `is_trial`. Cursor pagination. |
| `lines.get(id)` | `Line` | Returns one line by numeric ID. |
| `lines.update(id, ...)` | `Line` | Updates fields on an existing line. Only fields you pass are sent. |
| `lines.enable(id)` | `Line` | Enables a disabled line. |
| `lines.disable(id)` | `Line` | Disables an enabled line without deleting it. |
| `lines.renew(id, packageId?)` | `Line` | Extends the expiry, optionally switching package. |
| `lines.resetPassword(id)` / `reset_password(id)` | `string` | Rotates the password and returns the new one as a plain string. Note that this is the one method that does NOT return a `Line`. If you need the full object after, call `lines.get(id)`. |
| `lines.delete(id)` | `bool` | Deletes the line. Returns `true` when the panel confirms the row is gone. |
| `lines.connections(id)` | `list[Connection]` | Returns currently active connections for the line. Plain list, no cursor. |

### Catalog

Read-only endpoints that describe what your panel sells and streams. Packages and bouquets are small enough that they come back as plain lists. Streams and VODs are paginated.

| Method | Returns | What it does |
|---|---|---|
| `catalog.packages()` | `list[Package]` | All packages available to the caller. |
| `catalog.bouquets()` | `list[Bouquet]` | All bouquets. |
| `catalog.streams(...)` | `PaginatedResponse<Stream>` | Lists live streams. Filterable by category and search string. |
| `catalog.stream(id)` | `Stream` | Returns one stream by ID. |
| `catalog.vods(...)` | `PaginatedResponse<Vod>` | Lists VOD items. Filterable by category and search string. |
| `catalog.vod(id)` | `Vod` | Returns one VOD by ID. |

### Resellers

Manage the reseller tree and adjust reseller billing. Only admins can create resellers. A reseller with `subresellers:write` can create sub-resellers under themselves.

| Method | Returns | What it does |
|---|---|---|
| `resellers.list(...)` | `PaginatedResponse<Reseller>` | Lists resellers you can see. Filterable by group, status, username. |
| `resellers.create(...)` | `Reseller` | Creates a reseller (admin) or a sub-reseller (reseller with the right scope). |
| `resellers.get(id)` | `Reseller` | Returns one reseller by ID. |
| `resellers.update(id, fields)` | `Reseller` | Updates the given fields on the reseller. |
| `resellers.billing(id)` | `BillingSnapshot` | Current credit balance, cap, active users, mode. |
| `resellers.adjustBilling(id, delta, reason?)` / `adjust_billing(...)` | `BillingSnapshot` | Adds or removes credits (negative delta deducts). Ideal for wiring your billing system into the panel. |

### Identity

One tiny resource that returns who the current API key belongs to. Useful for smoke tests and for building UIs that display "connected as X".

| Method | Returns | What it does |
|---|---|---|
| `me.get()` | `Identity` | Returns the caller's type, group, scopes, key prefix, and (for resellers) a billing snapshot. |

### Health

Not a resource, just a top-level helper on the client. It calls the public probe with no authentication. Handy for monitoring and for the very first sanity check when a new panel comes online.

| Method | Returns | What it does |
|---|---|---|
| `client.health()` | `dict` | Returns the panel's health payload. No auth required. |

## Pagination

The list endpoints use cursor pagination. Every `list()` call gives you a `PaginatedResponse` with two things: the items on the current page, and an opaque `next_cursor` string that fetches the next page. When there are no more results, the cursor comes back as `null` in PHP and `None` in Python.

The SDK does not loop for you. That is deliberate. Many real integrations do not want the whole set. They stop as soon as they find a match, or after they have collected enough rows to render one screen. You drive the loop, and you stop when it suits your use case.

Always pass the cursor back verbatim. It is opaque to your code, and the panel is free to change what it encodes in future releases.

```php
<?php
require __DIR__ . '/api-panel-php-sdk-1.0.0/autoload.php';

use XtreamAI\PanelApi\PanelApiClient;

$client = new PanelApiClient(
    baseUrl: 'https://<your-panel-domain>',
    token:   '<your-api-key>',
);

$page  = $client->resellers->list(limit: 3);
$total = 0;
$pages = 0;

while (true) {
    $pages++;
    $total += count($page->items);
    echo "Page {$pages}: ", count($page->items), " resellers.", PHP_EOL;
    if ($page->nextCursor === null) {
        break;
    }
    $page = $client->resellers->list(limit: 3, cursor: $page->nextCursor);
}

echo "Total: {$total} resellers across {$pages} pages.", PHP_EOL;
```

```python
from xtream_ai_panel_api import PanelApiClient

client = PanelApiClient(
    base_url="https://<your-panel-domain>",
    token="<your-api-key>",
)

page  = client.resellers.list(limit=3)
total = 0
pages = 0

while True:
    pages += 1
    total += len(page.items)
    print(f"Page {pages}: {len(page.items)} resellers.")
    if page.next_cursor is None:
        break
    page = client.resellers.list(limit=3, cursor=page.next_cursor)

print(f"Total: {total} resellers across {pages} pages.")
```

Running the PHP script against a test panel with a handful of resellers produced:

```
Page 1: got 3 resellers. next=yes
Page 2: got 3 resellers. next=yes
Page 3: got 3 resellers. next=yes
Page 4: got 3 resellers. next=yes
Page 5: got 3 resellers. next=yes
Total collected: 15 resellers over 5 pages
```

The Python version produced the same output. Small `limit` values are fine, and often desirable, because they keep memory bounded and let the caller react to results as they come.

## Idempotency

Every mutating call (create, update, enable, disable, renew, reset, delete, adjust billing) generates a random UUID v4 and sends it as the `Idempotency-Key` header. If the SDK has to retry that call internally because of a 5xx, a 429, or a network blip, it reuses the same key. The panel remembers the result of the first successful attempt and replays it verbatim. You never create two lines from one call.

That default is enough for interactive uses. But if the call is being triggered by an event that can fire more than once (a payment webhook, a job queue with at-least-once delivery, a WHMCS invoice hook), you should override the key with a value derived from a stable business identifier. Then even a retry that comes from outside the SDK, hours later, resolves to a single side effect instead of two.

```php
<?php
require __DIR__ . '/api-panel-php-sdk-1.0.0/autoload.php';

use XtreamAI\PanelApi\PanelApiClient;

$client = new PanelApiClient(
    baseUrl: 'https://<your-panel-domain>',
    token:   '<your-api-key>',
);

$first  = $client->lines->create(
    packageId: 76,
    memberId:  260595,
    username:  'newcustomer',
    idempotencyKey: 'invoice-INV-123',
);
$second = $client->lines->create(
    packageId: 76,
    memberId:  260595,
    username:  'newcustomer',
    idempotencyKey: 'invoice-INV-123',
);

echo "first  id=", $first->id,  PHP_EOL;
echo "second id=", $second->id, PHP_EOL;
echo "same result? ", ($first->id === $second->id ? "YES" : "NO"), PHP_EOL;
```

```python
from xtream_ai_panel_api import PanelApiClient

client = PanelApiClient(
    base_url="https://<your-panel-domain>",
    token="<your-api-key>",
)

first = client.lines.create(
    package_id=76, member_id=260595, username="newcustomer",
    idempotency_key="invoice-INV-123",
)
second = client.lines.create(
    package_id=76, member_id=260595, username="newcustomer",
    idempotency_key="invoice-INV-123",
)

print(f"first  id={first.id}")
print(f"second id={second.id}")
print("same result?", "YES" if first.id == second.id else "NO")
```

Running the PHP version against a test panel produced:

```
first  id=172511984 username=docsdemo_706819
second id=172511984 username=docsdemo_706819
same result? YES
```

The second call did not create a new line. The panel recognised the key from the first call and replayed the exact same response, including the same numeric `id`. If you had used a fresh random UUID for the second call, you would have gotten a second line and a second credit deduction.

For the full server-side contract, including how the panel handles a genuinely conflicting retry (`idempotency_conflict`) and how it resolves a race between two concurrent requests using the same key (`idempotency_in_flight`), see [Rate limits and Idempotency](/docs/?page=panel-api-rate-limits-idempotency).

## Retry policy

The SDK retries transient failures for you. A 5xx response, a network error, and a dropped connection are all considered transient. The SDK waits, then tries again, with exponential backoff between attempts. The default is up to three additional attempts on top of the original request. If none of them succeeds, the underlying exception surfaces to your code.

A 429 response is handled specially. The panel returns the standard `Retry-After` header telling you how many seconds to wait before trying again. The SDK reads that header (capped at 60 seconds so a hostile header cannot stall your process for hours) and sleeps for exactly that long before its next attempt.

A 4xx response other than 429 is never retried. Those errors mean the request itself is wrong (bad body, missing scope, unknown line ID). Retrying without changing the request would just produce the same failure. That is your bug to fix, not a transient condition to wait out.

Both the per-request timeout and the retry count are set once at construction. Sensible defaults are `timeout=30.0` seconds and `maxRetries=3`, but you can raise them for slow networks or lower them for jobs that need to fail fast.

## Exceptions

The SDK's exception hierarchy is designed so you can be as broad or as narrow as you want. Catch the base class `PanelApiException` and you handle every API failure in one place. Catch a specific subclass and you handle exactly one condition (for example "reseller ran out of credits") while letting everything else bubble up.

| Class | HTTP status | What it means |
|---|---|---|
| `PanelApiException` | any | Base class for every SDK error. All others extend it. |
| `BadRequestException` | 400 | The request body is malformed or a required header is missing. |
| `AuthenticationException` | 401 | The API key is invalid, disabled, expired, or the caller IP is not allowed. |
| `AuthorizationException` | 403 | The key is valid but does not have the scope needed for this action. |
| `InsufficientCreditsException` | 402 or 422 | The reseller is out of credits, out of user slots, or their billing has lapsed. |
| `NotFoundException` | 404 | The URL is right but the row does not exist (or is not visible to the caller). |
| `ConflictException` | 409 | A concurrent write already used the same idempotency key with a different payload. |
| `ValidationException` | 422 | The request was well formed but a field failed a business rule (see `.field`). |
| `RateLimitException` | 429 | You exceeded the per-key or per-IP rate limit. Read `.retryAfter` and back off. |
| `ServerException` | 5xx | The panel encountered an internal error. The SDK already retried before raising this. |
| `ServiceUnavailableException` | 503 | The API is disabled or temporarily unavailable at the panel level. |
| `UnknownApiException` | any other | Fallback for status codes the SDK does not recognise. |

Every exception carries the same context so you can log and act on it without any extra plumbing.

- PHP: `$e->slug`, `$e->getMessage()`, `$e->requestId`, `$e->details`, `$e->statusCode`. `ValidationException` adds a `->field()` helper. `RateLimitException` adds `->retryAfter` (seconds).
- Python: `.slug`, `str(e)`, `.request_id`, `.details`, `.status_code`. `ValidationException` adds `.field`. `RateLimitException` adds `.retry_after`.

A typical `try` block groups the ones you have a specific response for, and lets the rest hit the base class handler:

```php
<?php
use XtreamAI\PanelApi\Exceptions\{
    InsufficientCreditsException,
    RateLimitException,
    ValidationException,
    PanelApiException,
};

try {
    $line = $client->lines->create(
        packageId: 42, memberId: 260595, username: 'johndoe',
    );
} catch (InsufficientCreditsException $e) {
    // Reseller is out of credits or user slots. Tell them to top up.
} catch (RateLimitException $e) {
    // Slow down. The panel told us how long to wait.
    sleep($e->retryAfter);
} catch (ValidationException $e) {
    // A field is invalid. $e->field() names the culprit.
} catch (PanelApiException $e) {
    // Anything else. Log $e->requestId and surface a generic error.
}
```

```python
from xtream_ai_panel_api.exceptions import (
    InsufficientCreditsException,
    RateLimitException,
    ValidationException,
    PanelApiException,
)
import time

try:
    line = client.lines.create(
        package_id=42, member_id=260595, username="johndoe",
    )
except InsufficientCreditsException:
    pass  # Reseller ran out. Tell them to top up.
except RateLimitException as e:
    time.sleep(e.retry_after)
except ValidationException as e:
    print("invalid field:", e.field)
except PanelApiException as e:
    print("unexpected api error:", e.request_id, e.slug)
```

> [!IMPORTANT]
> The panel emits `insufficient_slots` with HTTP 422, not 402. Semantically it is the users-billing-mode analog of `insufficient_credits`. The SDK special-cases it and raises `InsufficientCreditsException` in both cases, so a single `catch` handles credits mode and users mode uniformly.

The complete slug catalogue, with a remediation note for each one, lives in [Errors](/docs/?page=panel-api-errors).

## Thread safety

The client is safe to share across threads. Once constructed, its internal configuration (base URL, token, timeout, retry count) is read-only. Each request creates its own cURL handle in PHP and its own connection in Python, so two threads calling the same client instance do not step on each other's transport state. You do not need a lock, and you do not need to build a pool of clients.

Under classic mod_php or PHP-FPM there is nothing to think about here, because each request already runs in its own process. Under a long-lived worker (a queue consumer, a background daemon, a Python service using threads), constructing one client at startup and sharing it across your workers is the recommended pattern.

## Migrating from Xtream Codes or OneStream

These SDKs speak only the native `/panel-api/v1/*` dialect. If your integration was written for the Xtream Codes / XUI.one player API, or for the OneStream ext API, you do not need to adopt these SDKs to point your integration at an Xtream AI panel. The panel speaks both wire formats natively. In most cases you only need to change the base URL and the API key.

- [Coming from XC or OneStream?](/docs/?page=panel-api-compatibility). A friendly overview of what carries over and what does not.
- [Xtream Codes / XUI.one compatibility reference](/docs/?page=panel-api-xtream-codes-compatibility).
- [OneStream compatibility reference](/docs/?page=panel-api-onestream-compatibility).

Reach for the PHP or Python SDK when you are building something new against the native dialect, or when you want the typed models and typed exceptions without writing that plumbing yourself.

## See also

- [Quickstart](/docs/?page=panel-api-quickstart). Your first line, end to end, with either raw `curl` or the SDK.
- [Common tasks](/docs/?page=xai-ref-lines-list). Recipes for the operations you will run most often.
- [Authentication](/docs/?page=panel-api-authentication). Token shape, scopes, and IP allow-lists.
