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
statusfield 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_idinstead ofpackage, orbouquetsinstead ofbouquets_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.phpand/reseller/index.phpaccept 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.phpand/enigma/index.phpare accepted at the routing level, but every MAG/Enigma action returnsSTATUS_FAILUREwithnot_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
startvalue 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:
ridis optional. If you omit it, each request runs independently (no idempotency guarantee). This preserves classic behavior for integrations that never asked for it.ridis scoped per API key. Two different keys can use the sameridwithout conflicting.- Same
rid, same body: original response replayed. - Same
rid, different body:STATUS_FAILUREwitherror: "idempotency_conflict". This is a signal that your retry logic sent a mutated request under a key that should have been unique. - The
ridwindow 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_magscreate_mag,edit_mag,delete_magenable_mag,disable_magban_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
-
Point your client at the new base URL.
/admin/index.phpif your integration uses admin credentials./reseller/index.phpif 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 likepanel_api.
-
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
*:readscopes you actually need.
- Billing / provisioning bots:
- 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.
-
Replace the old
api_key=<old-secret>with the new token.- Same query parameter name, same URL position. Nothing else changes.
-
Run your test suite against the new base URL.
- Everything except
mysql_queryand 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
statusfield. - Any hidden dependencies on
edit_linewithbouquets_selected[]ornoteswill also surface: those are unsupported by the native update path; see Gotchas.
- Everything except
-
Optional: add
ridto your write requests.- Assign a unique-per-operation identifier and pass it as
rid=.... This makes retries after network timeouts safe. Highly recommended forcreate_line,adjust_credits, anddelete_line.
- Assign a unique-per-operation identifier and pass it as
-
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.
- As you touch each call, consider replacing it with the equivalent
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_cursoron every list response); the XC dialect'sstartoffset 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-levelerrorslug and, where applicable, adetailsobject. The XC dialect always returns HTTP 200 withstatus: STATUS_...and stringly-typed error mapping. - You need proper idempotency semantics. The native dialect uses the
Idempotency-KeyHTTP header, requires it for every write, and returns explicitidempotency_in_flight/idempotency_conflictslugs. The XC dialect exposes the same underlying mechanism viarid, 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=dorapplication/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/*withX-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
- Panel API Overview — architecture, three dialects, base URLs.
- Panel API Authentication — token shape, scopes, IP allow-lists, key rotation.
- Panel API Rate limits & Idempotency — per-key and per-IP budgets, safe-retry contract.
- Panel API Lines — the native
/linesresource, field-by-field. - Panel API Catalog — the native read-only endpoints for packages, bouquets, streams, VODs.
- Panel API Resellers — the native reseller and sub-reseller endpoints.
- Panel API Errors — full slug table.
- OneStream compatibility — the other compatibility dialect.
- Common Tasks — copy-pasteable flows for user provisioning, subscription renewal, and billing reconciliation.