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 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, with the reason.

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, Panel API Catalog, and Panel API Resellers. 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 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).

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:

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:

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

{
  "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 catalog) to build better handling.

Correct envelope parsing

$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'];
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. 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:

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

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

curl "$BASE?api_key=$TOKEN&action=user_info"
$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'
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

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"
$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'], ...
}
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

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"
$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'];
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

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"
$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);
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

curl -X POST "$BASE?api_key=$TOKEN&action=extend_line" \
     -d "id=12345" \
     -d "package=5" \
     -d "rid=renew-12345-2026-01-15"
$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);
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

curl -X POST "$BASE?api_key=$TOKEN&action=delete_line" \
     -d "id=12345" \
     -d "rid=delete-12345-2026-01-15"
$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
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)

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

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.

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

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

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.

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