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-Key header. 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:

  1. 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, the member_id of the owner reseller. The line is created with enabled=true and admin_enabled=true. On a reseller key, credits are deducted atomically before the row is written, and refunded automatically if the create fails.
  2. List and get with GET /lines and GET /lines/{id}. Reseller keys are auto-scoped to their own member_id.
  3. Enable and disable with POST /lines/{id}/enable and POST /lines/{id}/disable. disable never charges. enable re-checks slot capacity on reseller keys in users billing mode, so a reseller cannot use disable-then-enable to sneak past the cap.
  4. Renew with POST /lines/{id}/renew to push exp_date forward by the package's official duration and apply the package's connections (a trial line becomes an official one). In credits mode this deducts official_credits; in users mode renewals are free.
  5. Reset the password at any time with POST /lines/{id}/reset-password (returns the new password in the response).
  6. 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, including package_id to move the line onto another package without renewing it or spending credits; reseller keys are limited to the line's notes and to trimming its bouquets.
  7. Inspect live connections with GET /lines/{id}/connections to see who is streaming what, from where.
  8. 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 / client already constructed as shown on the SDKs page (base URL plus API key), so each endpoint shows only the call itself. The SDK generates the Idempotency-Key header for every write automatically; pass idempotencyKey / idempotency_key yourself 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):

  1. Package exists.
  2. Reseller only: the package is in a member group the reseller can sell from.
  3. is_trial matches the package.
  4. Reseller only: subscription is still active (billing_expires not in the past). Admin keys are exempt here, so the admin can rescue an expired reseller's customer manually.
  5. Reseller + trial: the reseller's trial quota for the current window is not exceeded.
  6. Reseller + users billing mode + non-trial: slot capacity (both the reseller's own cap and any ancestor cap) is not exceeded.
  7. Reseller + credits billing mode: enough credits to cover the package cost.
  8. Member-group permission checks for password and is_isplock.
  9. 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, calling GET /lines/{id} once you already know the id. A password in 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_date here 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_enabled and enabled are both set to true in the same statement, so a renew reactivates a previously disabled or admin-blocked line.
  • max_connections is set to the package's value (clamped to [1, 100]) and is_trial is set to false, matching the panel's own Extend. A trial renewed onto an official package leaves the trial state. is_restreamer and the other per-line settings are untouched. Connections you raised by hand above the package go back to the package value; re-apply them with POST /lines/{id}/update (admin keys) if you want to keep them.
  • Reseller in credits mode: deducts package.official_credits before the update. Failure to deduct returns 402 insufficient_credits. If the update fails after the deduction, the credits are refunded.
  • Reseller in users mode: 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 returns 422 line_has_no_expiry. Use POST /lines/{id}/update with a chosen exp_date if you really want to convert a perpetual line into a timed one.
  • bouquets is validated before any credit is deducted, so a rejected bouquet id never costs a reseller a period. Ids outside the package return 422 validation_error with details.invalid_ids and 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: update can only remove bouquets, renew re-provisions the line against package_id and charges a full period in credits mode. Pick the package first, then narrow it with bouquets if 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_type is normalized to "live" or "movie". Series episodes are reported as "movie" with an additional is_serie: true flag.
  • content_id and content_name are null / 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_sec is computed server-side, so successive calls give you a monotonically increasing value without clock drift concerns on your end.
  • client_ip and client_country come 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 /lines auto-filters by member_id = <caller>.
  • GET /lines/{id}, POST /lines/{id}/*, GET /lines/{id}/connections all resolve the line, check ownership, and return 404 not_found on any mismatch.
  • We return 404, not 403, on cross-tenant reads. A 403 would 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.