---
title: "Panel API Errors"
description: "The complete error contract for the Panel API. Envelope shape, every slug your integration may see, HTTP status codes and the recommended retry strategy for each."
---

# Panel API Errors

Every failing request returns the same JSON envelope with the same four fields, regardless of which flavor of the API you called or which resource you touched. This page catalogs every slug your integration may encounter, explains what triggers each one and tells you what to do about it.

> [!TIP]
> The official [SDKs](/docs/?page=panel-api-sdks) turn every error below into a typed exception you can catch. You only need this page if you are calling the API by hand.

## The error envelope

```json
{
  "error": "<slug>",
  "message": "<human-readable text>",
  "request_id": "<uuid-v4>",
  "details": { }
}
```

- **`error`**. A short, stable, machine-readable slug (for example `insufficient_scope`, `rate_limited`, `validation_error`). This is what your integration should branch on. The full catalog is listed below.
- **`message`**. A human-readable English sentence. Safe to log and to surface in your own admin UI, but do **not** parse it or match against it. The wording may change without notice. The slug will not.
- **`request_id`**. A UUID v4 generated per request. It also appears in the `X-Request-Id` response header. Log it on every request. Quote it whenever you contact Xtream AI support. It is the single fastest way to find your call in server logs.
- **`details`**. An optional object with structured extra context. It is present only when there is something concrete to tell you (which field failed validation, which scope was missing, which bouquet ids were rejected). If there is nothing to add, the field is omitted entirely.

The `Content-Type` is always `application/json; charset=utf-8`, and every response carries `Cache-Control: no-store`.

Compatibility flavors follow the same rules underneath, but each also translates the envelope into its own historical shape.

- The **Xtream Codes / XUI.one flavor** always responds with HTTP 200 and encodes the error in the JSON body under `status: "failure"` (plus the original slug on the side, so you can still recognize it).
- The **OneStream flavor** returns the native slug envelope with its own status codes.

Both flavors preserve `X-Request-Id`, so support workflows stay identical.

## Handling errors in code

The pattern is the same in every language. Branch on the slug. Quote the request id on 5xx. Retry on 429 and 5xx with backoff.

```php
$ch = curl_init('https://<your-panel-domain>/panel-api/v1/lines');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . $token,
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'package_id' => 1,
        'username' => 'johndoe',
        'password' => 'secret',
    ]),
]);
$rawBody = curl_exec($ch);
$status  = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$body    = json_decode($rawBody, true) ?: [];

if ($status >= 400) {
    $slug = $body['error'] ?? 'unknown';
    $rid  = $body['request_id'] ?? '(no request id)';

    switch ($slug) {
        case 'validation_error':
            // details.field tells you which field to fix
            $field = $body['details']['field'] ?? '(unknown field)';
            throw new BadRequestException("Field '{$field}' failed validation: " . $body['message']);
        case 'insufficient_credits':
        case 'billing_expired':
            // Never auto-retry a payment error. Escalate to your billing workflow.
            markSubscriptionOverdue($rid);
            break;
        case 'rate_limited':
            // Honor Retry-After. See the retry recipe below.
            break;
        case 'internal_error':
        case 'service_unavailable':
            // Retry with exponential backoff. Escalate quoting request_id if it persists.
            error_log("[panel-api] server error request_id={$rid}: " . $body['message']);
            break;
        default:
            throw new PanelApiException($slug, $body['message'], $rid);
    }
}
```

```python
import requests, time, uuid

def call(method, url, **kwargs):
    r = requests.request(method, url, timeout=30, **kwargs)
    if r.status_code < 400:
        return r.json()

    body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
    slug = body.get("error", "unknown")
    rid  = body.get("request_id", "(no request id)")

    if slug == "validation_error":
        field = body.get("details", {}).get("field", "(unknown field)")
        raise ValueError(f"validation_error on field {field}: {body.get('message')}")
    if slug in ("insufficient_credits", "billing_expired"):
        raise PermissionError(f"payment required ({slug}). request_id={rid}")
    if slug == "rate_limited":
        raise RateLimited(retry_after=int(r.headers.get("Retry-After", "60")))
    if r.status_code >= 500:
        raise ServerError(f"{slug} request_id={rid}: {body.get('message')}")
    raise PanelApiError(slug, body.get("message"), rid)
```

The important discipline. Your code should read `error` and never `message`. The slug is the contract. The message is documentation.

## The full slug catalog

