---
title: "Panel API Resellers"
description: "Manage the reseller and sub-reseller tree of an Xtream AI panel: create sub-resellers, patch profile fields, view billing state, and apply credit or user-slot adjustments through the Panel API."
---

> [!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.

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

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

> [!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. 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. |

```bash
curl -H "Authorization: Bearer $TOKEN" \
     "https://<your-panel-domain>/panel-api/v1/resellers?limit=25"
```

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

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

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

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

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

```bash
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
```

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

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

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

```bash
curl -H "Authorization: Bearer $TOKEN" \
     https://<your-panel-domain>/panel-api/v1/resellers/42
```

```php
$reseller = $client->resellers->get(42);
echo $reseller->username, ' ', $reseller->billing?->credits, PHP_EOL;
```

```python
reseller = client.resellers.get(42)
print(reseller.username, reseller.billing.credits if reseller.billing else None)
```

Response:

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

```bash
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
```

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

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

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

```bash
curl -H "Authorization: Bearer $TOKEN" \
     https://<your-panel-domain>/panel-api/v1/resellers/108/billing
```

```php
$billing = $client->resellers->billing(108);
echo $billing->mode, ' ', $billing->activeUsers, '/', $billing->maxUsers, PHP_EOL;
```

```python
billing = client.resellers.billing(108)
print(billing.mode, billing.active_users, billing.max_users)
```

Response for a `users`-mode reseller:

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

Response for a `credits`-mode reseller:

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

```bash
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
```

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

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

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

> [!IMPORTANT]
> 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:**

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

```python
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](/docs/?page=panel-api-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](/docs/?page=panel-api-overview) for the API's scope, dialects, and getting started.
- [Authentication](/docs/?page=panel-api-authentication) for scopes (`resellers:read`, `resellers:write`, `subresellers:write`) and how they map to admin vs reseller keys.
- [Rate limits & Idempotency](/docs/?page=panel-api-rate-limits-idempotency) for the retry contract that the recipes above rely on.
- [Lines](/docs/?page=panel-api-lines) for the subscriber lines a reseller sells to end users, with billing rules that consume the credits and slots documented here.
- [Errors](/docs/?page=panel-api-errors) for the full slug reference, including every code emitted by the reseller endpoints.
