Using the official SDKs? Every endpoint below is a one-line typed method call — the HTTP details on this page are handled for you.
What a line is
A line is a subscriber account on your panel. It has a username and password that end customers plug into an IPTV app, an expiry date, a set of bouquets (channel groups) it is allowed to see, a maximum number of concurrent connections, and a few flags (is_trial, is_restreamer). Every line belongs to a specific owner: an admin or a reseller. Resellers only ever see, mutate, or bill for their own lines.
This page documents every endpoint under /panel-api/v1/lines/*. Requests need either the lines:read or lines:write scope depending on the operation. See Authentication for how to obtain a token.
Every write operation (create, update, enable, disable, renew, reset password, delete) requires an
Idempotency-Keyheader. See Rate limits & Idempotency for the safe-retry contract.
Every write also leaves an entry in the panel's activity log (Logs in the panel menu), with the same labels the panel buttons use (
Create Line,Enable Line,Disable Line,Extend Line,Edit Line,Delete Line) and the label of the API key that made the call. Reseller keys are listed under the reseller account; admin keys show as "Panel API".
The line lifecycle
A line moves through a small number of well-defined states over its life:
- Create with
POST /lines. You pick the package (which decides duration, credit cost, default bouquets, default connections, and the trial flag) and, if you are an admin, themember_idof the owner reseller. The line is created withenabled=trueandadmin_enabled=true. On a reseller key, credits are deducted atomically before the row is written, and refunded automatically if the create fails. - List and get with
GET /linesandGET /lines/{id}. Reseller keys are auto-scoped to their ownmember_id. - Enable and disable with
POST /lines/{id}/enableandPOST /lines/{id}/disable.disablenever charges.enablere-checks slot capacity on reseller keys inusersbilling mode, so a reseller cannot use disable-then-enable to sneak past the cap. - Renew with
POST /lines/{id}/renewto pushexp_dateforward by the package's official duration and apply the package's connections (a trial line becomes an official one). Increditsmode this deductsofficial_credits; inusersmode renewals are free. - Reset the password at any time with
POST /lines/{id}/reset-password(returns the new password in the response). - Update with
POST /lines/{id}/update. This is a partial update: only the fields you send are written. Admin keys patch every operator-level field, includingpackage_idto move the line onto another package without renewing it or spending credits; reseller keys are limited to the line'snotesand to trimming its bouquets. - Inspect live connections with
GET /lines/{id}/connectionsto see who is streaming what, from where. - Delete with
POST /lines/{id}/delete. Bouquet assignments and contact-info rows are cleaned up in the same transaction. Deletion does not refund credits, matching the panel UI's behavior.
The two flags enabled and admin_enabled are separate on purpose: enabled is the reseller-visible toggle, admin_enabled is a hard override the admin can flip to block a line regardless of what the reseller does. The Panel API surfaces both, but only POST /lines/{id}/update, and only with an admin key, can touch admin_enabled; the enable/disable endpoints only move enabled.
The line object
Every endpoint that returns a line uses this 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
}
Field notes:
| Field | Type | Notes |
|---|---|---|
id |
int | Panel-wide numeric id. Stable for the life of the line. |
username |
string | Autogenerated as u_<8hex> (or trial_<8hex> for trials) if you don't send one. |
password |
string | Autogenerated (8 hex chars) if omitted. Returned in clear in every line response. |
member_id |
int | Numeric id of the owner (admin or reseller). Reseller keys always see their own id here. |
exp_date |
int or null | Expiry as a UTC Unix epoch. null means the line never expires (perpetual). |
max_connections |
int | Concurrent-connection cap, in the range [1, 100]. |
is_trial |
bool | True if the line was created from a trial package. |
is_restreamer |
bool | True if this line is allowed to restream through the panel. |
enabled |
bool | Reseller-visible toggle. |
admin_enabled |
bool | Admin override. If false, the line is blocked no matter what enabled says. |
bouquets |
int[] | Bouquet ids assigned to the line. |
created_at |
int or null | Creation timestamp (UTC Unix epoch). |
Endpoints
The PHP and Python examples below assume a
$client/clientalready constructed as shown on the SDKs page (base URL plus API key), so each endpoint shows only the call itself. The SDK generates theIdempotency-Keyheader for every write automatically; passidempotencyKey/idempotency_keyyourself only when you want to derive it from a business ID. All line methods live under$client->lines/client.lines.
POST /lines — create a line
Create a new subscriber line.
Scope: lines:write. Idempotency: required.
Body fields
| Field | Type | Required | Notes |
|---|---|---|---|
package_id |
int | yes | Must exist. Determines default duration, credit cost, default bouquets, default max_connections, and whether the line is a trial. |
member_id |
int | admin only, required | The reseller (or admin) that will own the line. Reseller keys must NOT send this: the owner is forced to the key's own reseller id, and sending this field returns 403 admin_only_field. |
username |
string | no | Autogenerated if omitted. Must be unique panel-wide. |
password |
string | no | Autogenerated (8 hex chars) if omitted. Reseller keys whose member group has allow_change_pass=0 cannot pick a password (returns 403 password_change_not_allowed). |
bouquets |
int[] | no | When bouquets is omitted or empty, the line inherits the package's bouquets. On reseller keys every bouquet id must be visible to the reseller's member group, inherited ones included; ids the reseller cannot see return 422 validation_error with details.invalid_ids. |
is_trial |
bool | no | Only valid if the package itself is a trial package. Sending true on a non-trial package returns 422 trial_flag_requires_trial_package. A trial package always forces is_trial=true even if you don't send it. |
email, notes |
string | no | Stored on the line's contact-info row. Best-effort: a write failure here logs but does not fail the create. Reseller keys write to reseller_user_contact_info, admin keys to admin_user_contact_info. |
exp_date |
int (UTC epoch) | admin only | Custom expiry. Must be in the future and within 5 years. If omitted, expiry comes from the package's duration. |
max_connections |
int | admin only | Override the package's default. Clamped to [1, 100]. |
is_restreamer |
bool | admin only | Override the package's default. |
allowed_ips |
string[] | admin only | IPv4 allow-list. Up to 50 entries. Invalid entries are dropped silently. |
allowed_ua |
string[] | admin only | User-Agent allow-list. Up to 50 entries, each capped at 500 characters. |
is_isplock |
bool | admin only | Lock the line to the first ISP that connects. |
Admin-only fields: member_id, exp_date, max_connections, is_restreamer, allowed_ips, allowed_ua, is_isplock. If a reseller key sends any of these, the request is rejected with 403 admin_only_field and the offending field names come back in details.fields.
Order of validation (all before any write):
- Package exists.
- Reseller only: the package is in a member group the reseller can sell from.
is_trialmatches the package.- Reseller only: subscription is still active (
billing_expiresnot in the past). Admin keys are exempt here, so the admin can rescue an expired reseller's customer manually. - Reseller + trial: the reseller's trial quota for the current window is not exceeded.
- Reseller +
usersbilling mode + non-trial: slot capacity (both the reseller's own cap and any ancestor cap) is not exceeded. - Reseller +
creditsbilling mode: enough credits to cover the package cost. - Member-group permission checks for
passwordandis_isplock. - Bouquet ids are all visible to the reseller.
Credit deduction is atomic. In credits mode the deduction happens before the row insert, using a conditional UPDATE. If the deduction fails you get 402 insufficient_credits. If the insert fails afterwards (for example, a username collision) the credits are refunded automatically.
curl:
curl -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"package_id": 1,
"member_id": 42,
"username": "johndoe",
"password": "s3cret!",
"bouquets": [1, 4, 9],
"email": "johndoe@example.com",
"notes": "created from WHMCS order #4712"
}' \
https://<your-panel-domain>/panel-api/v1/lines
$line = $client->lines->create(
packageId: 1,
memberId: 42,
username: 'johndoe',
password: 's3cret!',
bouquets: [1, 4, 9],
email: 'johndoe@example.com',
notes: 'created from WHMCS order #4712',
);
echo $line->id, ' ', $line->username, PHP_EOL;
line = client.lines.create(
package_id=1,
member_id=42,
username="johndoe",
password="s3cret!",
bouquets=[1, 4, 9],
email="johndoe@example.com",
notes="created from WHMCS order #4712",
)
print(line.id, line.username)
Response (201): a full line object.
Errors:
| HTTP | Slug | Meaning |
|---|---|---|
| 400 | missing_idempotency_key |
Header not sent. |
| 402 | billing_expired |
Reseller's subscription has expired. |
| 402 | trial_quota_exceeded |
Reseller hit their trial-creation cap for the current window. |
| 402 | slot_limit_exceeded |
Reseller's own user cap would be exceeded. |
| 402 | ancestor_cap_reached |
A parent reseller's cap would be exceeded. |
| 402 | insufficient_credits |
Not enough credits to pay for the package. |
| 403 | admin_only_field |
Reseller sent a field reserved to admin keys. |
| 403 | package_not_accessible |
Reseller cannot sell from that package. |
| 403 | password_change_not_allowed |
Reseller's member group forbids picking a password. |
| 403 | isplock_not_allowed |
Reseller's member group forbids ISP-lock. |
| 409 | idempotency_conflict / idempotency_in_flight |
Retry with a different key, or wait for the in-flight request to finish. |
| 422 | validation_error |
Missing / malformed field, unknown package, unknown member_id, username collision, bouquet not accessible. |
| 422 | trial_flag_requires_trial_package |
is_trial=true on a non-trial package. |
GET /lines — list lines
List lines with cursor-based pagination.
Scope: lines:read.
Query parameters
| Param | Type | Notes |
|---|---|---|
limit |
int | Page size. Clamped to [1, 100], default 50. |
cursor |
int | Keyset cursor: the API returns lines with id > cursor, ascending. Use next_cursor from the previous response to walk the whole list. |
is_trial |
bool | Filter by trial flag. Accepts true / false / 1 / 0 / yes / no / on / off, case-insensitive. |
enabled |
bool | Same boolean parser as is_trial. |
username |
string | Exact match. |
password |
string | Exact match. Combined with username, this gives you the "find by credentials" query billing systems use to reconcile. |
Reseller keys are automatically scoped to their own member_id: they cannot see lines that belong to another owner, regardless of any filter they send.
When you filter by
password, we recommend sending the value in the request body (as an alternative supported form) or, better, callingGET /lines/{id}once you already know the id. Apasswordin a query string can leak into intermediate access logs (your load balancer, a corporate proxy). Our nginx logs strip the query string for the Panel API location, but you should not assume the whole path from client to server does the same.
curl:
curl -H "Authorization: Bearer $TOKEN" \
"https://<your-panel-domain>/panel-api/v1/lines?limit=50&enabled=true&cursor=0"
$page = $client->lines->list(limit: 50, enabled: true);
foreach ($page->items as $line) {
echo $line->id, ' ', $line->username, ' ', $line->enabled ? 'on' : 'off', PHP_EOL;
}
if ($page->nextCursor !== null) {
echo 'next page cursor: ', $page->nextCursor, PHP_EOL;
}
page = client.lines.list(limit=50, enabled=True)
for line in page.items:
print(line.id, line.username, line.enabled)
if page.next_cursor is not None:
print("next page cursor:", page.next_cursor)
Response (200):
{
"items": [
{ "id": 12345, "username": "u_ab12cd34", "...": "..." },
{ "id": 12346, "username": "u_ff2233aa", "...": "..." }
],
"next_cursor": 12346
}
next_cursor is null on the last page. Pass it back as the cursor query parameter to fetch the next slice.
GET /lines/{id} — fetch one line
Return the full line object for a single id.
Scope: lines:read.
curl:
curl -H "Authorization: Bearer $TOKEN" \
https://<your-panel-domain>/panel-api/v1/lines/12345
$line = $client->lines->get(12345);
echo $line->id, ' ', $line->username, ' expires ',
$line->expDate?->format(DATE_ATOM) ?? 'never', PHP_EOL;
line = client.lines.get(12345)
print(line.id, line.username, "expires",
line.exp_date.isoformat() if line.exp_date else "never")
Response (200): a full line object.
Errors: 404 not_found. Cross-tenant reads also return 404, not 403, so a reseller cannot use this endpoint to check whether an id exists in another reseller's book of business.
POST /lines/{id}/update — partial update
Patch a subset of fields on an existing line. Every field is optional; only the fields you send are written.
Scope: lines:write. Idempotency: required. Admin keys reach every field below; reseller keys reach notes and bouquets only.
Accepted fields
| Field | Type | Notes |
|---|---|---|
notes |
string | Free-text note attached to the line, up to 4000 characters. Stored as sent, trimmed of surrounding whitespace, with no prefix added. An empty string clears it. Admin keys write the admin note, reseller keys write their own reseller note, and the two are stored separately. The note is not returned in the line object: this endpoint writes it, no endpoint reads it back. |
bouquets |
int[] | Replaces the line's bouquet assignment. Never empty, and at most 512 ids per call. Admin keys can set any id that exists in the bouquet catalog; reseller keys can only send a subset of what the line already has (see below). |
package_id |
int | Admin only. Move the line onto another package. Applies that package's max_connections and is_restreamer, plus its bouquets when bouquets is not in the body. Does not renew, does not charge credits, does not move exp_date. Must exist and must not be a trial package. See below. |
password |
string | Admin only. Set a new password (no random generation, use /reset-password for that). |
exp_date |
int, or null |
Admin only. UTC epoch. Sending null explicitly makes the line perpetual (no expiry). Omitting the field leaves the current value alone. |
max_connections |
int | Admin only. Clamped to [1, 100]. |
is_restreamer |
bool | Admin only. Flip the restreamer flag. |
enabled |
bool | Admin only. Reseller-visible toggle. |
admin_enabled |
bool | Admin only. Hard admin override. Only settable through this endpoint. |
allowed_ips |
string[] | Admin only. IPv4 allow-list, up to 50 entries. |
allowed_ua |
string[] | Admin only. User-Agent allow-list, up to 50 entries. |
Admin-only fields: package_id, password, exp_date, max_connections, is_restreamer, enabled, admin_enabled, allowed_ips, allowed_ua. If a reseller key sends any of them the call is rejected with 403 admin_only_field, the offending names come back in details.fields, and nothing at all is written.
Changing the package (admin keys)
package_id applies another package's template to a live line. The line keeps its id, username, password and expiry; only the configuration moves. The new package writes max_connections (clamped to [1, 100]), is_restreamer, and the bouquets. Nothing on the billing side moves: exp_date, enabled, admin_enabled, is_trial, member_id and the owner reseller's credits are all left alone. This is the mid-cycle upgrade and downgrade path a billing system needs, where the money changed hands on its side and the panel only has to reflect the new plan.
Two rules decide what the line ends up with. Fields you send yourself win over the package template, so {"package_id": 7, "max_connections": 9} gives 9 connections plus package 7's restreamer flag. And bouquets, when present, must be a non-empty subset of the new package's bouquets (ids outside it return 422 validation_error with details.invalid_ids); when absent, the line inherits the new package's bouquets in full, so a downgrade really does remove content. A package with no bouquets of its own leaves the line with none.
The panel does not store a line's package: a package is a template, and the line object does not carry one. Record the applied package on your side when the call returns 200. A trial package is refused with 422 trial_package_not_allowed, and a reseller key sending package_id gets 403 admin_only_field so that a package upgrade cannot bypass the credit charge that renew applies.
What a reseller key can do here
A reseller key can send notes, bouquets, or both. The bouquets array must be a non-empty subset of the bouquets the line already has: through this endpoint a reseller can only remove bouquets. Any id outside the line's current set is rejected with 422 validation_error and details.invalid_ids.
To add bouquets to a line, or to move it to a different package, a reseller calls POST /lines/{id}/renew with the new package_id and, optionally, the subset of that package's bouquets to keep. Renew is a billing operation: in credits mode it charges a full period at the package's price. There is no free path for a reseller key to widen a line's bouquets or to change its package.
Ownership still resolves the same way, but the admin-only field gate runs first: a reseller key that sends only the fields it is allowed to send gets 404 not_found for a line it does not own, while a body carrying an admin-only field gets 403 admin_only_field whoever owns the line.
Unlike the create endpoint,
exp_datehere is not range-validated. You can move a line arbitrarily far into the future or back in time. Use with care.
curl (admin key, operator fields):
curl -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"max_connections": 4, "is_restreamer": true}' \
https://<your-panel-domain>/panel-api/v1/lines/12345/update
curl (reseller key, a note plus a narrower bouquet set):
curl -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"notes": "downgraded to the sports-only plan", "bouquets": [1, 4]}' \
https://<your-panel-domain>/panel-api/v1/lines/12345/update
$line = $client->lines->update(
id: 12345,
maxConnections: 4,
isRestreamer: true,
);
echo $line->maxConnections, PHP_EOL;
line = client.lines.update(
id=12345,
max_connections=4,
is_restreamer=True,
)
print(line.max_connections)
exp_date is three-state: omit the argument to leave the current expiry untouched, pass a UTC epoch to set a new one, or pass null / None to make the line perpetual — e.g. $client->lines->update(id: 12345, expDate: null) / client.lines.update(id=12345, exp_date=None).
notes and bouquets are named arguments like any other, so the reseller-side call is $client->lines->update(id: 12345, notes: 'downgraded to the sports-only plan', bouquets: [1, 4]) / client.lines.update(id=12345, notes="downgraded to the sports-only plan", bouquets=[1, 4]).
The package change is the same shape, from SDK v1.2.0 on: $client->lines->update(id: 12345, maxConnections: 9, packageId: 7) / client.lines.update(id=12345, max_connections=9, package_id=7).
Response (200): a full line object with the updated fields. bouquets is re-read from the panel after the write, so it shows what was actually stored.
Errors: 400 missing_idempotency_key · 403 admin_only_field · 404 not_found · 409 idempotency codes · 422 validation_error (empty or malformed bouquets, bouquet ids the caller may not set, notes longer than 4000 characters, package_id not a positive integer or pointing at a package that does not exist) · 422 trial_package_not_allowed (package_id points at a trial package) · 500 internal_error.
POST /lines/{id}/enable
Flip the enabled flag to true.
Scope: lines:write. Idempotency: required.
For reseller keys in users billing mode with a non-trial line, this endpoint re-checks slot capacity before enabling. This closes the loophole where a reseller could disable an old line and enable a new one to sneak past their user cap.
curl:
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
https://<your-panel-domain>/panel-api/v1/lines/12345/enable
$line = $client->lines->enable(12345);
echo $line->enabled ? 'enabled' : 'still disabled', PHP_EOL;
line = client.lines.enable(12345)
print(line.enabled)
Response (200): a full line object with "enabled": true.
Errors: 400 missing_idempotency_key · 402 slot_limit_exceeded / ancestor_cap_reached (reseller, users mode) · 404 not_found · 409 idempotency codes · 500 internal_error.
POST /lines/{id}/disable
Flip the enabled flag to false. Never charges, never rejects on billing.
Scope: lines:write. Idempotency: required.
curl:
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
https://<your-panel-domain>/panel-api/v1/lines/12345/disable
$line = $client->lines->disable(12345);
echo $line->enabled ? 'still enabled' : 'disabled', PHP_EOL;
line = client.lines.disable(12345)
print(line.enabled)
Response (200): a full line object with "enabled": false.
Errors: 400 missing_idempotency_key · 404 not_found · 409 idempotency codes · 500 internal_error.
POST /lines/{id}/renew
Extend the expiry of a line by the package's official duration.
Scope: lines:write. Idempotency: required.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
package_id |
int | yes | Must exist. Cannot be a trial package (returns 422 renew_with_trial_package_not_allowed). Reseller keys must have access to the package (403 package_not_accessible otherwise). |
bouquets |
int[] | no | Non-empty subset of the bouquets of package_id, at most 512 ids, and every id must still exist in the bouquet catalog. Sent, it replaces the line's bouquet assignment; omitted, the line keeps the bouquets it already has. Same rule for admin and reseller keys. |
Behavior:
- The new expiry is
max(current_exp_date, now) + package.official_duration. If the line already expired, the countdown starts from now. If it is still active, the extension is added on top of the current expiry (no lost days). admin_enabledandenabledare both set totruein the same statement, so a renew reactivates a previously disabled or admin-blocked line.max_connectionsis set to the package's value (clamped to[1, 100]) andis_trialis set tofalse, matching the panel's own Extend. A trial renewed onto an official package leaves the trial state.is_restreamerand the other per-line settings are untouched. Connections you raised by hand above the package go back to the package value; re-apply them withPOST /lines/{id}/update(admin keys) if you want to keep them.- Reseller in
creditsmode: deductspackage.official_creditsbefore the update. Failure to deduct returns402 insufficient_credits. If the update fails after the deduction, the credits are refunded. - Reseller in
usersmode: renewals are free (a slot is already accounted for as long as the line exists). - Perpetual lines (
exp_date == null) cannot be renewed: renewing would set an expiry and effectively degrade the line. The endpoint returns422 line_has_no_expiry. UsePOST /lines/{id}/updatewith a chosenexp_dateif you really want to convert a perpetual line into a timed one. bouquetsis validated before any credit is deducted, so a rejected bouquet id never costs a reseller a period. Ids outside the package return422 validation_errorwithdetails.invalid_idsand nothing is charged or changed. A package definition can still list a bouquet that was deleted afterwards; those ids are rejected the same way.- This is also how a reseller key adds bouquets to a line or moves it to another package:
updatecan only remove bouquets,renewre-provisions the line againstpackage_idand charges a full period increditsmode. Pick the package first, then narrow it withbouquetsif the customer is not buying all of it.
curl:
curl -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"package_id": 1}' \
https://<your-panel-domain>/panel-api/v1/lines/12345/renew
To renew onto a package and keep only part of it, add the subset:
curl -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"package_id": 1, "bouquets": [1, 4]}' \
https://<your-panel-domain>/panel-api/v1/lines/12345/renew
$line = $client->lines->renew(id: 12345, packageId: 1);
echo 'new expiry ', $line->expDate?->format(DATE_ATOM), PHP_EOL;
line = client.lines.renew(id=12345, package_id=1)
print("new expiry", line.exp_date.isoformat() if line.exp_date else None)
In the SDKs the subset is a named argument: $client->lines->renew(id: 12345, packageId: 1, bouquets: [1, 4]) / client.lines.renew(id=12345, package_id=1, bouquets=[1, 4]).
Response (200): a full line object with the new exp_date, and with bouquets re-read from the panel.
Errors: 400 missing_idempotency_key · 402 billing_expired / insufficient_credits · 403 package_not_accessible · 404 not_found · 409 idempotency codes · 422 validation_error (unknown package, or bouquets empty, malformed, or outside the package) / renew_with_trial_package_not_allowed / line_has_no_expiry · 500 internal_error.
POST /lines/{id}/reset-password
Rotate a line's password.
Scope: lines:write. Idempotency: required.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
password |
string | no | If omitted, the API generates a random 8-hex-character password. |
Reseller keys whose member group has allow_change_pass=0 cannot use this endpoint (403 password_change_not_allowed).
curl:
curl -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{}' \
https://<your-panel-domain>/panel-api/v1/lines/12345/reset-password
// resetPassword returns the new password as a string, not a Line.
$newPassword = $client->lines->resetPassword(12345);
echo $newPassword, PHP_EOL; // e.g. "9f3c1a77"
# reset_password returns the new password as a str, not a Line.
new_password = client.lines.reset_password(12345)
print(new_password) # e.g. "9f3c1a77"
To set a specific password instead of a random one, pass it: $client->lines->resetPassword(12345, 's3cret!') / client.lines.reset_password(12345, "s3cret!").
Response (200): a compact shape, not the full line object:
{"id": 12345, "password": "9f3c1a77"}
If you need the full line back, call GET /lines/{id} afterwards.
Errors: 400 missing_idempotency_key · 403 password_change_not_allowed · 404 not_found · 409 idempotency codes · 500 internal_error.
POST /lines/{id}/delete
Delete a line and cascade-clean its related rows (bouquet assignments, contact info).
Scope: lines:write. Idempotency: required.
Reseller keys are subject to the member_groups.delete_users permission: if the reseller's group is not allowed to delete customers, the endpoint returns 403 delete_not_allowed. This mirrors the guard the reseller UI applies, so a POST from an API integration cannot bypass what the UI itself would block.
Deletion does not refund credits, matching the panel UI. In users billing mode the slot is released automatically (the active-users count reads live from the users table).
curl:
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
https://<your-panel-domain>/panel-api/v1/lines/12345/delete
// delete returns a bool: true once the panel confirms the row is gone.
$deleted = $client->lines->delete(12345);
echo $deleted ? 'gone' : 'still there', PHP_EOL;
# delete returns a bool: True once the panel confirms the row is gone.
deleted = client.lines.delete(12345)
print(deleted) # True
Response (200):
{"id": 12345, "username": "u_ab12cd34", "deleted": true}
The API verifies the row actually disappeared before reporting success. If the delete cascade fails partway through, the endpoint returns 500 delete_failed instead of a false 200, so your billing system does not mark a customer inactive while their line is still active on the panel.
Errors: 400 missing_idempotency_key · 403 delete_not_allowed (reseller only) · 404 not_found · 409 idempotency codes · 500 delete_failed / internal_error.
GET /lines/{id}/connections
List live streaming connections for a single line. Useful for kick-per-user diagnostics, showing "who is watching what" in a support UI, or feeding a fraud-detection pipeline.
Scope: lines:read.
Returns up to 200 connections, ordered by started_at descending. There is no pagination cursor: a single line rarely has more than a handful of active connections at once.
curl:
curl -H "Authorization: Bearer $TOKEN" \
https://<your-panel-domain>/panel-api/v1/lines/12345/connections
// connections returns a plain array of Connection objects (no cursor).
$connections = $client->lines->connections(12345);
foreach ($connections as $c) {
echo $c->clientIp, ' ', $c->clientCountry, ' ', $c->contentName, ' ', $c->elapsedSec, "s\n";
}
# connections returns a plain list of Connection objects (no cursor).
connections = client.lines.connections(12345)
for c in connections:
print(c.client_ip, c.client_country, c.content_name, c.elapsed_sec, "s")
Response (200):
{
"items": [
{
"connection_id": 987654,
"content_type": "live",
"content_id": 4211,
"content_name": "ESPN HD",
"started_at": 1785984000,
"elapsed_sec": 132,
"client_ip": "203.0.113.42",
"client_country": "US"
},
{
"connection_id": 987655,
"content_type": "movie",
"content_id": 88012,
"content_name": "Breaking Bad S01E01",
"started_at": 1785983900,
"elapsed_sec": 232,
"client_ip": "203.0.113.42",
"client_country": "US",
"is_serie": true
}
]
}
Field notes:
content_typeis normalized to"live"or"movie". Series episodes are reported as"movie"with an additionalis_serie: trueflag.content_idandcontent_namearenull/ empty if the content was deleted after the connection started (rare, but the join is a LEFT JOIN so the connection is still surfaced).elapsed_secis computed server-side, so successive calls give you a monotonically increasing value without clock drift concerns on your end.client_ipandclient_countrycome from the streaming server's own connection log, which uses a MaxMind GeoIP2 database for the country code.
Fields that we deliberately do NOT expose here: server ip, session id, user-agent. If you need those for a specific audit, tell us the use case and we will decide whether to add them behind an opt-in scope.
Errors: 404 not_found.
Cross-tenant isolation
Every endpoint on this page enforces the same rule: a reseller key can never see or mutate a line whose member_id is not the key's own reseller id. This includes:
GET /linesauto-filters bymember_id = <caller>.GET /lines/{id},POST /lines/{id}/*,GET /lines/{id}/connectionsall resolve the line, check ownership, and return404 not_foundon any mismatch.- We return
404, not403, on cross-tenant reads. A403would confirm that the requested id exists, which is enough for an attacker to enumerate the size of another reseller's book of business inside the same panel.
Admin keys have no such filter: they see and mutate every line, in every reseller's book. Give the admin scope only to integrations that genuinely need panel-wide reach (billing reconciliation, migration tooling, incident response). For everything else, issue a reseller key with the smallest scope that gets the job done.
Common errors
The full table of Panel API error slugs, HTTP codes, and remediation lives in Errors. The subset your integration is most likely to hit on this resource:
| HTTP | Slug | When |
|---|---|---|
| 400 | missing_idempotency_key |
Any write without the header. Send a fresh key on each new attempt; reuse only when retrying the same logical operation. |
| 402 | insufficient_credits |
Reseller ran out of credits mid-create or mid-renew. Top up and retry with a fresh idempotency key. |
| 402 | billing_expired |
Reseller's subscription lapsed. Renew the reseller first, then retry. |
| 402 | slot_limit_exceeded / ancestor_cap_reached |
Reseller (or an ancestor) hit their user cap. Raise the cap or delete inactive lines. |
| 402 | trial_quota_exceeded |
Reseller hit their trial-creation quota for the current window. Wait for the window to roll or raise the cap. |
| 403 | admin_only_field |
Reseller sent a field reserved to admin keys. On create: member_id, exp_date, max_connections, is_restreamer, allowed_ips, allowed_ua, is_isplock. On update: package_id, password, exp_date, max_connections, is_restreamer, enabled, admin_enabled, allowed_ips, allowed_ua. details.fields names the ones rejected. Drop them or use an admin key. |
| 403 | package_not_accessible |
Reseller cannot sell from that package. Add the package to the reseller's member group, or pick another package. |
| 403 | password_change_not_allowed |
Reseller's member group forbids picking / resetting passwords. Change the group permission or let the API autogenerate. |
| 403 | delete_not_allowed |
Reseller's member group forbids deleting customers. |
| 404 | not_found |
The line does not exist, or (reseller only) it exists but belongs to someone else. |
| 409 | idempotency_in_flight |
An earlier request with the same key is still processing. Wait and retry. |
| 409 | idempotency_conflict |
An earlier request with the same key resolved with a different body. Generate a fresh key. |
| 422 | validation_error |
Missing / malformed field, unknown package or member id, username collision, bouquets not accessible, notes longer than 4000 characters. details usually points to the offending field, and details.invalid_ids lists the bouquet ids that were refused. |
| 422 | trial_flag_requires_trial_package |
Sent is_trial=true on a non-trial package. |
| 422 | renew_with_trial_package_not_allowed |
Passed a trial package to /renew. |
| 422 | trial_package_not_allowed |
Passed a trial package as package_id on /update. |
| 422 | line_has_no_expiry |
Tried to renew a perpetual line. |
| 429 | rate_limited |
Per-key or per-IP budget exceeded. Honor the Retry-After header. |
See also
- Overview — the whole Panel API in one page.
- Authentication — token shape, scopes, IP allow-lists.
- Rate limits & Idempotency — safe-retry semantics, budgets.
- Catalog — packages, bouquets, streams, and VODs you can attach to a line.
- Resellers — the owners of the lines you create with
member_id. - Errors — the full slug table for every endpoint.