The slugs below are the complete, exhaustive contract. If your integration receives an `error` value not listed here, it means a new slug was added. Please open a support ticket with the request id so we can update this page.

### Routing

| Slug | HTTP | When it fires | What to do |
|---|---|---|---|
| `not_found` | 404 | The URL path is unknown, or the resource id does not exist, or the resource exists but does not belong to you. Reseller keys receive 404 (never 403) when they touch a line owned by another reseller. This is deliberate. It prevents a reseller from using the API to enumerate ids belonging to competitors on the same panel. | Verify the URL and the resource id. If a resource that used to exist is now 404, treat it as deleted. |
| `method_not_allowed` | 405 | The path exists but not for the HTTP method you used (for example `GET /lines` is valid, `PUT /lines` is not). | Fix the method in your integration. This is not a retry-able error. |

### Authentication

| Slug | HTTP | When it fires | What to do |
|---|---|---|---|
| `invalid_key` | 401 | Uniform slug for **every** authentication failure. Missing `Authorization` header, malformed token, unknown prefix, wrong secret, expired key, disabled key, soft-deleted key or a caller IP that is not on the key's allow-list. The uniform response is deliberate so that an attacker cannot use the API as an oracle to probe which prefixes exist or which IPs are on your allow-list. | Verify the token is correct, current and being sent from an IP the key permits. See [Authentication](/docs/?page=panel-api-authentication). Not retry-able without operator intervention. |
| `caller_disabled` | 401 | The Bearer token is valid but the underlying reseller account has been banned, its member group has been banned or the reseller record no longer exists. Only reseller keys can receive this slug. Admin keys are not tied to a reg_user. | Stop using this key. Contact the panel administrator. The ban was applied by them and must be lifted by them. The check is cached for 60 seconds, so a lifted ban may take up to a minute to unblock the key. |

### Authorization

