---
title: "Xtream Codes / XUI.one / OTT Panel Compatibility"
description: "Point your existing Xtream Codes, XUI.one, or OTT Panel integration at an Xtream AI panel by changing only the base URL. Full mapping of every action, request field, and response envelope."
---

## The promise

If you already have working Xtream Codes, XUI.one, Xtream-Masters, or OTT Panel billing code, migrating to Xtream AI is a **base URL change** plus a **key swap**. Nothing else changes.

- The `?api_key=<token>&action=<name>` query contract is preserved exactly.
- The `{"status": "STATUS_...", "data": {...}}` response envelope is preserved exactly.
- HTTP status is **always 200** for this dialect. The `status` field inside the body is the signal your code has to branch on, matching the classic panel contract.
- Request and response field names are preserved. Where the underlying native API uses a different name (for example, `package_id` instead of `package`, or `bouquets` instead of `bouquets_selected[]`), the compatibility layer translates transparently, in both directions.
- Every action listed in [The full action mapping table](#the-full-action-mapping-table) below is supported today. Actions that we do not implement on purpose (`mysql_query`, MAG/Enigma device management) are called out in [What is deliberately not supported](#what-is-deliberately-not-supported), with the reason.

> [!IMPORTANT]
> This page documents the XC / XUI.one / OTT Panel compatibility dialect. If you are starting a project from scratch, use the native JSON dialect described in [Panel API Lines](/docs/?page=xai-ref-lines-list), [Panel API Catalog](/docs/?page=xai-ref-overview), and [Panel API Resellers](/docs/?page=xai-ref-resellers-list). The native dialect has cursor-based pagination, HTTP status codes, structured errors, and header-based idempotency.

## Base URL and authentication

### Old base URL (classic panels)

```
https://your-panel.example.com/panel_api/admin/index.php?api_key=<token>&action=<name>
https://your-panel.example.com/panel_api/reseller/index.php?api_key=<token>&action=<name>
```

### New base URL (Xtream AI)

```
https://<your-xtream-ai-panel-domain>/panel-api/xc/panel_api/admin/index.php?api_key=<token>&action=<name>
https://<your-xtream-ai-panel-domain>/panel-api/xc/panel_api/reseller/index.php?api_key=<token>&action=<name>
```

Notes:

- The `{accesscode}` segment (here, `panel_api`) is decorative. Any non-empty value works. The classic panels used that segment as a "secret access code" mixed with the API key. Xtream AI enforces security purely through the API key, not the path, so you can leave whatever value your current integration hardcodes.
- Both `/admin/index.php` and `/reseller/index.php` accept the same key type. The API infers admin-versus-reseller from the key itself (see [Panel API Authentication](/docs/?page=panel-api-authentication) for how issued keys are tagged). Point your integration at whichever path your existing code already uses.
- `/mag/index.php` and `/enigma/index.php` are accepted at the routing level, but every MAG/Enigma action returns `STATUS_FAILURE` with `not_implemented` (see [What is deliberately not supported](#what-is-deliberately-not-supported)).

### Alternative authentication: Bearer header

The compat dialect also accepts `Authorization: Bearer <token>` as an alternative to `?api_key=`, in case you want to move keys out of URLs (for logs hygiene, referrer safety, or centralized secret storage) without rewriting every action call:

```bash
curl -H "Authorization: Bearer $TOKEN" \
     "https://<your-xtream-ai-panel-domain>/panel-api/xc/panel_api/admin/index.php?action=user_info"
```

If both `?api_key=` and `Authorization: Bearer` are present, they must match; conflicting values are rejected. You can also send the key inside a POST body (`api_key=<token>` as a form field) if that fits your transport better.

## The response envelope

Every response in the XC dialect has this exact shape:

```json
{
  "status": "STATUS_SUCCESS",
  "data": { "...": "..." }
}
```

HTTP status is always **200**, even for authentication failures, missing parameters, forbidden actions, or upstream errors. The classic panels are designed this way, and every widely deployed XC client (WHMCS modules, Blesta plugins, PHP SDKs, custom bots) branches on the `status` field, not on the HTTP code. We keep that contract.

### Possible `status` values

| Value | Meaning |
|---|---|
| `STATUS_SUCCESS` | The action ran and `data` holds the result payload. |
| `STATUS_INVALID_DATA` | A required parameter is missing, a value is malformed, or the underlying `validation_error` fired. |
| `STATUS_NO_PERMISSIONS` | The API key lacks the scope, or a reseller key tried to set an admin-only field, or ownership rules rejected the request. |
| `STATUS_INSUFFICIENT_CREDITS` | Reseller does not have enough credits (`credits` mode) or slots (`users` mode) to complete the operation. |
| `STATUS_INVALID_PACKAGE` | Package is not accessible for the caller, or a trial package was used in `extend_line`. |
| `STATUS_FAILURE` | Anything else, including unknown key, rate limit, upstream server error, idempotency conflict, unsupported action, and MAG/Enigma actions. |

On failure, `data` contains structured details you can log:

```json
{
  "status": "STATUS_FAILURE",
  "data": {
    "error": "not_found",
    "message": "Line not found"
  }
}
```

Classic XC clients ignore the `data` object on failure and simply switch on `status`; modern integrations can read the `error` slug (matching the [Panel API Errors](/docs/?page=panel-api-errors) catalog) to build better handling.

### Correct envelope parsing

```php
$body = json_decode(curl_exec($ch), true);
if (!is_array($body) || !isset($body['status'])) {
    throw new RuntimeException('Malformed response');
}
if ($body['status'] !== 'STATUS_SUCCESS') {
    $slug = $body['data']['error'] ?? 'unknown';
    $msg  = $body['data']['message'] ?? '';
    // Handle by $body['status'] and $slug
    throw new RuntimeException("Panel error: {$body['status']} / {$slug} / {$msg}");
}
$data = $body['data'];
```

```python
resp = requests.get(url, params=params, timeout=30)
resp.raise_for_status()  # will fire only on real HTTP errors (network, 5xx from nginx)
body = resp.json()
if body.get("status") != "STATUS_SUCCESS":
    err = (body.get("data") or {}).get("error", "unknown")
    msg = (body.get("data") or {}).get("message", "")
    raise RuntimeError(f"Panel error: {body.get('status')} / {err} / {msg}")
data = body["data"]
```

## The full action mapping table

Every action supported by the XC dialect. Each row lists the underlying native endpoint the compat layer translates to; if you ever want to migrate a specific call to the native v1 dialect, that column tells you where to go.

### Info and read-only

| Action | Method | Required params | Optional params | Response `data` | Maps to |
|---|---|---|---|---|---|
| `user_info` | GET | none | none | Identity of the key: admin gets `type`, `key`; reseller also gets `reg_user_id`, `member_group_id`, `member_group_name`, `billing`, `permissions`. Full shape in [Authentication](/docs/?page=panel-api-authentication#the-me-endpoint). | `GET /panel-api/v1/me` |
| `packages` (alias `get_packages`) | GET | none | none | `{"items": [{"id","package_name","is_trial","is_official","official_credits","official_duration","official_duration_in","trial_credits","trial_duration","trial_duration_in","max_connections","is_restreamer","forced_country"}]}` | `GET /panel-api/v1/packages` |
| `get_bouquets` | GET | none | none | `{"items": [{"id","name","order"}]}` | `GET /panel-api/v1/bouquets` |
| `get_streams` | GET | none | `limit` (1-100), `start` (offset, mapped best-effort to `cursor`) | `{"items": [{"id","name","icon","categories":[{"id","name"}]}],"next_cursor": int or null}` | `GET /panel-api/v1/streams` |
| `get_stream` | GET | `id` | none | Same shape as one item of `get_streams`. | `GET /panel-api/v1/streams/{id}` |
| `get_movies` | GET | none | `limit`, `start` | `{"items":[{"id","name","icon","year","rating","is_serie","categories":[…]}],"next_cursor": int or null}` | `GET /panel-api/v1/vods` |
| `get_movie` | GET | `id` | none | Same shape as one item of `get_movies`. | `GET /panel-api/v1/vods/{id}` |

### Lines (read)

| Action | Method | Required params | Optional params | Response `data` | Maps to |
|---|---|---|---|---|---|
| `get_lines` | GET | none | `limit`, `start` (offset), `username` (exact match), `search[value]` (DataTables-style, mapped to `username` filter, best-effort) | `{"items":[<Line>],"next_cursor": int or null}` | `GET /panel-api/v1/lines` |
| `get_line` | GET | `id` | none | The `Line` object (see below). | `GET /panel-api/v1/lines/{id}` |

The `Line` object shape:

```json
{
  "id": 12345,
  "username": "u_ab12cd34",
  "password": "9f3c1a77",
  "member_id": 42,
  "exp_date": 1793520000,
  "max_connections": 2,
  "is_trial": false,
  "is_restreamer": false,
  "enabled": true,
  "admin_enabled": true,
  "bouquets": [1, 4, 9],
  "created_at": 1785984000
}
```

### Lines (write)

| Action | Method | Required params | Optional params | Response `data` | Maps to |
|---|---|---|---|---|---|
| `create_line` | POST | `package` (aliased to `package_id`) | `username`, `password`, `bouquets_selected[]` (aliased to `bouquets`), `trial` (aliased to `is_trial`, boolean), `member_id` (admin only), `max_connections`, `exp_date`, `is_isplock`, `is_restreamer`, `allowed_ips[]`, `reseller_notes`/`admin_notes` (aliased to `notes`) | The created `Line` object. Autogenerated `username` and `password` are returned in clear. | `POST /panel-api/v1/lines` |
| `edit_line` | POST | `id` plus at least one editable field | `password`, `exp_date`, `max_connections` (clamped to `[1,100]`), `is_restreamer` (bool), `enabled` (bool), `admin_enabled` (bool, admin only), `allowed_ips[]`, `allowed_ua[]` | The updated `Line` object, re-read from the database. | `POST /panel-api/v1/lines/{id}/update` |
| `extend_line` | POST | `id`, `package` (aliased to `package_id`) | none | The renewed `Line` object with new `exp_date`, `enabled=true`, `admin_enabled=true`. | `POST /panel-api/v1/lines/{id}/renew` |
| `enable_line` | POST | `id` | none | The `Line` object with `enabled=true`. Reseller keys in `users` billing mode may hit `STATUS_INSUFFICIENT_CREDITS` (slot cap) here on non-trial lines. | `POST /panel-api/v1/lines/{id}/enable` |
| `disable_line` | POST | `id` | none | The `Line` object with `enabled=false`. Never charges credits and never fails on billing. | `POST /panel-api/v1/lines/{id}/disable` |
| `delete_line` | POST | `id` | none | `{"id": int, "username": string, "deleted": true}`. Bouquet assignments and contact-info rows are cleaned up in the same transaction. Deletion does NOT refund credits, matching the classic panels. | `POST /panel-api/v1/lines/{id}/delete` |

### Resellers (sub-resellers)

Admin keys manage every reseller. Reseller keys can only create sub-resellers under themselves, and only if the panel admin has granted `create_sub_resellers` on the reseller's member group.

| Action | Method | Required params | Optional params | Response `data` | Maps to |
|---|---|---|---|---|---|
| `get_users` | GET | none | `limit`, `start`, `member_group_id`, `status` | `{"items":[{"id","username","email","member_group_id","member_group_name","status","billing_mode","credits","max_users","active_users","billing_expires","created_at"}],"next_cursor": int or null}`. Admin key only. | `GET /panel-api/v1/resellers` |
| `get_user` | GET | `id` | none | `{"id","username","email","member_group_id","member_group_name","status","billing":{…}}`. Admin key only. | `GET /panel-api/v1/resellers/{id}` |
| `create_user` | POST | `username`, `password`, `email`, `member_group_id`; if reseller in `users` mode also `max_users` and `billing_expires` | `notes`, `credits` (admin only), `billing_mode` (admin only; `credits` or `users`; reseller inherits parent mode), `max_users` (admin only in `users` mode), `billing_expires` (admin only in `users` mode), `owner_id` (admin only; reseller keys force it to themselves) | `{"id","username","email","member_group_id","owner_id","credits_charged","billing":{…}}`. | `POST /panel-api/v1/resellers` |
| `edit_user` | POST | `id` | `username`, `email`, `member_group_id`, `password` (empty preserves current), `notes` | `{"id","username","email","member_group_id","billing":{…}}`. Admin key only. | `POST /panel-api/v1/resellers/{id}/update` |
| `adjust_credits` | POST | `id`, `credits` (aliased to `delta`, positive or negative float) | `note` (aliased to `reason`, truncated to 100 characters in the audit log) | The reseller's billing shape after the adjustment: `{"mode","credits","max_users","active_users","billing_expires"}`. Admin key only. | `POST /panel-api/v1/resellers/{id}/billing/adjust` |

### Field translation summary

The compat layer does these key-name and value translations transparently for you. Send what your classic-panel code sends; the payload the underlying handler receives is the right one.

| XC field name | Native v1 field name | Notes |
|---|---|---|
| `package` (in `create_line`, `extend_line`) | `package_id` | You can send `package_id` directly too; both accepted. |
| `bouquets_selected[]` | `bouquets[]` | Array of integer bouquet ids. |
| `trial` | `is_trial` | Boolean (see [Boolean coercion](#boolean-coercion) below). |
| `reseller_notes`, `admin_notes` | `notes` | Whichever you send lands in the same `notes` field. |
| `credits` (in `adjust_credits`) | `delta` | Positive to credit, negative to debit. |
| `note` (in `adjust_credits`) | `reason` | |

### Boolean coercion

XC integrations POST form-encoded, so every value arrives as a string. PHP's default `(bool) "false"` is `true`, which historically turned `enabled=false` into `enabled=1` (silently leaving a line live that the integrator asked to suspend, and returning `STATUS_SUCCESS`). We coerce carefully:

| String value | Interpreted as |
|---|---|
| `"true"`, `"1"`, `"yes"`, `"on"`, or any other non-empty non-listed value | `true` |
| `"false"`, `"0"`, `"no"`, `"off"`, `""` | `false` |

Comparison is case-insensitive and trimmed. `"FALSE"`, `"False"`, `" no "` all resolve to `false`.

### Numeric strictness

`adjust_credits` rejects malformed numeric values loudly. `(float) "1,000"` in PHP is `1.0`, so a top-up of 1000 credits used to be silently accredited as 1 with `STATUS_SUCCESS`, and the reseller portal thought the transaction was settled. The compat layer now returns `STATUS_INVALID_DATA` for any `credits` value that fails `is_numeric()`, including thousands separators (`"1,000"`), unit suffixes (`"12abc"`), or plain non-numeric text.

Valid: `"1000"`, `"10.5"`, `"-7"`, `1000`, `10.5`.
Invalid: `"1,000"`, `"10,50"`, `"abc"`, `"12abc"`.

### Pagination

XC uses `start` (offset) plus `limit`; the native v1 dialect uses a `cursor` (keyset on the last returned id). The compat layer maps `start > 0` to `cursor = start` as a best-effort bridge:

- If your ids are dense and monotonic (typical for a healthy panel), pagination works as expected.
- If your ids are sparse (rows deleted historically), a given `start` value may skip fewer or more rows than you would expect from a pure offset.
- If you paginate the whole list with `start = 0, start = limit, start = 2*limit, ...`, you may miss or repeat rows.

Recommendation: when you need reliable pagination over a large resource, migrate that call to the native `/panel-api/v1/*` dialect with `next_cursor`.

## Snippets for the top six actions

Every snippet uses these placeholders. Replace them with your panel domain and API key.

```bash
PANEL="https://<your-xtream-ai-panel-domain>"
TOKEN="pk_live_x2fakekey423.Fk3xAmpLeSecretNotARealKeyDoNotUse_9x2fake9"
BASE="$PANEL/panel-api/xc/panel_api/admin/index.php"
```

### user_info

```bash
curl "$BASE?api_key=$TOKEN&action=user_info"
```

```php
$ch = curl_init(
    'https://<your-xtream-ai-panel-domain>/panel-api/xc/panel_api/admin/index.php'
    . '?api_key=' . urlencode($token) . '&action=user_info'
);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// $body['status'] === 'STATUS_SUCCESS'
// $body['data']['type']  ==> 'admin' or 'reseller'
```

```python
import requests

r = requests.get(
    "https://<your-xtream-ai-panel-domain>/panel-api/xc/panel_api/admin/index.php",
    params={"api_key": token, "action": "user_info"},
    timeout=30,
)
r.raise_for_status()
body = r.json()
assert body["status"] == "STATUS_SUCCESS"
identity = body["data"]  # {'type': 'admin'|'reseller', 'key': {...}, ...}
```

### get_lines

```bash
curl "$BASE?api_key=$TOKEN&action=get_lines&limit=50"
curl "$BASE?api_key=$TOKEN&action=get_lines&limit=50&username=alice"
curl "$BASE?api_key=$TOKEN&action=get_lines&search%5Bvalue%5D=alice"
```

```php
$url = 'https://<your-xtream-ai-panel-domain>/panel-api/xc/panel_api/admin/index.php'
     . '?' . http_build_query([
         'api_key'  => $token,
         'action'   => 'get_lines',
         'limit'    => 50,
         'username' => 'alice',
       ]);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$body = json_decode(curl_exec($ch), true);
curl_close($ch);
foreach ($body['data']['items'] as $line) {
    // $line['id'], $line['username'], $line['exp_date'], ...
}
```

```python
r = requests.get(
    "https://<your-xtream-ai-panel-domain>/panel-api/xc/panel_api/admin/index.php",
    params={"api_key": token, "action": "get_lines", "limit": 50, "username": "alice"},
    timeout=30,
)
r.raise_for_status()
body = r.json()
for line in body["data"]["items"]:
    print(line["id"], line["username"], line["exp_date"])
```

### create_line

```bash
curl -X POST "$BASE?api_key=$TOKEN&action=create_line" \
     -d "package=5" \
     -d "username=johndoe" \
     -d "password=SuperSecret" \
     -d "bouquets_selected[]=1" -d "bouquets_selected[]=4" -d "bouquets_selected[]=9" \
     -d "trial=false" \
     -d "rid=create-johndoe-2026-01-15"
```

```php
$url = 'https://<your-xtream-ai-panel-domain>/panel-api/xc/panel_api/admin/index.php'
     . '?' . http_build_query([
         'api_key' => $token,
         'action'  => 'create_line',
       ]);
$body = http_build_query([
    'package'            => 5,
    'username'           => 'johndoe',
    'password'           => 'SuperSecret',
    'bouquets_selected'  => [1, 4, 9],
    'trial'              => 'false',
    'rid'                => 'create-johndoe-' . bin2hex(random_bytes(8)),
]);
$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => $body,
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
if ($response['status'] !== 'STATUS_SUCCESS') {
    throw new RuntimeException($response['data']['message'] ?? 'create_line failed');
}
$lineId = $response['data']['id'];
```

```python
import requests, secrets

r = requests.post(
    "https://<your-xtream-ai-panel-domain>/panel-api/xc/panel_api/admin/index.php",
    params={"api_key": token, "action": "create_line"},
    data={
        "package":              5,
        "username":             "johndoe",
        "password":             "SuperSecret",
        "bouquets_selected[]":  [1, 4, 9],
        "trial":                "false",
        "rid":                  f"create-johndoe-{secrets.token_hex(8)}",
    },
    timeout=30,
)
r.raise_for_status()
body = r.json()
if body["status"] != "STATUS_SUCCESS":
    raise RuntimeError(body["data"].get("message", "create_line failed"))
line_id = body["data"]["id"]
```

### edit_line

```bash
curl -X POST "$BASE?api_key=$TOKEN&action=edit_line" \
     -d "id=12345" \
     -d "max_connections=3" \
     -d "enabled=true" \
     -d "rid=edit-12345-2026-01-15"
```

```php
$url = 'https://<your-xtream-ai-panel-domain>/panel-api/xc/panel_api/admin/index.php'
     . '?' . http_build_query(['api_key' => $token, 'action' => 'edit_line']);
$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query([
        'id'              => 12345,
        'max_connections' => 3,
        'enabled'         => 'true',
        'rid'             => 'edit-12345-' . bin2hex(random_bytes(8)),
    ]),
]);
$body = json_decode(curl_exec($ch), true);
```

```python
r = requests.post(
    "https://<your-xtream-ai-panel-domain>/panel-api/xc/panel_api/admin/index.php",
    params={"api_key": token, "action": "edit_line"},
    data={
        "id":              12345,
        "max_connections": 3,
        "enabled":         "true",
        "rid":             f"edit-12345-{secrets.token_hex(8)}",
    },
    timeout=30,
)
body = r.json()
```

### extend_line

```bash
curl -X POST "$BASE?api_key=$TOKEN&action=extend_line" \
     -d "id=12345" \
     -d "package=5" \
     -d "rid=renew-12345-2026-01-15"
```

```php
$url = 'https://<your-xtream-ai-panel-domain>/panel-api/xc/panel_api/admin/index.php'
     . '?' . http_build_query(['api_key' => $token, 'action' => 'extend_line']);
$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query([
        'id'      => 12345,
        'package' => 5,
        'rid'     => 'renew-12345-' . bin2hex(random_bytes(8)),
    ]),
]);
$body = json_decode(curl_exec($ch), true);
```

```python
r = requests.post(
    "https://<your-xtream-ai-panel-domain>/panel-api/xc/panel_api/admin/index.php",
    params={"api_key": token, "action": "extend_line"},
    data={"id": 12345, "package": 5, "rid": f"renew-12345-{secrets.token_hex(8)}"},
    timeout=30,
)
body = r.json()
```

### delete_line

```bash
curl -X POST "$BASE?api_key=$TOKEN&action=delete_line" \
     -d "id=12345" \
     -d "rid=delete-12345-2026-01-15"
```

```php
$url = 'https://<your-xtream-ai-panel-domain>/panel-api/xc/panel_api/admin/index.php'
     . '?' . http_build_query(['api_key' => $token, 'action' => 'delete_line']);
$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query([
        'id'  => 12345,
        'rid' => 'delete-12345-' . bin2hex(random_bytes(8)),
    ]),
]);
$body = json_decode(curl_exec($ch), true);
// $body['data']['deleted'] === true on success
```

```python
r = requests.post(
    "https://<your-xtream-ai-panel-domain>/panel-api/xc/panel_api/admin/index.php",
    params={"api_key": token, "action": "delete_line"},
    data={"id": 12345, "rid": f"delete-12345-{secrets.token_hex(8)}"},
    timeout=30,
)
body = r.json()
assert body["status"] == "STATUS_SUCCESS" and body["data"]["deleted"] is True
```

### Other write actions (curl)

```bash
# enable / disable
curl -X POST "$BASE?api_key=$TOKEN&action=enable_line"  -d "id=12345" -d "rid=en-12345-<uniq>"
curl -X POST "$BASE?api_key=$TOKEN&action=disable_line" -d "id=12345" -d "rid=dis-12345-<uniq>"

# Sub-reseller management (admin key)
curl -X POST "$BASE?api_key=$TOKEN&action=create_user" \
     -d "username=alice-shop" -d "password=SuperSecret" \
     -d "email=alice@example.com" -d "member_group_id=4" \
     -d "billing_mode=credits" -d "credits=100" \
     -d "rid=create-alice-<uniq>"

curl -X POST "$BASE?api_key=$TOKEN&action=edit_user" \
     -d "id=99" -d "email=alice-new@example.com" \
     -d "rid=edit-alice-<uniq>"

# Credit adjustment (positive tops up, negative debits)
curl -X POST "$BASE?api_key=$TOKEN&action=adjust_credits" \
     -d "id=99" -d "credits=50" -d "note=Manual top-up 2026-01-15" \
     -d "rid=topup-99-<uniq>"
```

## Idempotency via `rid`

Classic XC panels have no idempotency layer. If your integration retries a `create_line` after a network timeout, on the classic panels you either get a duplicate line or an obscure error, depending on the flavor of panel and how the failure fell.

Xtream AI adds an idempotency layer on top of the compat dialect. On any **write** action, pass a `rid=<unique-per-operation>` parameter (in the query string or the POST body). If the same request repeats with the same `rid` **and** the same body, you get the original response back and nothing runs twice.

Rules:

- `rid` is optional. If you omit it, each request runs independently (no idempotency guarantee). This preserves classic behavior for integrations that never asked for it.
- `rid` is scoped per API key. Two different keys can use the same `rid` without conflicting.
- Same `rid`, same body: original response replayed.
- Same `rid`, **different** body: `STATUS_FAILURE` with `error: "idempotency_conflict"`. This is a signal that your retry logic sent a mutated request under a key that should have been unique.
- The `rid` window is 24 hours. After that the id is free to reuse.
- Values up to 255 characters. Use a UUID, a Unix timestamp plus a business id, or any deterministic-per-operation string.

Example retry pattern in Python:

```python
import requests, secrets, time

rid = f"create-alice-{secrets.token_hex(16)}"  # stable across retries
for attempt in range(5):
    try:
        r = requests.post(
            "https://<your-xtream-ai-panel-domain>/panel-api/xc/panel_api/admin/index.php",
            params={"api_key": token, "action": "create_line"},
            data={"package": 5, "username": "alice", "rid": rid},
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        if body["status"] == "STATUS_SUCCESS":
            break
        if body["data"].get("error") == "idempotency_conflict":
            # We sent a different body under the same rid. Bail loudly.
            raise RuntimeError("Idempotency conflict: request body changed across retries")
        # STATUS_FAILURE with a transient error? Retry.
        time.sleep(2 ** attempt)
    except requests.RequestException:
        time.sleep(2 ** attempt)
```

If you prefer the header-based idempotency contract, the native v1 dialect exposes it via the `Idempotency-Key` HTTP header. See [Rate limits & Idempotency](/docs/?page=panel-api-rate-limits-idempotency).

## What is deliberately not supported

### `action=mysql_query`

The classic panels expose an `action=mysql_query` that runs arbitrary SQL against the panel database. Xtream AI rejects it with `STATUS_FAILURE` and `error: "forbidden_action"`:

```json
{
  "status": "STATUS_FAILURE",
  "data": {
    "error": "forbidden_action",
    "message": "Arbitrary SQL execution is intentionally not exposed by this API."
  }
}
```

Arbitrary SQL execution over HTTP is a foot-gun that has caused most of the well-known Xtream Codes / XUI.one incidents. We do not implement it, in any dialect, ever.

If your existing integration depends on `mysql_query` for something that has no first-class equivalent (a report, a bulk fix-up, a niche query), [contact us](https://xtreamai.net/): the prioritization of new first-class endpoints is driven by exactly these gaps.

### MAG device management

Xtream AI does not provision physical MAG devices, so none of the classic MAG actions are implemented:

- `get_mag`, `get_mags`
- `create_mag`, `edit_mag`, `delete_mag`
- `enable_mag`, `disable_mag`
- `ban_mag`, `unban_mag`, `convert_mag`

Each returns `STATUS_FAILURE` with `error: "not_implemented"` and a message pointing you to the Lines API. The equivalent product concept in Xtream AI is a subscriber Line with the appropriate flags set on the client device.

### Enigma device management

Same as MAG. `get_enigma`, `get_enigmas`, `create_enigma`, `edit_enigma`, `delete_enigma`, `enable_enigma`, `disable_enigma`, `ban_enigma`, `unban_enigma`, `convert_enigma` all return `STATUS_FAILURE` with `error: "not_implemented"`.

### Path variations that hardcode device paths

The compat router accepts `/mag/index.php` and `/enigma/index.php` under `/panel-api/xc/...` so integrations that hardcode those paths do not 404 at the network layer. Every action on those paths returns `not_implemented`.

### Global connection listing and global connection kill

The classic panels expose "list all connections across all lines" and "kill all connections" endpoints. Xtream AI intentionally does not. Per-line inspection is available via the native `GET /panel-api/v1/lines/{id}/connections` (see [Panel API Lines](/docs/?page=xai-ref-lines-list)). We do not expose a global kill by design.

### Bulk fields that the underlying line update does not accept

The native `POST /lines/{id}/update` handler supports only a subset of the classic panels' `edit_line` fields: `password`, `exp_date`, `max_connections`, `is_restreamer`, `enabled`, `admin_enabled`, `allowed_ips`, `allowed_ua`. Fields the classic panels supported in `edit_line` but that the native handler does not, such as `bouquets_selected[]`, `username`, `notes`, and `is_trial`, are described in [Gotchas and known differences](#gotchas-and-known-differences) below.

## Gotchas and known differences

### Duplicate `action=` parameter

If your URL assembly ends up sending `?action=X&action=Y` (a common accident with string concatenation), PHP will take the **last** value. This is not a security bypass, since both actions still go through full auth and validation, but it can produce very surprising behavior when the "wrong" action succeeds silently. Build URLs with `http_build_query` (PHP), `URLSearchParams` (JS), or your language's equivalent so this stays a one-of situation.

### GET is accepted for write actions

The compat dialect accepts GET for every write action, matching XUI.one's classic behavior. There is no CSRF vector (no cookies, no session), but the `api_key=` value will appear in browser history, HTTP referrer headers, and any intermediary access logs if a write URL is ever shared. Prefer POST for writes, especially for `adjust_credits`, `create_line`, and `delete_line`. Rotate the key if a write URL leaks into logs or is shared over chat.

### `edit_line` silently drops unsupported fields

The native update handler only writes `password`, `exp_date`, `max_connections`, `is_restreamer`, `enabled`, `admin_enabled`, `allowed_ips`, `allowed_ua`. Everything else is silently discarded.

The compat layer protects you from one specific version of this footgun: an `edit_line` call that provides **only** unsupported fields (for example, only `bouquets_selected[]`) returns `STATUS_INVALID_DATA` with an explicit message listing the supported fields. But an `edit_line` that mixes supported and unsupported fields will apply the supported ones and silently drop the rest with `STATUS_SUCCESS`.

Recommendation: pass **only** the fields you know are supported. If you need to change bouquets, the native v1 API does not expose that on line update either; the current workaround is deletion plus recreation.

### `edit_user` accepts `notes` but does not echo it back

The `notes` field is accepted and persisted, but the response body does not include it. If your integration reads back `notes` from the update response to reconcile state, fetch the reseller separately with `get_user` after the update.

### `extend_line` accepts `package` only, not classic `months`/`days`/`exp_date`

Some classic panels let you renew by sending `months=N`, `days=N`, or a computed `exp_date`. The compat dialect maps `extend_line` to the native renew endpoint, which extends by the official duration attached to the specified `package_id`. If your integration currently computes the new expiry itself and sends `exp_date`, you will need to create the equivalent package in your Xtream AI panel and send its id instead. This keeps renewal duration and cost in one place (the package definition) rather than scattered across billing logic.

Trial packages cannot be used in `extend_line`; you get `STATUS_INVALID_PACKAGE` with `error: "renew_with_trial_package_not_allowed"`.

### `get_lines` with `search[value]` filters by exact `username`

Classic DataTables-style search is best-effort: `search[value]=<term>` is mapped to an exact `username=<term>` filter. If your integration searched by other columns (email, package name, connection state), the compat layer will not find matches for those. Migrate those calls to the native `GET /panel-api/v1/lines` with `username=` and other filters.

### `start` pagination is best-effort

See [Pagination](#pagination) above. If exact pagination matters, use the native dialect and consume `next_cursor`.

### HTTP is always 200

Reminder: the classic contract is HTTP 200 for every response, with the `status` field carrying the actual outcome. Your client must branch on `body.status`, not on the HTTP status. `raise_for_status()` (Python), `throwFor(response.status)` (JS), or an equivalent HTTP-status guard will only fire on real network errors and 5xx from nginx.

If your code today gates on HTTP status code, migrate that logic to the `status` field before switching base URLs.

### Rate limits and per-key limits

Each API key carries its own `rate_limit_per_min` (default 60). When exceeded, the response is `STATUS_FAILURE` with `error: "rate_limited"` (HTTP still 200). See [Rate limits & Idempotency](/docs/?page=panel-api-rate-limits-idempotency) for the exact contract.

### Admin-only fields on reseller keys

If a reseller key sends any of `exp_date`, `max_connections`, `is_restreamer`, `is_isplock`, `allowed_ips`, `allowed_ua`, or `member_id` in a `create_line` call, the response is `STATUS_NO_PERMISSIONS` with `error: "admin_only_field"` and `data.details.fields` listing which fields tripped the guard. Adjust your integration to omit those fields on reseller keys, or issue an admin key for that specific integration path.

### `create_user` on reseller keys inherits the parent billing mode

If your integration passes `billing_mode` in `create_user` on a reseller key, it is ignored. The child sub-reseller always inherits the parent's `billing_mode`. If the parent is in `users` mode, `max_users` and `billing_expires` are required on the child, or you get `STATUS_INVALID_DATA` with `billing_expires_required`.

### `create_user` and `edit_user` username / password character restrictions

`username` must be at least 3 characters. `password` must be at least 8 characters. Neither may contain `% & ? # / \ = + @ : ;`. Violations return `STATUS_INVALID_DATA`.

### `owner_id` on `create_user` for reseller keys

A reseller key that sends `owner_id` to a value other than itself gets `STATUS_NO_PERMISSIONS` with `error: "owner_must_be_self"`. This is an anti-sabotage guard: without it, a reseller could inflate a peer's slot usage by creating sub-resellers under them. Reseller keys should either omit `owner_id` or send their own `reg_user_id`.

## Migration checklist

1. **Point your client at the new base URL.**
   - `/admin/index.php` if your integration uses admin credentials.
   - `/reseller/index.php` if it uses reseller credentials.
   - Any `{accesscode}` segment your code hardcodes still works. If your code parses it out and expects to configure it, you can leave it as-is or set it to something descriptive like `panel_api`.

2. **Issue an API key from your Xtream AI panel.**
   - Log in as admin (or as a reseller if your admin has enabled reseller-issued keys).
   - Go to **Settings → Panel API Keys → Create key**.
   - Pick the scopes matching your integration:
     - Billing / provisioning bots: `lines:read`, `lines:write`, `packages:read`, `bouquets:read`.
     - Admin integrations that manage resellers too: add `resellers:read`, `resellers:write`.
     - Reseller integrations that create sub-resellers: add `subresellers:write`.
     - Read-only reporting: only the `*:read` scopes you actually need.
   - Optional: add an IP allow-list and a per-minute rate limit for defense in depth. See [Authentication](/docs/?page=panel-api-authentication) for details.
   - Copy the token immediately. The secret is shown only once.

3. **Replace the old `api_key=<old-secret>` with the new token.**
   - Same query parameter name, same URL position. Nothing else changes.

4. **Run your test suite** against the new base URL.
   - Everything except `mysql_query` and MAG/Enigma should work identically.
   - Any hidden dependencies on the classic panel's HTTP status codes will surface here: switch that logic to gate on the `status` field.
   - Any hidden dependencies on `edit_line` with `bouquets_selected[]` or `notes` will also surface: those are unsupported by the native update path; see [Gotchas](#gotchas-and-known-differences).

5. **Optional: add `rid` to your write requests.**
   - Assign a unique-per-operation identifier and pass it as `rid=...`. This makes retries after network timeouts safe. Highly recommended for `create_line`, `adjust_credits`, and `delete_line`.

6. **Optional: migrate slowly to the native v1 dialect.**
   - As you touch each call, consider replacing it with the equivalent `/panel-api/v1/*` request. See [When to graduate to native v1](#when-to-graduate-to-native-v1).

## When to graduate to native v1

The XC compat dialect is designed to unblock migration, not to be the recommended long-term dialect. Consider moving specific calls to native v1 when:

- **You need reliable pagination** for a resource with more than a few hundred items. The native dialect uses keyset cursors (`next_cursor` on every list response); the XC dialect's `start` offset is best-effort.
- **You want structured error handling.** The native dialect uses HTTP status codes (`400`, `401`, `402`, `403`, `404`, `409`, `422`, `429`, `500`, `503`) plus a top-level `error` slug and, where applicable, a `details` object. The XC dialect always returns HTTP 200 with `status: STATUS_...` and stringly-typed error mapping.
- **You need proper idempotency semantics.** The native dialect uses the `Idempotency-Key` HTTP header, requires it for every write, and returns explicit `idempotency_in_flight` / `idempotency_conflict` slugs. The XC dialect exposes the same underlying mechanism via `rid`, but as an optional parameter.
- **You want richer request shapes.** Native accepts JSON bodies, which handle nested structures and arrays cleanly. The XC dialect is form-encoded (`?a=b&c=d` or `application/x-www-form-urlencoded`) which trips on booleans and thousands separators.
- **You are on the OneStream side of the shop.** The OneStream REST dialect (`/ext/*` with `X-Api-Key`, opaque line UUIDs) may fit your existing tooling better. See [OneStream compatibility](/docs/?page=panel-api-onestream-compatibility).

The three dialects share **one and the same API key**. There is no "migrate the key" step; migrate the calls at your own pace, one at a time, mixing dialects during the transition.

## See also

- [Panel API Overview](/docs/?page=panel-api-overview) — architecture, three dialects, base URLs.
- [Panel API Authentication](/docs/?page=panel-api-authentication) — token shape, scopes, IP allow-lists, key rotation.
- [Panel API Rate limits & Idempotency](/docs/?page=panel-api-rate-limits-idempotency) — per-key and per-IP budgets, safe-retry contract.
- [Panel API Lines](/docs/?page=xai-ref-lines-list) — the native `/lines` resource, field-by-field.
- [Panel API Catalog](/docs/?page=xai-ref-overview) — the native read-only endpoints for packages, bouquets, streams, VODs.
- [Panel API Resellers](/docs/?page=xai-ref-resellers-list) — the native reseller and sub-reseller endpoints.
- [Panel API Errors](/docs/?page=panel-api-errors) — full slug table.
- [OneStream compatibility](/docs/?page=panel-api-onestream-compatibility) — the other compatibility dialect.
- [Common Tasks](/docs/?page=panel-api-migration-recipes) — copy-pasteable flows for user provisioning, subscription renewal, and billing reconciliation.
