Create a line
If you are starting a new integration instead of migrating an existing one, prefer the native v1 API with the official SDKs. The XC dialect keeps compatibility with legacy tooling; the native dialect gives you typed models, header-based idempotency, and structured HTTP status codes.
action=create_line provisions a new subscriber line under a package and returns the full Line object. A line is a username plus password pair that end customers plug into an IPTV app. Every line belongs to an owner (an admin, or a reseller) and inherits its duration, connection cap, default bouquets, and trial flag from the package you pick.
The compatibility layer maps the classic XC parameter names to the native handler's fields (package -> package_id, bouquets_selected[] -> bouquets, trial -> is_trial, reseller_notes/admin_notes -> notes), then delegates to POST /panel-api/v1/lines. Billing, quota checks, credit deduction and refund on failure all happen inside the native handler, unchanged.
Autogenerated username and password are returned in clear in the response, so a bot can hand them to the customer directly.
Endpoint
POST https://<your-panel-domain>/panel-api/xc/{accesscode}/admin/index.php?action=create_line
Both /admin/index.php and /reseller/index.php are accepted. The admin-versus-reseller decision comes from the key, not the path.
GET is also accepted for backward compatibility with classic XUI.one integrations, but write actions over GET leave api_key= in browser history, referrer headers, and any intermediary access log. Use POST for writes.
Authentication
Any one of these three forms:
?api_key=<your-api-key>in the query string.api_key=<your-api-key>in the POST body form field.Authorization: Bearer <your-api-key>HTTP header.
See Authentication.
Required scope
lines:write.
Idempotency
Optional in the XC dialect (the classic panels had none). Pass rid=<unique-per-operation> in the query string or in the POST body to opt in.
- Same
rid, same body: the original response is replayed and nothing runs twice. - Same
rid, different body:STATUS_FAILUREwitherror: "idempotency_conflict". - Windows for 24 hours.
- Values up to 255 characters. Use a UUID, or a business id.
- If you omit
rid, the compat layer synthesizes an internal single-shot key per request. The request still goes through the same pipeline (audit, credit deduction with refund on failure), but a blind retry after a network timeout is no longer idempotent, and you may create the line twice.
Request body
The XC dialect posts form-encoded (application/x-www-form-urlencoded), and every value arrives as a string. See Boolean coercion for the exact rules on strings like "true", "false", "0", "1".
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
package |
int | yes | Package the line inherits from. Must exist. Aliased to package_id internally; you may send package_id directly instead. |
|
member_id |
int | admin only, required | Owner reseller's reg_user_id. Admin keys must supply this; the line will belong to that reseller. Reseller keys must NOT send this; the field is admin-only and returns STATUS_NO_PERMISSIONS (admin_only_field). |
|
username |
string | no | autogenerated u_<8hex> (or trial_<8hex>) |
Must be unique panel-wide. |
password |
string | no | autogenerated (8 hex chars) | Reseller keys whose member group has allow_change_pass=0 cannot set this and receive STATUS_NO_PERMISSIONS with password_change_not_allowed. |
bouquets_selected[] |
int[] | no | package default | Aliased to bouquets[]. On reseller keys every id must be visible to the reseller's group. |
trial |
bool | no | package default | Aliased to is_trial. Only valid on a trial package; sending true on a non-trial package returns STATUS_INVALID_DATA with trial_flag_requires_trial_package. |
reseller_notes |
string | no | Aliased to notes and stored on the contact-info row. |
|
admin_notes |
string | no | Same field as reseller_notes. Whichever you send lands in the same slot. |
|
exp_date |
int (UTC epoch) | admin only | package duration | Must be in the future and within 5 years from now. |
max_connections |
int | admin only | package default | Clamped to [1, 100]. |
is_restreamer |
bool | admin only | package default | |
allowed_ips[] |
string[] | admin only | [] |
IPv4 allow-list, up to 50 entries. Invalid entries drop silently. |
is_isplock |
bool | admin only | false | Requires the reseller group to have edit_isplock=1 when set by a reseller (returns STATUS_NO_PERMISSIONS otherwise). |
rid |
string | no | Idempotency identifier. See the section above. |
Admin-only fields sent by a reseller key trigger STATUS_NO_PERMISSIONS with error: "admin_only_field" and data.details.fields listing the offending names.
Response
data is the full Line object. Same shape used by action=get_line.
{
"status": "STATUS_SUCCESS",
"data": {
"id": 172511994,
"username": "u_a1b2c3d4",
"password": "4102fec6",
"member_id": 100,
"exp_date": 1817743138,
"max_connections": 1,
"is_trial": false,
"is_restreamer": false,
"enabled": true,
"admin_enabled": true,
"bouquets": [2, 4],
"created_at": 1786207138
}
}
HTTP status is always 200, even on failure. Branch on status in the envelope.
Examples
cURL
curl -X POST "https://<your-panel-domain>/panel-api/xc/panel_api/admin/index.php?api_key=<your-api-key>&action=create_line" \
-d "package=42" \
-d "member_id=100" \
-d "username=johndoe" \
-d "password=SuperSecret1" \
-d "bouquets_selected[]=1" -d "bouquets_selected[]=4" -d "bouquets_selected[]=9" \
-d "trial=false" \
-d "rid=create-johndoe-2026-01-15"
PHP (raw HTTP)
$url = 'https://<your-panel-domain>/panel-api/xc/panel_api/admin/index.php'
. '?' . http_build_query(['api_key' => '<your-api-key>', 'action' => 'create_line']);
$body = http_build_query([
'package' => 42,
'member_id' => 100,
'username' => 'johndoe',
'password' => 'SuperSecret1',
'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,
]);
$resp = json_decode(curl_exec($ch), true);
curl_close($ch);
if (($resp['status'] ?? '') !== 'STATUS_SUCCESS') {
throw new RuntimeException($resp['data']['message'] ?? 'create_line failed');
}
$lineId = $resp['data']['id'];
Python (raw HTTP)
import requests, secrets
r = requests.post(
"https://<your-panel-domain>/panel-api/xc/panel_api/admin/index.php",
params={"api_key": "<your-api-key>", "action": "create_line"},
data={
"package": 42,
"member_id": 100,
"username": "johndoe",
"password": "SuperSecret1",
"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.get("status") != "STATUS_SUCCESS":
raise RuntimeError(body["data"].get("message", "create_line failed"))
line_id = body["data"]["id"]
Errors
Response is always HTTP 200. Branch on status, then data.error.
| status | Error slug | When it happens | How to fix |
|---|---|---|---|
STATUS_INVALID_DATA |
validation_error |
Missing or malformed field: package not sent (data.details.field is package_id); admin key without member_id; unknown member_id; unknown package; username collision (Username already exist); bouquet id not accessible; exp_date outside the 5-year window. |
Read data.details.field (or data.details.invalid_ids for bouquets) and fix the input. |
STATUS_INVALID_DATA |
trial_flag_requires_trial_package |
trial=true was sent for a non-trial package. |
Remove trial, or pick a trial package. |
STATUS_NO_PERMISSIONS |
admin_only_field |
A reseller key sent one of member_id, exp_date, max_connections, is_restreamer, allowed_ips, allowed_ua, or is_isplock. data.details.fields lists which ones tripped the guard. |
Remove those fields on reseller keys, or issue an admin key for this integration. |
STATUS_NO_PERMISSIONS |
insufficient_scope |
The key does not have lines:write. |
Grant the scope, or issue a new key. |
STATUS_NO_PERMISSIONS |
password_change_not_allowed |
Reseller group has allow_change_pass=0 and the request set password. |
Omit password; the panel will autogenerate one. |
STATUS_NO_PERMISSIONS |
isplock_not_allowed |
Reseller group has edit_isplock=0 and the request set is_isplock=true. |
Omit is_isplock. |
STATUS_INVALID_PACKAGE |
package_not_accessible |
The reseller cannot sell from that package. | Use a package inside the reseller's member group. |
STATUS_FAILURE |
billing_expired |
Reseller subscription has expired. | Extend the reseller subscription before creating lines. |
STATUS_INSUFFICIENT_CREDITS |
insufficient_credits |
Reseller in credits mode lacks credits for the package cost. |
Top up credits, or pick a cheaper package. |
STATUS_INSUFFICIENT_CREDITS |
slot_limit_exceeded |
Reseller in users mode has reached its slot cap. |
Delete unused lines, or raise the cap. |
STATUS_INSUFFICIENT_CREDITS |
trial_quota_exceeded |
Reseller hit the trial-creation cap for the current window (per group config). | Wait for the window to roll, or raise the group cap. |
STATUS_FAILURE |
idempotency_conflict |
The same rid was reused with a different body. |
Pick a new rid, or send the original body. |
STATUS_FAILURE |
idempotency_in_flight |
The same rid is still processing on another request. |
Retry after a moment. |
STATUS_FAILURE |
invalid_key |
The token is missing, unknown, disabled, expired, or the source IP is not in the key's allow-list. | Verify the token and the IP allow-list. |
STATUS_FAILURE |
rate_limited |
Per-minute cap or per-IP cap exceeded. | Back off. |
STATUS_FAILURE |
api_disabled |
An admin has switched the Panel API off for this panel. | Contact the panel admin. |