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.

The official 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

{
  "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.

$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);
    }
}
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. 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) 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 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 429s in quick succession, back off further so that your integration converges below the per-minute budget.

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")
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.

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.

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})")

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. How the three flavors fit together.
  • Authentication. How invalid_key and caller_disabled are triggered, and how to avoid them.
  • Rate limits and Idempotency. Full contract behind rate_limited, missing_idempotency_key, idempotency_conflict and idempotency_in_flight.
  • Lines. Per-endpoint error tables for the lines resource.
  • Resellers. Per-endpoint error tables for reseller and sub-reseller management.