Using the official SDKs? Every endpoint below is a one-line typed method call — the HTTP details on this page are handled for you.

Overview

Every Xtream AI panel is a tree of accounts. The admin sits at the root; resellers hang from the admin; sub-resellers hang from other resellers. Every account except the admin has an owner_id pointing to its parent, and every account can hold subscriber lines and, if its group permits, create accounts below it.

The Panel API exposes six endpoints for this tree:

  • GET /resellers and GET /resellers/{id} to browse the tree (admin only).
  • POST /resellers to create a reseller (admin) or a sub-reseller (reseller with the right group permission).
  • POST /resellers/{id}/update to patch profile fields such as username, email, or notes (admin only).
  • GET /resellers/{id}/billing and POST /resellers/{id}/billing/adjust to inspect and modify a reseller's spending budget (admin only).

All admin-only endpoints require the resellers:read or resellers:write scope, both of which are rejected at issuance time on reseller keys. Reseller keys can only reach POST /resellers, and only with the subresellers:write scope plus the panel-admin gate described below.

Billing modes: credits vs users

Every reseller runs in exactly one of two billing modes, decided by the admin when the reseller is created:

  • credits mode. The reseller has a credits balance. Each subscriber line they create costs credits (from the package's price). Credits can be topped up or clawed back at any time via POST /resellers/{id}/billing/adjust.
  • users mode. The reseller has a fixed max_users slot count. Lines are unmetered, but the count of active non-trial lines (plus the slots reserved by their own sub-resellers) cannot exceed max_users. In this mode you also track a billing_expires timestamp: past that moment, the reseller cannot create or renew lines.

Sub-resellers always inherit their parent's mode. A users-mode reseller can only produce users-mode sub-resellers, and each new sub-reseller reserves a chunk of the parent's slot budget. A credits-mode reseller pays a fixed per-creation price (configured by the panel admin on the reseller's group) from their own balance every time they create one.

Fractional credit balances (for example 0.35) are preserved on adjust. The API does the arithmetic with SQL-side credits + delta, not by reading a stale cached value.

The reseller tree

The admin sees the entire tree. When you call GET /resellers with an admin key, the list is flat but includes member_group_id, member_group_name, and everything you need to reconstruct the hierarchy in your own tooling.

Reseller keys have a hard boundary: they can GET /me to see their own identity, they can create sub-resellers under themselves via POST /resellers, and they can operate on the lines they and their descendants own. They cannot list the panel's resellers, cannot see any other reseller, and cannot re-parent an account they created. Any attempt to pass owner_id=<some-other-id> on a reseller key returns:

{
  "error": "owner_must_be_self",
  "message": "owner_id must be your own account; sub-resellers are always created under the caller.",
  "request_id": "req_..."
}

The reason is a slot-budget invariant: the API charges the caller for the slots the new account reserves, but only counts direct children when computing the caller's remaining capacity. If callers could pick an arbitrary owner, a reseller with 100 slots would park sub-resellers under their own children and fabricate unlimited capacity without ever consuming their own budget.

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. All reseller methods live under $client->resellers / client.resellers.

GET /resellers

Admin only. Returns a keyset-paginated list of every reseller under the panel, newest first by ID.

Query parameters:

Parameter Type Notes
limit int Clamp [1, 100], default 50.
cursor int Return rows with id > cursor. Read the next cursor from next_cursor in the previous page.
member_group_id int Filter by member group (for example, only sub-resellers of a specific tier).
status int Filter by account status (1 = active).
username string Exact username match. Useful when your billing system stores usernames and needs to look up the reseller before adjusting.
curl -H "Authorization: Bearer $TOKEN" \
     "https://<your-panel-domain>/panel-api/v1/resellers?limit=25"
$page = $client->resellers->list(limit: 50, status: true);
foreach ($page->items as $reseller) {
    echo $reseller->id, ' ', $reseller->username, ' ', $reseller->billing?->mode, PHP_EOL;
}
if ($page->nextCursor !== null) {
    echo 'next page cursor: ', $page->nextCursor, PHP_EOL;
}
page = client.resellers.list(limit=50, status=True)
for reseller in page.items:
    print(reseller.id, reseller.username,
          reseller.billing.mode if reseller.billing else None)
if page.next_cursor is not None:
    print("next page cursor:", page.next_cursor)

Response:

{
  "items": [
    {
      "id": 42,
      "username": "reseller_alice",
      "email": "alice@example.com",
      "member_group_id": 4,
      "member_group_name": "RESELLER",
      "status": 1,
      "billing_mode": "credits",
      "credits": 47.5,
      "max_users": 0,
      "active_users": null,
      "billing_expires": null,
      "created_at": 1712430022
    },
    {
      "id": 108,
      "username": "reseller_bob",
      "email": "bob@example.com",
      "member_group_id": 5,
      "member_group_name": "PARTNER",
      "status": 1,
      "billing_mode": "users",
      "credits": 0.0,
      "max_users": 500,
      "active_users": 312,
      "billing_expires": 1793308800
    }
  ],
  "next_cursor": 108
}

active_users is populated only for users-mode resellers. For credits-mode rows it is null. When next_cursor is null, you have reached the end of the list.

POST /resellers

Create a reseller (admin key) or a sub-reseller (reseller key with subresellers:write).

Required fields (all callers)

Field Type Notes
username string Minimum 3 characters. Cannot contain % & ? # / \ = + @ : ;.
password string Minimum 8 characters, same character restrictions as username.
email string Validated with FILTER_VALIDATE_EMAIL.

Admin-only fields

Field Type Notes
member_group_id int Must exist. Determines which group the new reseller belongs to.
credits float Starting credit balance. Optional, default 0.
billing_mode "credits" or "users" Optional, default "credits". Any other value falls back to "credits".
max_users int Required if billing_mode is "users", must be > 0.
billing_expires int (unix seconds) Optional expiration timestamp.
owner_id int Optional. Which existing reseller becomes the parent. Defaults to the panel admin.
notes string Free-form.

Reseller-key behavior

When a reseller key calls this endpoint, several server-side rules kick in regardless of what you send:

  • owner_id is forced to the caller's own ID. Sending any other value returns 403 owner_must_be_self.
  • member_group_id is forced to whatever the panel admin configured in the sub-reseller setup for the caller's group. Sending a different value has no effect.
  • credits is forced to 0.0. Give a sub-reseller an initial balance later via POST /resellers/{id}/billing/adjust, which enforces the caller's own budget.
  • billing_mode is inherited from the parent. A users-mode reseller can only create users-mode sub-resellers; a credits-mode reseller can only create credits-mode ones.

Additionally, the reseller's group must have the Can create sub-resellers permission enabled (an explicit toggle the panel admin sets per group), and the admin must have set up a sub-reseller package for the group. If either is missing:

{"error": "sub_reseller_creation_not_allowed", "message": "Your account is not allowed to create sub-resellers. Ask the panel administrator to enable it for your group.", "request_id": "req_..."}

or

{"error": "no_sub_reseller_setup", "message": "The panel administrator has not configured a sub-reseller package for your group.", "request_id": "req_..."}

Cost

  • Admin key: free. The row is inserted with whatever credits and max_users you passed.
  • Reseller key, credits mode parent: the per-creation price configured on the caller's group is deducted from their balance. If the balance is insufficient, 402 insufficient_credits.
  • Reseller key, users mode parent: free, but reserves max_users slots from the caller's budget. If the caller's max_users - (active_lines + reserved_slots_of_existing_sub_resellers) < requested_max_users, 422 insufficient_slots. In this mode, billing_expires is also required for the new sub-reseller.

The check and the insert run inside a single MySQL transaction with a SELECT ... FOR UPDATE on the parent row, so two concurrent creates cannot over-allocate.

Snippets

curl -H "Authorization: Bearer $TOKEN" \
     -H "Idempotency-Key: $(uuidgen)" \
     -H "Content-Type: application/json" \
     -d '{
           "username": "subreseller_carol",
           "password": "s3cret-p4ssword",
           "email": "carol@example.com",
           "billing_mode": "users",
           "max_users": 50,
           "billing_expires": 1793308800
         }' \
     https://<your-panel-domain>/panel-api/v1/resellers
$reseller = $client->resellers->create(
    username:       'subreseller_carol',
    password:       's3cret-p4ssword',
    email:          'carol@example.com',
    billingMode:    'users',
    maxUsers:       50,
    billingExpires: 1793308800,
);
echo $reseller->id, ' charged ', $reseller->creditsCharged, PHP_EOL;
reseller = client.resellers.create(
    username="subreseller_carol",
    password="s3cret-p4ssword",
    email="carol@example.com",
    billing_mode="users",
    max_users=50,
    billing_expires=1793308800,
)
print(reseller.id, reseller.credits_charged)

Response 201 Created:

{
  "id": 259,
  "username": "subreseller_carol",
  "email": "carol@example.com",
  "member_group_id": 6,
  "owner_id": 42,
  "credits_charged": 0.0,
  "billing": {
    "mode": "users",
    "credits": null,
    "max_users": 50,
    "active_users": 0,
    "billing_expires": 1793308800
  }
}

credits_charged reflects what the API deducted from the caller (only meaningful when a reseller key creates a credits-mode sub-reseller). For admin-key creates it is always 0.0.

GET /resellers/{id}

Admin only. Returns a single reseller with the billing snapshot embedded.

curl -H "Authorization: Bearer $TOKEN" \
     https://<your-panel-domain>/panel-api/v1/resellers/42
$reseller = $client->resellers->get(42);
echo $reseller->username, ' ', $reseller->billing?->credits, PHP_EOL;
reseller = client.resellers.get(42)
print(reseller.username, reseller.billing.credits if reseller.billing else None)

Response:

{
  "id": 42,
  "username": "reseller_alice",
  "email": "alice@example.com",
  "member_group_id": 4,
  "member_group_name": "RESELLER",
  "status": 1,
  "billing": {
    "mode": "credits",
    "credits": 47.5,
    "max_users": null,
    "active_users": null,
    "billing_expires": null
  }
}

If the reseller does not exist, the response is 404 not_found.

POST /resellers/{id}/update

Admin only. PATCH-style: only the fields you send change.

Editable fields: username, email, member_group_id, password (send an empty string to keep the current one), notes.

Preserved: owner_id, credits (with fractional precision), billing_mode, billing_expires, max_users, override_packages (per-package price overrides), default_lang, reseller_dns, pin_access, pin_code, badge_label, badge_color. None of these can be changed through this endpoint. Credit and slot changes go through POST /resellers/{id}/billing/adjust; re-parenting an account is a CMS-only operation because it needs the full tree view.

curl -H "Authorization: Bearer $TOKEN" \
     -H "Idempotency-Key: $(uuidgen)" \
     -H "Content-Type: application/json" \
     -d '{"email":"alice+billing@example.com","notes":"Contact via WhatsApp"}' \
     https://<your-panel-domain>/panel-api/v1/resellers/42/update
// update takes a map of the fields you want to change.
$reseller = $client->resellers->update(42, [
    'email' => 'alice+billing@example.com',
    'notes' => 'Contact via WhatsApp',
]);
echo $reseller->email, PHP_EOL;
# update takes a dict of the fields you want to change.
reseller = client.resellers.update(42, {
    "email": "alice+billing@example.com",
    "notes": "Contact via WhatsApp",
})
print(reseller.email)

Response 200 OK:

{
  "id": 42,
  "username": "reseller_alice",
  "email": "alice+billing@example.com",
  "member_group_id": 4,
  "billing": {
    "mode": "credits",
    "credits": 47.5,
    "max_users": null,
    "active_users": null,
    "billing_expires": null
  }
}

GET /resellers/{id}/billing

Admin only. Returns a compact billing snapshot with the same shape used everywhere else in the API.

curl -H "Authorization: Bearer $TOKEN" \
     https://<your-panel-domain>/panel-api/v1/resellers/108/billing
$billing = $client->resellers->billing(108);
echo $billing->mode, ' ', $billing->activeUsers, '/', $billing->maxUsers, PHP_EOL;
billing = client.resellers.billing(108)
print(billing.mode, billing.active_users, billing.max_users)

Response for a users-mode reseller:

{
  "mode": "users",
  "credits": null,
  "max_users": 500,
  "active_users": 312,
  "billing_expires": 1793308800
}

Response for a credits-mode reseller:

{
  "mode": "credits",
  "credits": 47.5,
  "max_users": null,
  "active_users": null,
  "billing_expires": null
}

active_users is always live: it recomputes count(active non-trial users) + sum(sub-reseller max_users) at read time, so it is never stale.

POST /resellers/{id}/billing/adjust

Admin only. Applies a signed delta to the reseller's credits (mode credits) or max_users (mode users). The API picks the right column based on the reseller's mode; you never pass the mode explicitly.

Field Type Notes
delta float Required. Positive to top up, negative to claw back. In users mode, cast to int server-side.
reason string Optional. Truncated to 100 characters and written to the reseller's audit log.

Validation

  • Credits mode, negative delta larger than the current balance. 422 negative_balance_not_allowed. The balance is untouched; no partial deduction happens.
  • Users mode, negative delta that would drop max_users below the currently used slot count. 422 cap_below_active_users, with the current active count in the message. No lines are auto-disabled to make room; you must free the slots first.
  • Users mode, negative delta that would take max_users below zero. 422 negative_cap_not_allowed.

The adjustment runs as a single atomic relative update on the underlying row (credits = credits + delta, or the max_users equivalent). Two concurrent adjustments sum instead of racing, and neither reads through the caching layer that other endpoints use for the reseller state.

Every successful adjustment is written to the reseller's audit log with the type panel_api_billing_adjust, so it appears in the same audit trail as CMS-driven changes.

curl -H "Authorization: Bearer $TOKEN" \
     -H "Idempotency-Key: $(uuidgen)" \
     -H "Content-Type: application/json" \
     -d '{"delta":25.5,"reason":"Monthly top-up invoice #2411"}' \
     https://<your-panel-domain>/panel-api/v1/resellers/42/billing/adjust
$billing = $client->resellers->adjustBilling(
    id:     42,
    delta:  25.5,               // positive to top up, negative to claw back
    reason: 'Monthly top-up invoice #2411',
);
echo $billing->credits, PHP_EOL;
billing = client.resellers.adjust_billing(
    id=42,
    delta=25.5,                 # positive to top up, negative to claw back
    reason="Monthly top-up invoice #2411",
)
print(billing.credits)

Response 200 OK returns the post-adjustment billing snapshot:

{
  "mode": "credits",
  "credits": 73.0,
  "max_users": null,
  "active_users": null,
  "billing_expires": null
}

Fractional credits are preserved. Adding 0.10 to a balance of 0.25 produces 0.35, not 0. Do not floor or round in your own code before sending the delta.

Recipe: provisioning a new sub-reseller

The end-to-end flow when a reseller wants to spin up a sub-reseller for one of their own dealers.

One-time setup by the panel admin (from the CMS, not the API):

  1. Open Settings, Reseller Groups, pick the group your reseller belongs to.
  2. Turn on Can create sub-resellers.
  3. Configure the sub-reseller package: which target member group new sub-resellers will land in, and if the parent is on credits mode, the price to charge per creation.

Once that is done, the reseller can create sub-resellers with a single API call.

From the reseller's integration:

# `client` is built with the reseller's own key, as shown on the SDKs page.

# 1. Confirm the caller has the permission.
me = client.me.get()
assert me.type == "reseller"
assert me.permissions["create_sub_resellers"] is True

# 2. Create the sub-reseller. billing_mode is inherited from the parent,
#    so we do not send it. owner_id defaults to self.
kwargs = dict(
    username="dealer_dan",
    password="T3mp-p4ss-change-me",
    email="dan@dealer.example",
    notes="Referred by campaign Q3-launch",
)

# In users-mode inheritance, billing_expires is required for the child.
if me.billing is not None and me.billing.mode == "users":
    kwargs["max_users"] = 25
    kwargs["billing_expires"] = 1793308800

created = client.resellers.create(**kwargs)
print(f"Created sub-reseller {created.id} with {created.credits_charged} credits deducted")

If the reseller's group is not opted in, the response is 403 sub_reseller_creation_not_allowed. If the admin never configured the sub-reseller package, it is 422 no_sub_reseller_setup. Neither error is a bug; both are pointing at a missing configuration step on the panel side.

Recipe: reconciling monthly billing

A common back-office job: iterate every reseller, snapshot their balances, and apply this month's credit adjustments from an external billing system.

import time
from xtream_ai_panel_api.exceptions import ValidationException

# `client` is built with an admin key, as shown on the SDKs page.


def all_resellers():
    """Yield every reseller across pages until next_cursor is None."""
    page = client.resellers.list(limit=100)
    while True:
        yield from page.items
        if page.next_cursor is None:
            return
        page = client.resellers.list(cursor=page.next_cursor, limit=100)


# 1. Pull the current state of every reseller.
snapshot = {row.id: row for row in all_resellers()}
print(f"Fetched {len(snapshot)} resellers")

# 2. Compute deltas from your billing system. Example: give every active
#    credits-mode reseller +10 credits, and claw back all trial balances that
#    expired more than 90 days ago.
adjustments = []
now = int(time.time())
ninety_days = 90 * 86400

for reseller_id, row in snapshot.items():
    if not row.status:
        continue
    if row.billing is not None and row.billing.mode == "credits":
        adjustments.append((reseller_id, 10.0, "Monthly refresh"))
    expires = row.billing.billing_expires if row.billing else None
    if expires is not None and expires.timestamp() < now - ninety_days:
        # Trigger a manual review, do not auto-modify anything.
        print(f"Reseller {reseller_id} expired long ago, flag for review")

# 3. Apply the adjustments, one at a time, with an idempotency key derived from
#    the invoice ID so a retry after a timeout does not double-charge.
for reseller_id, delta, reason in adjustments:
    try:
        billing = client.resellers.adjust_billing(
            reseller_id, delta, reason,
            idempotency_key=f"monthly-2026-08-{reseller_id}",
        )
    except ValidationException as e:
        print(f"Skipping {reseller_id}: {e.slug}")
        continue
    print(f"Reseller {reseller_id}: new balance {billing.credits}")

Two properties of this pattern are worth calling out:

  • The Idempotency-Key is a function of the invoice, not a random UUID. If the run crashes halfway and you rerun it, the API replays the cached response for every already-applied delta instead of stacking a second charge on top. See Rate limits & Idempotency for the full contract.
  • A 422 on adjust is business logic, not a bug. negative_balance_not_allowed and cap_below_active_users are the API refusing to leave the reseller in an inconsistent state. Log and move on; do not retry.

See also

  • Panel API Overview for the API's scope, dialects, and getting started.
  • Authentication for scopes (resellers:read, resellers:write, subresellers:write) and how they map to admin vs reseller keys.
  • Rate limits & Idempotency for the retry contract that the recipes above rely on.
  • Lines for the subscriber lines a reseller sells to end users, with billing rules that consume the credits and slots documented here.
  • Errors for the full slug reference, including every code emitted by the reseller endpoints.