| Slug | HTTP | When it fires | What to do |
|---|---|---|---|
| `insufficient_scope` | 403 | The key is valid but does not carry the scope the endpoint requires. `details.required_scope` names the missing scope. | Rotate the key with a wider scope set (see [Authentication](/docs/?page=panel-api-authentication#scopes)) or route the call to a different key that already has it. Never retry with the same key. |
| `admin_only_endpoint` | 403 | The endpoint is reserved for admin keys and a reseller key tried to call it (for example `POST /lines/{id}/update`, or any `/resellers/*` route). | Use an admin key. This is not a scope check. Even a reseller key that carried `resellers:read` (which cannot happen at issuance) would still be rejected. |
| `admin_only_field` | 403 | A reseller key set a field that only admin keys are allowed to touch on line create or update. Those fields are `exp_date`, `max_connections`, `is_restreamer`, `allowed_ips`, `allowed_ua`, `is_isplock` and `member_id`. `details.fields` lists exactly which fields were rejected. | Remove those fields from the request body. They will still be honored, but with values derived from the package definition. |
| `package_not_accessible` | 403 | A reseller tried to use a package that their member group does not allow. | Choose one of the packages returned by `GET /packages` under this key. |
| `password_change_not_allowed` | 403 | A reseller key sent an explicit `password` on line create or update, but the reseller's member group has `allow_change_pass = 0`. | Omit the `password` field and let the API generate one, or ask the panel administrator to enable password overrides for the group. |
| `isplock_not_allowed` | 403 | A reseller key set `is_isplock` but the member group has `edit_isplock = 0`. | Omit the field or ask the administrator to enable it. |
| `owner_must_be_self` | 403 | A reseller key called `POST /resellers` with an `owner_id` that is not the caller's own reg_user id. Sub-resellers are always created under the caller. | Omit `owner_id` (it defaults to the caller) or set it to the caller's own id. |
| `caller_not_found` | 403 | A reseller key called `POST /resellers` but the reseller's own reg_user row has vanished from the panel's database. | Practical impact is the same as `caller_disabled`. Stop using the key and contact the administrator. |
| `sub_reseller_creation_not_allowed` | 403 | A reseller key called `POST /resellers` without the `create_sub_resellers` permission on its member group. | Ask the administrator to enable the permission on the group. |
| `delete_not_allowed` | 403 | A reseller key called `POST /lines/{id}/delete` without the `delete_users` permission on its member group. | Ask the administrator to enable it. |
| `forbidden_action` | 403 | Xtream Codes / XUI.one flavor only. An action that is intentionally not exposed (currently `mysql_query`, arbitrary SQL execution against the panel database) was requested. | Do not retry. If you need something that `mysql_query` used to provide, please contact us so we can prioritize a first-class endpoint. |

### Request body and validation

| Slug | HTTP | When it fires | What to do |
|---|---|---|---|
| `invalid_body` | 400 | A required body field is missing on `POST /resellers/{id}/billing/adjust`. `message` names the missing field. | Fix the body and retry. |
| `validation_error` | 422 | The body reached the endpoint but a field failed validation (wrong type, out of range, unknown foreign id, malformed reference). `details.field` names the offender when known. For bouquets specifically, `details.invalid_ids` lists the ids that were not accessible to the caller. | Fix the field, then retry. Never blindly re-send the same body. |
| `trial_flag_requires_trial_package` | 422 | `POST /lines` was called with `is_trial: true` but the referenced `package_id` is not a trial package. | Either drop the `is_trial` flag or reference a package with `is_trial=1`. |
| `renew_with_trial_package_not_allowed` | 422 | `POST /lines/{id}/renew` referenced a package that is flagged as a trial. Trials cannot be used to renew an existing subscription. | Pick a normal (non-trial) package for the renewal. |
| `line_has_no_expiry` | 422 | `POST /lines/{id}/renew` was called on a line that has no expiry date (a perpetual account). Renewing would set an expiry and shorten it, so the call is rejected instead. | Change the expiry from the panel UI if that is really what you want, or renew a different line. |
| `no_sub_reseller_setup` | 422 | A reseller called `POST /resellers` but the panel administrator has not configured a sub-reseller package for the reseller's member group, or the configured setup is unusable. | Ask the administrator to configure a valid sub-reseller setup for the group. |
| `billing_expires_required` | 422 | A reseller in `users` billing mode called `POST /resellers` without a `billing_expires` timestamp. Sub-resellers in slots mode need an explicit expiry. | Include `billing_expires` (unix timestamp) in the body. |
| `insufficient_slots` | 422 | `POST /resellers` in `users` mode tried to assign a `max_users` cap to the sub-reseller that would exceed the caller's remaining slot budget. | Lower `max_users`, or free up slots by removing existing lines or sub-resellers. |
| `owner_id_required` | 422 | An admin key called `POST /resellers` on a panel that has no admin account to default `owner_id` to, and no explicit `owner_id` was provided. | Pass `owner_id` explicitly. |
| `negative_balance_not_allowed` | 422 | `POST /resellers/{id}/billing/adjust` on a credits reseller tried to push the credit balance below zero. | Reduce the negative delta so the resulting balance is >= 0. |
| `negative_cap_not_allowed` | 422 | `POST /resellers/{id}/billing/adjust` on a users reseller tried to push `max_users` below zero. | Reduce the delta. |
| `cap_below_active_users` | 422 | `POST /resellers/{id}/billing/adjust` tried to set `max_users` below the number of currently active users of that reseller. | Delete or disable lines first, then re-run the adjust. |
| `mismatched_mode` | 422 | `POST /resellers/{id}/billing/adjust` sent a `delta` type that does not match the reseller's billing mode (for example a `credits` delta against a `users`-mode reseller). | Read the reseller's `billing.mode` from `GET /resellers/{id}/billing` and send a matching delta. |

### Billing and capacity

Errors in this section always mean the request was well-formed but cannot proceed until you take a payment or capacity action. Never auto-retry them. Your billing workflow needs to react.

| Slug | HTTP | When it fires | What to do |
|---|---|---|---|
| `insufficient_credits` | 402 | A reseller in credits mode tried to create or renew a line, or create a sub-reseller, and their credit balance is below the required cost. | Top up the reseller's credits (from the panel or via `POST /resellers/{id}/billing/adjust` with a positive delta) and retry. |
| `billing_expired` | 402 | A reseller's own subscription has expired (`billing_expires` in the past). All write operations are blocked until it is renewed. Admin keys bypass this check, so admins can still rescue an expired reseller. | Renew the reseller's subscription and retry. |
| `slot_limit_exceeded` | 402 | A reseller in `users` mode tried to create a non-trial line and already holds the maximum number of active non-trial users (`max_users`). It also fires for sub-resellers whose `max_users` allocation is counted against the parent's cap. | Delete or disable an existing line, raise the reseller's `max_users` cap, or upgrade the reseller. |
| `ancestor_cap_reached` | 402 | A reseller can create the line locally but an ancestor further up the reseller tree is in `users` mode and at its own cap. | Free up capacity on the parent reseller or ask the administrator to raise their cap. |
| `trial_quota_exceeded` | 402 | A reseller in a member group with a rolling trial quota (`total_allowed_gen_trials` per day or per month) tried to create a trial and the quota is exhausted. | Wait for the rolling window to reset, or ask the administrator to raise the quota. |

### Idempotency

See [Rate limits and Idempotency](/docs/?page=panel-api-rate-limits-idempotency) for the full contract. The slugs your code will see are the following.

| Slug | HTTP | When it fires | What to do |
|---|---|---|---|
| `missing_idempotency_key` | 400 | A write endpoint (any `POST` that creates, updates or deletes state) was called without an `Idempotency-Key` header. | Add the header. Every retry of the same logical operation must reuse the same key. |
| `idempotency_conflict` | 409 | The same `Idempotency-Key` was sent again but with a different request body (byte-exact comparison of the raw JSON). | Do not retry with the same key. Either generate a fresh key for the new body, or send the exact original body. |
| `idempotency_in_flight` | 409 | The same `Idempotency-Key` is currently being processed by the server (a concurrent retry raced ahead of the first response). | Wait a few seconds and retry with the same key. The completed response will be replayed once the in-flight request finishes. |

### Rate limits

| Slug | HTTP | When it fires | What to do |
|---|---|---|---|
| `rate_limited` | 429 | Either the per-key budget (default 60/min, configurable per key) or the per-`(license, IP)` budget was exhausted. `/health` has its own 30/min per IP budget. | Read `Retry-After` (seconds) and wait at least that long before retrying. The response also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` when the failure is on the per-key budget, so you can pace future calls. |

### Kill switches

| Slug | HTTP | When it fires | What to do |
|---|---|---|---|
| `api_disabled` | 503 | The Panel API is off, either globally (the operator turned it off panel-wide via env var) or per-tenant (the customer disabled the Panel API from their settings). `/health` is intentionally the only endpoint that still responds `200` when this is set, so external monitoring can distinguish "API disabled" from "API down". | Retry later. If you never see this recover, contact the customer running the panel. This is a deliberate opt-out, not a bug. |

### Server errors

| Slug | HTTP | When it fires | What to do |
|---|---|---|---|
| `internal_error` | 500 | An unhandled exception escaped the request handler. The full stack trace is written to the server's error log with the same `request_id`. The response body carries a generic message. No internals are leaked. | Retry with exponential backoff. If it persists, contact support and **always** quote `request_id`. It is the only way to find your specific failure in the logs. |
| `delete_failed` | 500 | `POST /lines/{id}/delete` executed but a follow-up SELECT saw the row still present. That indicates the underlying delete was not applied (deadlock, connection dropped, permission gap). The API refuses to falsely report success. | Retry with the same `Idempotency-Key`, since the delete may already have partially succeeded and the follow-up SELECT is the authority. If it repeatedly fails, contact support with the request id. |
| `not_implemented` | 501 | A dialect endpoint has no counterpart in the native API and no local handler either. In practice this covers Xtream Codes / XUI.one MAG and Enigma device management (`mag/index.php`, `enigma/index.php`) and OneStream endpoints that the compatibility layer does not translate. | Do not retry. Rewrite the call against a supported endpoint. Contact us if you need something specific. |
| `service_unavailable` | 503 | The Panel API failed during bootstrap (unable to load its dependencies, database initialization failed, configuration missing). This is a catch-all for startup failures and is distinct from `api_disabled`. | Retry with exponential backoff. If it persists across several minutes, contact support quoting `request_id`. |

## Retry recipes

### 429 rate_limited

Read `Retry-After` from the response and wait at least that many seconds. If you receive several `429`s in quick succession, back off further so that your integration converges below the per-minute budget.

```python
import time, requests

def call_with_ratelimit(session, method, url, **kwargs):
    for attempt in range(6):
        r = session.request(method, url, timeout=30, **kwargs)
        if r.status_code != 429:
            return r
        wait = int(r.headers.get("Retry-After", "60"))
        # Add a small jitter so many workers do not un-pause together.
        time.sleep(wait + attempt * 1.5)
    raise RuntimeError("Rate limit did not clear after 6 attempts")
```

```php
function callWithRatelimit(callable $doRequest): array {
    for ($attempt = 0; $attempt < 6; $attempt++) {
        [$status, $headers, $body] = $doRequest();
        if ($status !== 429) return [$status, $headers, $body];
        $wait = (int) ($headers['retry-after'] ?? 60);
        sleep($wait + (int) ($attempt * 1.5));
    }
    throw new RuntimeException("Rate limit did not clear after 6 attempts");
}
```

For a full explanation of the per-key and per-IP budgets, and how to size your integration below them, read [Rate limits and Idempotency](/docs/?page=panel-api-rate-limits-idempotency).

### 5xx (internal_error, service_unavailable, delete_failed)

Retry with exponential backoff (1s, 2s, 4s, 8s, and so on). Cap at a small number of attempts (five or six). Every retry of a write operation **must reuse the same `Idempotency-Key`** as the original call. That guarantees that even if the previous attempt did commit server-side before the connection broke, the retry returns the cached successful response rather than creating a duplicate.

```python
import time, uuid, requests

def create_line_with_retry(base_url, token, body):
    idem = uuid.uuid4().hex
    for attempt in range(5):
        r = requests.post(
            f"{base_url}/panel-api/v1/lines",
            headers={
                "Authorization": f"Bearer {token}",
                "Idempotency-Key": idem,
                "Content-Type": "application/json",
            },
            json=body,
            timeout=30,
        )
        if r.status_code < 500:
            return r
        rid = (r.json() or {}).get("request_id", "(no request id)")
        print(f"[panel-api] 5xx on attempt {attempt+1}, request_id={rid}")
        time.sleep(2 ** attempt)
    raise RuntimeError(f"Panel API did not recover after 5 attempts (last request_id={rid})")
```

> [!IMPORTANT]
> Never retry a `POST` without an `Idempotency-Key`. If your original call did commit before the network dropped and you send a fresh request with a fresh key, you create a duplicate line, a duplicate reseller or a double charge on credits.

### 4xx errors

Never automatically retry a 4xx unless you have programmatically fixed the underlying cause.

- `validation_error`. Fix the body (using `details.field`), then retry with the **same** `Idempotency-Key`.
- `insufficient_credits`, `billing_expired`, `slot_limit_exceeded`, `ancestor_cap_reached`, `trial_quota_exceeded`. Escalate to your billing workflow. Do not retry until capacity is restored.
- `insufficient_scope`, `admin_only_endpoint`, `admin_only_field`. The call cannot succeed with the current key. Rewrite the call or use a different key.
- `invalid_key`, `caller_disabled`. The credential is dead. Stop retrying and rotate or re-issue.

## `X-Request-Id` and support

Every response carries an `X-Request-Id` header with a UUID v4, and the same value appears in the response body under `request_id`. The two are always identical.

We recommend logging the request id on **every** call, not just on errors. A fair number of support cases turn out to be "the request succeeded on our side and something changed later", and the id ties the two conversations together.

When contacting Xtream AI support, always include the following.

1. The `request_id` (from `X-Request-Id` or from the error body).
2. The exact HTTP status and error slug you received.
3. The rough timestamp (UTC) of the request, ideally within a minute.
4. The panel domain the call was made against.

With that information a support engineer can pull the full server-side context (auth path, handler decision, database queries) in under a minute. Without it, diagnosis can take hours.

## Never assume the wording of `message`

The `message` field is meant to be human-readable and may change without notice for any of the reasons below.

- Refinements to wording for clarity.
- Localization of certain error contexts in the future.
- Additional context appended when new details become available.

Your production code must branch exclusively on the `error` slug. The slugs listed on this page are the stable contract. They will not change without a version bump and prior announcement. New slugs may be added in the future, so treat any unknown slug as a generic failure (log the message and the request id, refuse to auto-retry) rather than crashing.

## See also

- [Panel API Overview](/docs/?page=panel-api-overview). How the three flavors fit together.
- [Authentication](/docs/?page=panel-api-authentication). How `invalid_key` and `caller_disabled` are triggered, and how to avoid them.
- [Rate limits and Idempotency](/docs/?page=panel-api-rate-limits-idempotency). Full contract behind `rate_limited`, `missing_idempotency_key`, `idempotency_conflict` and `idempotency_in_flight`.
- [Lines](/docs/?page=xai-ref-lines-list). Per-endpoint error tables for the lines resource.
- [Resellers](/docs/?page=xai-ref-resellers-list). Per-endpoint error tables for reseller and sub-reseller management.
