---
title: "Panel API Lines"
description: "Complete reference for the /panel-api/v1/lines resource: create, list, get, update, enable, disable, renew, reset password, delete, and list live connections. Covers admin vs reseller behavior, cross-tenant isolation, and every error slug."
---

> [!TIP]
> Using the official [SDKs](/docs/?page=panel-api-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](/docs/?page=panel-api-authentication) for how to obtain a token.

> [!IMPORTANT]
> Every write operation (create, update, enable, disable, renew, reset password, delete) requires an `Idempotency-Key` header. See [Rate limits & Idempotency](/docs/?page=panel-api-rate-limits-idempotency) for the safe-retry contract.

## 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. 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** operator-level fields (admin only) with `POST /lines/{id}/update`. This is a partial update: only the fields you send are written.
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` (admin-only) can touch `admin_enabled`; the enable/disable endpoints only move `enabled`.

## The line object

Every endpoint that returns a line uses this shape:

```json
{
  "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

> [!NOTE]
> The **PHP** and **Python** examples below assume a `$client` / `client` already constructed as shown on the [SDKs page](/docs/?page=panel-api-sdks) (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 | Falls back to the package's default bouquets if omitted or empty. On reseller keys, every bouquet id must be visible to the reseller's member group; unknown ids 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:**
```bash
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
```

```php
$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;
```

```python
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](#the-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.

> [!IMPORTANT]
> 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:**
```bash
curl -H "Authorization: Bearer $TOKEN" \
     "https://<your-panel-domain>/panel-api/v1/lines?limit=50&enabled=true&cursor=0"
```

```php
$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;
}
```

```python
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):**

```json
{
  "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:**
```bash
curl -H "Authorization: Bearer $TOKEN" \
     https://<your-panel-domain>/panel-api/v1/lines/12345
```

```php
$line = $client->lines->get(12345);
echo $line->id, ' ', $line->username, ' expires ',
     $line->expDate?->format(DATE_ATOM) ?? 'never', PHP_EOL;
```

```python
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](#the-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 (admin only)

Patch a subset of fields on an existing line. Every field is optional; only the fields you send are written.

**Scope:** `lines:write`. **Admin key required**, reseller keys get `403 admin_only_endpoint`. **Idempotency:** required.

**Accepted fields**

| Field | Type | Notes |
|---|---|---|
| `password` | string | Set a new password (no random generation, use `/reset-password` for that). |
| `exp_date` | int, or `null` | UTC epoch. Sending `null` explicitly makes the line perpetual (no expiry). Omitting the field leaves the current value alone. |
| `max_connections` | int | Clamped to `[1, 100]`. |
| `is_restreamer` | bool | Flip the restreamer flag. |
| `enabled` | bool | Reseller-visible toggle. |
| `admin_enabled` | bool | Hard admin override. Only settable through this endpoint. |
| `allowed_ips` | string[] | IPv4 allow-list, up to 50 entries. |
| `allowed_ua` | string[] | User-Agent allow-list, up to 50 entries. |

> [!NOTE]
> 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:**
```bash
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
```

```php
$line = $client->lines->update(
    id:             12345,
    maxConnections: 4,
    isRestreamer:   true,
);
echo $line->maxConnections, PHP_EOL;
```

```python
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)`.

**Response (200):** a full [line object](#the-line-object) with the updated fields.

**Errors:** `400 missing_idempotency_key` · `403 admin_only_endpoint` · `404 not_found` · `409` idempotency codes · `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:**
```bash
curl -X POST \
     -H "Authorization: Bearer $TOKEN" \
     -H "Idempotency-Key: $(uuidgen)" \
     https://<your-panel-domain>/panel-api/v1/lines/12345/enable
```

```php
$line = $client->lines->enable(12345);
echo $line->enabled ? 'enabled' : 'still disabled', PHP_EOL;
```

```python
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:**
```bash
curl -X POST \
     -H "Authorization: Bearer $TOKEN" \
     -H "Idempotency-Key: $(uuidgen)" \
     https://<your-panel-domain>/panel-api/v1/lines/12345/disable
```

```php
$line = $client->lines->disable(12345);
echo $line->enabled ? 'still enabled' : 'disabled', PHP_EOL;
```

```python
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). |

**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.
- 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.

**curl:**
```bash
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
```

```php
$line = $client->lines->renew(id: 12345, packageId: 1);
echo 'new expiry ', $line->expDate?->format(DATE_ATOM), PHP_EOL;
```

```python
line = client.lines.renew(id=12345, package_id=1)
print("new expiry", line.exp_date.isoformat() if line.exp_date else None)
```

**Response (200):** a full line object with the new `exp_date`.

**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) / `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:**
```bash
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
```

```php
// resetPassword returns the new password as a string, not a Line.
$newPassword = $client->lines->resetPassword(12345);
echo $newPassword, PHP_EOL;  // e.g. "9f3c1a77"
```

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

```json
{"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:**
```bash
curl -X POST \
     -H "Authorization: Bearer $TOKEN" \
     -H "Idempotency-Key: $(uuidgen)" \
     https://<your-panel-domain>/panel-api/v1/lines/12345/delete
```

```php
// 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;
```

```python
# delete returns a bool: True once the panel confirms the row is gone.
deleted = client.lines.delete(12345)
print(deleted)  # True
```

**Response (200):**

```json
{"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:**
```bash
curl -H "Authorization: Bearer $TOKEN" \
     https://<your-panel-domain>/panel-api/v1/lines/12345/connections
```

```php
// 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";
}
```

```python
# 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):**

```json
{
  "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](/docs/?page=panel-api-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_endpoint` | Reseller called `/lines/{id}/update`. Use an admin key. |
| 403 | `admin_only_field` | Reseller sent a field reserved to admin keys (`member_id`, `exp_date`, `max_connections`, `is_restreamer`, `allowed_ips`, `allowed_ua`, `is_isplock`). Drop the field 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. `details` usually points to the offending field. |
| 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 | `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](/docs/?page=panel-api-overview) — the whole Panel API in one page.
- [Authentication](/docs/?page=panel-api-authentication) — token shape, scopes, IP allow-lists.
- [Rate limits & Idempotency](/docs/?page=panel-api-rate-limits-idempotency) — safe-retry semantics, budgets.
- [Catalog](/docs/?page=panel-api-catalog) — packages, bouquets, streams, and VODs you can attach to a line.
- [Resellers](/docs/?page=panel-api-resellers) — the owners of the lines you create with `member_id`.
- [Errors](/docs/?page=panel-api-errors) — the full slug table for every endpoint.
