What this page is

This page is a cookbook. Each section below is a full working script. You paste it into a file, change the handful of values listed at the top, and run it. Every recipe covers one clear business job. That job is described in plain terms first, then the code is shown, then the expected output, then the things that can go wrong and what to do about each one. At the end of every recipe there is a raw HTTP version for readers who cannot install a library.

The page assumes you already worked through the Quickstart, so you know how to install the SDK, how to issue a key in the panel, and how the token gets passed on every call. If any of that is unfamiliar, read the Quickstart first. You will come back here to pick a recipe once your first call is in place. The scripts are meant to be starting points. They are short on purpose so that you can read them top to bottom before you run them.

The recipes are ordered by the arc of a subscription business. Provisioning a new line comes first, because it is what happens when you land a customer. Renewals come next, because that is the second most common event. Suspending and re-enabling on payment status comes third, because it happens the moment a customer misses a payment. Then reconciliation, which closes the accounting loop for wholesale-style resellers. Then the self-service signup portal, for readers who want to sell resell accounts through a public checkout. Then the one-shot import, for readers moving off another panel. Finally, the safe-retry helper, which is not a recipe but a library to lean on if you cannot use the SDK.

You do not need to read them in order. Every recipe is self-contained. Skip straight to the one your business needs today, run it against a test line, then come back and skim the rest so that you know what tools are available when you need them. Reading the whole page end to end takes about twenty minutes, and it will save you hours the first time a new business requirement comes in.

Every code sample in this page has been run against the same live harness we use internally to verify the SDKs before every release. If a call in a recipe were wrong, our own tests would fail. That said, the recipes are not a substitute for reading the SDK reference, which lists every optional parameter and every response field. Use the cookbook to start. Use the reference when you outgrow the cookbook.

Every recipe below imports the SDK the same way. If you installed with Composer, keep the line require 'vendor/autoload.php'; as shown. If you dropped the SDK folder onto your server without Composer, use require __DIR__ . '/api-panel-php-sdk-1.0.0/autoload.php'; in its place. The Python recipes use pip install xtream-ai-panel-api and do not need an import file switch.

The three patterns you will see over and over

Before you dive into any single recipe, it is worth naming the three patterns that show up in most of them. These are the shape of a production-quality integration, and once you recognize them you will spot them in every script on this page.

The first pattern is the receipt number. Every write to the panel accepts a small string that identifies the business event behind the call. You build it from the reference your billing system already has (an invoice number, a payment intent id, an order number). When you call the panel twice with the same receipt for the same event, the panel notices and returns the first answer again instead of doing the work a second time. This is what lets a webhook that fires three times still create only one line. The recipes always show you how to build the receipt for each specific event, but the underlying pattern is always the same. One event on your side, one receipt, one effect on the panel side.

The second pattern is the narrow error handling. The recipes never catch a broad Exception. They catch the specific errors the SDK raises for the specific things that can go wrong, and they handle each one with a message the caller can act on. InsufficientCreditsException means top up the account. ValidationException with field=username means try another username. NotFoundException means check the id. This is more code than a blanket try/except, and it is more code on purpose. A production integration should tell your on-call engineer what happened, not just that something failed. If you find yourself writing a blanket catch, stop and add the specific paths one by one until every expected error has its own answer.

The third pattern is the paced loop. Some recipes walk many resellers or many rows in a CSV. Every one of them adds a tiny sleep between iterations. The panel enforces a per-key request budget and will start refusing calls with 429 responses when you cross it. Sleeping two hundred milliseconds between calls keeps you comfortably under the default budget while still finishing a large batch in a reasonable time. If you have a special reason to go faster, ask us to raise your key's limit before you rewrite the pacing.

A fourth pattern is worth mentioning even though only a handful of recipes use it. Some recipes read a value from the panel first (the current billing snapshot, the package catalog) before they write. That read is not a check-and-act pattern that races other writers. It is a lookup, followed by an unconditional write that the panel serializes on its side. If you find yourself thinking "I need to lock this record before I change it", you are overthinking the problem. The panel already serializes writes against the account they affect. Read what you need to compute the payload, then write. That is all.

A fifth pattern shows up in the way the recipes surface errors. Every script prints a clear message and exits with a nonzero status when it cannot do its job. That is how command-line scripts communicate with cron and with the rest of the shell. If you paste a recipe into a web handler, replace the exit(1) with a return that puts a 500 on the wire (or a 409 when it is a receipt collision, or a 402 when the customer needs to top up) and the same rigor still buys you clean logs and clear responses to your billing system.

Recipe 1. Provisioning a new customer line from a billing webhook

Your billing system (WHMCS, Blesta, Stripe, or a homegrown checkout) receives a payment and calls a webhook on your server. This recipe is the code you run inside that webhook. It creates the customer's line in the panel and returns the credentials you send back to them. The same script is safe to call five times if the webhook fires five times. Only one line is ever created.

You only change these things:

  • <your-panel-domain> and <your-api-key>.
  • invoice. The invoice number your billing system passes to the webhook.
  • packageName. The exact name of the package the customer bought.
  • memberId. The panel account the line belongs to. If your key is a reseller key, delete this value. Reseller keys always create lines under their own account.
  • baseUser and email. The username the customer asked for and the email address on the order.

Notice that no password is set. The panel picks a strong one and returns it in the response, which keeps you out of the business of generating passwords and out of the risk of writing weak ones.

How does it work. The recipe does two API calls. The first pulls the package catalog so you can look the package up by its printed name. Storing package IDs directly in your billing system works, but names are easier to read at 3am when a payment does not go through. The second call creates the line, tagged with a receipt number built from the invoice. That receipt number is the important detail. Billing systems retry webhooks when your server is slow, and every retry sends the exact same invoice again. The panel remembers the receipt number and, on the second call, returns the answer it already gave the first time. One paid invoice, one line, no duplicates.

The retry loop in the code handles the one case the panel cannot decide for you, which is what to do when the username the customer asked for is already taken. The loop tries johndoe, then johndoe2, then johndoe3, and stops when the panel accepts one. Each retry uses its own receipt number so that the panel treats it as a fresh attempt. If you skip the loop and only try the base username, the very first customer with a common name will hit a validation error and your webhook will fail. Adding four or five fallbacks costs almost nothing and covers the vast majority of collisions. If even the fifth attempt fails, the script exits with a clear error so that your billing system can flag the invoice for human review.

<?php
// provision_from_invoice.php
require 'vendor/autoload.php';

use XtreamAI\PanelApi\PanelApiClient;
use XtreamAI\PanelApi\Exceptions\ValidationException;
use XtreamAI\PanelApi\Exceptions\InsufficientCreditsException;

$client = new PanelApiClient(
    baseUrl: 'https://<your-panel-domain>',
    token:   '<your-api-key>',
);

$invoice     = 'INV-2026-00814';           // invoice number from your billing system
$packageName = 'Basic 12mo';               // package the customer bought
$memberId    = 260595;                     // panel account the line belongs to
$baseUser    = 'johndoe';                  // username the customer asked for
$email       = 'john@example.com';         // customer email

// 1. Find the package by name.
$package = null;
foreach ($client->catalog->packages() as $p) {
    if ($p->packageName === $packageName) {
        $package = $p;
        break;
    }
}
if ($package === null) {
    fwrite(STDERR, "No package called $packageName\n");
    exit(1);
}

// 2. Create the line. If the username is taken, try johndoe2, johndoe3...
$line = null;
for ($try = 1; $try <= 5; $try++) {
    $username = $try === 1 ? $baseUser : $baseUser . $try;
    $receipt  = $try === 1 ? "invoice-$invoice" : "invoice-$invoice-$try";
    try {
        $line = $client->lines->create(
            packageId:      $package->id,
            memberId:       $memberId,
            username:       $username,
            email:          $email,
            notes:          $invoice,
            idempotencyKey: $receipt,   // receipt number for this charge
        );
        break;
    } catch (ValidationException $e) {
        if ($e->field() !== 'username') {
            throw $e;                   // something else is wrong, stop here
        }
    } catch (InsufficientCreditsException $e) {
        fwrite(STDERR, "The account has no credits left.\n");
        exit(1);
    }
}
if ($line === null) {
    fwrite(STDERR, "Every username was taken. Ask the customer for another one.\n");
    exit(1);
}

// 3. Send these to your customer.
printf("Line %d created. Username: %s Password: %s Expires: %s\n",
    $line->id, $line->username, $line->password,
    $line->expDate?->format('Y-m-d H:i') ?? 'never');
# provision_from_invoice.py
from xtream_ai_panel_api import PanelApiClient
from xtream_ai_panel_api.exceptions import (
    ValidationException, InsufficientCreditsException,
)

client = PanelApiClient(
    base_url="https://<your-panel-domain>",
    token="<your-api-key>",
)

INVOICE      = "INV-2026-00814"       # invoice number from your billing system
PACKAGE_NAME = "Basic 12mo"           # package the customer bought
MEMBER_ID    = 260595                 # panel account the line belongs to
BASE_USER    = "johndoe"              # username the customer asked for
EMAIL        = "john@example.com"     # customer email

# 1. Find the package by name.
package = next((p for p in client.catalog.packages()
                if p.package_name == PACKAGE_NAME), None)
if package is None:
    raise SystemExit(f"No package called {PACKAGE_NAME}")

# 2. Create the line. If the username is taken, try johndoe2, johndoe3...
line = None
for attempt in range(1, 6):
    username = BASE_USER if attempt == 1 else f"{BASE_USER}{attempt}"
    receipt = f"invoice-{INVOICE}" if attempt == 1 else f"invoice-{INVOICE}-{attempt}"
    try:
        line = client.lines.create(
            package_id=package.id,
            member_id=MEMBER_ID,
            username=username,
            email=EMAIL,
            notes=INVOICE,
            idempotency_key=receipt,   # receipt number for this charge
        )
        break
    except ValidationException as e:
        if e.field != "username":
            raise                      # something else is wrong, stop here
    except InsufficientCreditsException:
        raise SystemExit("The account has no credits left.")

if line is None:
    raise SystemExit("Every username was taken. Ask the customer for another one.")

# 3. Send these to your customer.
print(f"Line {line.id} created. Username: {line.username} "
      f"Password: {line.password} Expires: {line.exp_date}")

You should see:

Line 172511964 created. Username: johndoe Password: 15466dc3 Expires: 2026-09-08 04:45

The line id in the output (172511964 in this example) is the number you keep in your billing system so that future renewals, suspensions, or the reset-password call can find this line. Save it alongside the invoice number. The username the customer picked is not a durable lookup key. The customer can change their password. The line id never changes.

If your billing system does not have a place to store the line id today, add one before you deploy this recipe. Retrofitting it later means running a cross-join against the panel's line list, which is doable but tedious and error-prone. A single integer column in your subscription table saves a lot of grief.

The receipt number in the output message on your side is invisible. It lives only in the header the SDK sends. But the panel keeps it in a small internal ledger for twenty-four hours. If your billing system fires the same invoice again during that window, the panel will match on the receipt, return the exact response it returned the first time, and skip creating a second line.

The password you print in the output is the one the customer needs to configure their app. Copy it into your outbound email template, then discard it. The panel does not store it anywhere you can read back later. If a customer forgets their password, use the reset-password endpoint ($client->lines->resetPassword($lineId)), which generates a new one and returns it in the response.

When it goes wrong:

What you see What it means What to do
InsufficientCreditsException The account paying for the line ran out of credits or free slots. Top up the account, then run the script again with the same values.
ValidationException, field member_id The account id does not exist, or you left it out while using an admin key. Put a real account id in memberId. Reseller keys must not send it at all.
AuthorizationException, slug admin_only_field You sent memberId with a reseller key. Delete the memberId line. Reseller keys always create lines under their own account.
ConflictException The same receipt number was reused with different customer details. Give every charge its own receipt number. Keep the details identical when you retry the same charge.

Raw HTTP version

curl -H "Authorization: Bearer $TOKEN" \
     https://<your-panel-domain>/panel-api/v1/packages

curl -X POST \
     -H "Authorization: Bearer $TOKEN" \
     -H "Idempotency-Key: invoice_INV-2026-00814" \
     -H "Content-Type: application/json" \
     -d '{"package_id":7,"username":"johndoe","password":"s3cret!","email":"john@example.com","notes":"INV-2026-00814"}' \
     https://<your-panel-domain>/panel-api/v1/lines

The Idempotency-Key must be stable across webhook retries. Deriving it from the invoice number (or from the Stripe payment intent id) guarantees that a webhook that fires three times still produces one line. Never use time(), uuid.uuid4() at retry time, or a random value regenerated on retry.

The package catalog does not change often. If your webhook handler makes many line creations in a row (say, an admin recovering from an outage by replaying a queue of missed webhooks) it is worth caching the catalog result for a minute or two in your handler process. The recipe does not do this on purpose, because a single-line webhook is not worth the caching complexity, but a batch of hundreds of lines will save a noticeable number of round trips.

Recipe 2. Automatic renewal on subscription renewal

Your billing system charges a recurring payment. The customer's line needs more time on the panel. This recipe is the code your renewal webhook runs. The panel adds the package duration to the date the line already had, so an early renewal never loses paid days. If the invoice is for three months and the line still had two weeks left, the new end date is the old end date plus three months.

You only change these things:

  • <your-panel-domain> and <your-api-key>.
  • lineId. The line you are renewing.
  • packageId. The package the customer pays for. A free-trial package cannot be used to renew, and the panel will refuse if you try.
  • invoice. The invoice number of this payment.

How does it work. A renewal is a single POST. The endpoint accepts the line id in the URL, the package id in the body, and a receipt number in the header. The receipt number should describe the charge, not the line, because the same line will be renewed many times over the years and each renewal needs its own receipt. A pattern that works is renew-<invoice>, which pairs each panel renewal to exactly one payment in your billing system.

The renewal never returns the amount charged. The panel does the internal accounting against the reseller balance and gives you back the line with the new end date so you can write that date back to your billing system. Handling the four expected errors on their own paths is what turns this into production code. The script prints a clear message for each one instead of letting a stack trace reach the customer. NotFoundException should never happen in a healthy integration, but it does happen when a customer cancels their line in your billing UI and the cancellation event races the renewal event. InsufficientCreditsException is the one to route to your on-call channel because it usually means the reseller's own subscription ran out and the whole downstream stack is affected.

<?php
// renew_line.php
require 'vendor/autoload.php';

use XtreamAI\PanelApi\PanelApiClient;
use XtreamAI\PanelApi\Exceptions\ValidationException;
use XtreamAI\PanelApi\Exceptions\InsufficientCreditsException;
use XtreamAI\PanelApi\Exceptions\NotFoundException;

$client = new PanelApiClient(
    baseUrl: 'https://<your-panel-domain>',
    token:   '<your-api-key>',
);

$lineId    = 12345;                  // line to renew
$packageId = 7;                      // package the customer pays for
$invoice   = 'INV-2026-01542';       // invoice number of this payment

try {
    $line = $client->lines->renew(
        id:             $lineId,
        packageId:      $packageId,
        idempotencyKey: "renew-$invoice",   // receipt number for this charge
    );
} catch (NotFoundException $e) {
    fwrite(STDERR, "No line with id $lineId on this account.\n");
    exit(1);
} catch (InsufficientCreditsException $e) {
    fwrite(STDERR, "The account has no credits left.\n");
    exit(1);
} catch (ValidationException $e) {
    fwrite(STDERR, "The panel refused the renewal: {$e->slug}\n");
    exit(1);
}

printf("Line %d now expires %s\n",
    $line->id, $line->expDate?->format('Y-m-d H:i') ?? 'never');

// Write the new date back to your billing system here.
# renew_line.py
from xtream_ai_panel_api import PanelApiClient
from xtream_ai_panel_api.exceptions import (
    ValidationException, InsufficientCreditsException, NotFoundException,
)

client = PanelApiClient(
    base_url="https://<your-panel-domain>",
    token="<your-api-key>",
)

LINE_ID    = 12345                 # line to renew
PACKAGE_ID = 7                     # package the customer pays for
INVOICE    = "INV-2026-01542"      # invoice number of this payment

try:
    line = client.lines.renew(
        id=LINE_ID,
        package_id=PACKAGE_ID,
        idempotency_key=f"renew-{INVOICE}",   # receipt number for this charge
    )
except NotFoundException:
    raise SystemExit(f"No line with id {LINE_ID} on this account.")
except InsufficientCreditsException:
    raise SystemExit("The account has no credits left.")
except ValidationException as e:
    raise SystemExit(f"The panel refused the renewal: {e.slug}")

print(f"Line {line.id} now expires {line.exp_date}")

# Write the new date back to your billing system here.

You should see:

Line 12345 now expires 2026-10-08 04:45

The new date is what you write back to your billing system so that next month's dunning check knows when to bill again. If your billing already tracks the paid-through date from its own records, cross-check the two after the renewal completes. A mismatch means one of the two systems drifted, and it is easier to catch the drift on the day it happens than a month later when a customer complains.

Every renewal is charged once. Repeat the call with the same receipt number and the panel will hand back the same new date without moving the line forward a second time. That is how you can call this endpoint from a webhook without adding your own locking or your own database to track which invoices you already processed.

If the customer changes their plan at renewal time (moving from a one-month package to a twelve-month one, for example) you pass the new package id. The panel adds the new package duration to the current end date. It does not prorate the days left on the old plan. If your business model prorates, you handle the math on your side before you call the API and adjust the reseller balance separately with Recipe 4.

If you want to see what the customer paid for on the panel side, list the line's connections after a renewal with $client->lines->connections($lineId). The response includes the current active sessions and the last-known IP the customer connected from. That data is useful for support (confirming that the customer's app connected successfully after a renewal) but is not needed for the renewal itself. Fetch it only when you have a specific reason.

When it goes wrong:

What you see What it means What to do
ValidationException, slug renew_with_trial_package_not_allowed packageId points at a free-trial package. Use the id of the paid package the customer bought.
ValidationException, slug line_has_no_expiry This line never expires, so there is nothing to extend. Leave the line alone. Renewing it would give it an end date.
InsufficientCreditsException The account has no credits, or its own subscription is over. Top up the account, then run the script again.
NotFoundException Wrong line id, or the line belongs to another account. Check the id. A reseller key only sees its own lines.

Raw HTTP version

curl -X POST \
     -H "Authorization: Bearer $TOKEN" \
     -H "Idempotency-Key: renew_INV-2026-01542" \
     -H "Content-Type: application/json" \
     -d '{"package_id":7}' \
     https://<your-panel-domain>/panel-api/v1/lines/12345/renew

Recipe 3. Suspending on non-payment and re-enabling on payment

An invoice goes overdue. You want to shut the customer's line off without deleting it, so a late payment brings the line back exactly as it was. Same username, same password, same channel list, no reconfiguration needed on the customer's app. When the payment arrives, you switch the line back on with a single call. This recipe has two halves. Run the first when the invoice tips overdue, run the second when the money arrives.

You only change these things:

  • <your-panel-domain> and <your-api-key>.
  • lineId. The line to switch off or back on.
  • invoice. The unpaid invoice, used to build both receipt numbers.

How does it work. Suspending is not deleting. The line stays in the database with all its settings and its channel list intact. The only thing that changes is a single boolean. When the line is suspended, the streaming server refuses to serve it, and the customer's app shows a login error. When you enable the line again, the streaming server accepts it on the next request. The customer never opens the app settings, never re-enters credentials, and never notices anything except the streaming coming back.

The recipe passes a different receipt number for each half (suspend-<invoice> and enable-<invoice>-late) because the two operations are separate charges in your accounting sense, even though the panel does not charge credits for a suspend or an enable. Using distinct receipts also prevents a scenario where a delayed webhook for the enable arrives while the suspend is still fresh in the panel's memory. Two separate receipts mean two separate ledger entries, and neither will collide with the other. This is worth spelling out because it is a mistake beginners often make. If both operations shared the same receipt, the second call would find a matching receipt in the panel's ledger and return the answer from the first call. The line would stay in whatever state the first call left it. Distinct receipts per operation, always.

<?php
// suspend_or_enable.php
require 'vendor/autoload.php';

use XtreamAI\PanelApi\PanelApiClient;
use XtreamAI\PanelApi\Exceptions\InsufficientCreditsException;
use XtreamAI\PanelApi\Exceptions\NotFoundException;

$client = new PanelApiClient(
    baseUrl: 'https://<your-panel-domain>',
    token:   '<your-api-key>',
);

$lineId  = 12345;                   // line to switch off or back on
$invoice = 'INV-2026-01542';        // the unpaid invoice

// The invoice is overdue: switch the line off.
try {
    $line = $client->lines->disable(
        id:             $lineId,
        idempotencyKey: "suspend-$invoice",   // receipt number for this charge
    );
    printf("Line %d suspended (enabled=%s)\n", $line->id, var_export($line->enabled, true));
} catch (NotFoundException $e) {
    fwrite(STDERR, "No line with id $lineId on this account.\n");
    exit(1);
}

// The customer paid: switch the line back on.
try {
    $line = $client->lines->enable(
        id:             $lineId,
        idempotencyKey: "enable-$invoice-late",   // receipt number for this charge
    );
    printf("Line %d back on (enabled=%s)\n", $line->id, var_export($line->enabled, true));
} catch (InsufficientCreditsException $e) {
    fwrite(STDERR, "The account has no free slots to switch this line back on.\n");
    exit(1);
}
# suspend_or_enable.py
from xtream_ai_panel_api import PanelApiClient
from xtream_ai_panel_api.exceptions import (
    InsufficientCreditsException, NotFoundException,
)

client = PanelApiClient(
    base_url="https://<your-panel-domain>",
    token="<your-api-key>",
)

LINE_ID = 12345                  # line to switch off or back on
INVOICE = "INV-2026-01542"       # the unpaid invoice

# The invoice is overdue: switch the line off.
try:
    line = client.lines.disable(
        id=LINE_ID,
        idempotency_key=f"suspend-{INVOICE}",   # receipt number for this charge
    )
    print(f"Line {line.id} suspended (enabled={line.enabled})")
except NotFoundException:
    raise SystemExit(f"No line with id {LINE_ID} on this account.")

# The customer paid: switch the line back on.
try:
    line = client.lines.enable(
        id=LINE_ID,
        idempotency_key=f"enable-{INVOICE}-late",   # receipt number for this charge
    )
    print(f"Line {line.id} back on (enabled={line.enabled})")
except InsufficientCreditsException:
    raise SystemExit("The account has no free slots to switch this line back on.")

You should see:

Line 12345 suspended (enabled=false)
Line 12345 back on (enabled=true)

Notice that the enabled flag in the response is the source of truth. Do not infer state from whether the call raised an error. A successful call always returns the new state of the line, and you should read that state and store it (or log it) rather than assume the call did what you asked. This defensive habit costs one line of code and saves entire debugging sessions when an unrelated future change alters the response shape.

When it goes wrong:

What you see What it means What to do
NotFoundException Wrong line id, or the line belongs to another account. Check the id. A reseller key only sees its own lines.
InsufficientCreditsException on enable The account is sold by slots and has none free right now. Free a slot or raise the account's limit, then enable again.
Nothing changes and you get the old answer back You reused a receipt number the panel already answered, so it repeated that answer instead of acting. Use a fresh receipt number every time you really want the panel to act. For example suspend-<invoice>-august.

Suspending never deletes anything. The username, the password and the channel list all stay exactly as they were, so the customer's app keeps working the moment you enable the line again.

Some businesses prefer to send a warning email before the suspend, to give the customer one last chance to pay. You can build that on top of Recipe 3 with a two-day grace between the overdue notice and the actual disable call. The panel does not send any emails on its own. All customer communication is your business's responsibility, so a grace window is a decision you make in your billing system, not one the API imposes.

Raw HTTP version

# Suspend
curl -X POST \
     -H "Authorization: Bearer $TOKEN" \
     -H "Idempotency-Key: suspend_INV-2026-01542" \
     https://<your-panel-domain>/panel-api/v1/lines/12345/disable

# Re-enable
curl -X POST \
     -H "Authorization: Bearer $TOKEN" \
     -H "Idempotency-Key: enable_INV-2026-01542-late" \
     https://<your-panel-domain>/panel-api/v1/lines/12345/enable

Recipe 4. Reconciling reseller balances at end-of-month

Once a month you want to charge every reseller for what they sold over the past month. This recipe walks every reseller under your admin account, asks your own billing database how many lines each one sold, and takes that amount off their balance in the panel. Run it as a scheduled job on the first of the month. Rerun it as often as you want during the same month. The panel remembers the receipt numbers you used and will refuse to charge the same month twice for the same reseller.

You only change these things:

  • <your-panel-domain> and <your-api-key>. This recipe needs an admin key.
  • month. The month you are charging for. It goes into every receipt number and keeps them unique.
  • unitPrice. What you charge per line at wholesale.
  • linesSoldBy(). Replace the fake lookup with a query against your own billing database.
  • The admin key needs the resellers:write scope ticked.

How does it work. The recipe walks the resellers page by page. Each page has up to fifty items and returns a small token that points at the next page. When the token is empty, you have reached the end. For every reseller it reads the current balance snapshot, computes what they owe based on your own records, and posts a single balance change. The receipt number is reconcile-<month>-<reseller_id>. That naming does two things at once. First, it lets you rerun the script without fear of double-charging any reseller who was already processed on a previous run. Second, it makes the ledger inside the panel easy to read at audit time, because each line in the ledger includes the receipt as a reason field.

The delta in the call is a signed number. Negative takes money off the balance. Positive puts money on. The panel applies the change atomically against the live balance, so a sale a reseller makes while your script is running is never overwritten. This matters at scale. A reseller who sells a line at the exact moment your reconciliation charges them will not lose that sale to a race condition. Your charge lands on the balance after the sale, or before the sale, and the arithmetic is right either way. You never see a corrupted number and you never lose a transaction, even with a hundred resellers all trading at the same time.

The paged walk is deliberately slow. Fifty resellers per page is well under the panel's page limit, and the loop does not need to sleep between pages because the amount of work per page is already big enough to pace naturally. If you have thousands of resellers and want the script to finish faster, raise the limit to 200 (the maximum) and the total number of API calls goes down by four. Do not raise it past 200. The panel clamps the value and larger requests return the same fifty rows plus a next-page token, wasting the round trip.

If your reseller tree is deep (partners that have sub-partners of their own), the list endpoint only returns the direct children of the account tied to the admin key. To reach the whole tree, filter each reseller's own children with the owner_id in the response and recurse. Most integrations do not need this. If yours does, keep the recursion shallow and consider caching the tree structure on your side.

<?php
// month_end_reconcile.php
require 'vendor/autoload.php';

use XtreamAI\PanelApi\PanelApiClient;
use XtreamAI\PanelApi\Exceptions\ValidationException;

$client = new PanelApiClient(
    baseUrl: 'https://<your-panel-domain>',
    token:   '<your-api-key>',      // admin key
);

$month     = '2026-07';             // month you are charging for
$unitPrice = 1.00;                  // what you charge per line

// Replace this with a query against your own billing database.
function linesSoldBy(int $resellerId, string $month): int {
    return [315 => 47, 316 => 12][$resellerId] ?? 0;
}

// Walk every reseller, one page at a time.
$page = null;
do {
    $page = $client->resellers->list(limit: 50, cursor: $page?->nextCursor);

    foreach ($page->items as $reseller) {
        $sold = linesSoldBy($reseller->id, $month);
        if ($sold === 0) {
            continue;
        }

        $before = $client->resellers->billing(id: $reseller->id);
        printf("reseller %d (%s): mode=%s credits=%s\n",
            $reseller->id, $reseller->username, $before->mode,
            $before->credits ?? 'n/a');

        try {
            $after = $client->resellers->adjustBilling(
                id:             $reseller->id,
                delta:          -($sold * $unitPrice),   // negative takes money off
                reason:         "$month wholesale, $sold lines",
                idempotencyKey: "reconcile-$month-$reseller->id",   // receipt number
            );
            printf("  charged %.2f for %d lines, credits now %s\n",
                $sold * $unitPrice, $sold, $after->credits ?? 'n/a');
        } catch (ValidationException $e) {
            printf("  skipped: %s\n", $e->slug);
        }
    }
} while ($page->nextCursor !== null);
# month_end_reconcile.py
from xtream_ai_panel_api import PanelApiClient
from xtream_ai_panel_api.exceptions import ValidationException

client = PanelApiClient(
    base_url="https://<your-panel-domain>",
    token="<your-api-key>",       # admin key
)

MONTH      = "2026-07"            # month you are charging for
UNIT_PRICE = 1.00                 # what you charge per line


# Replace this with a query against your own billing database.
def lines_sold_by(reseller_id: int, month: str) -> int:
    return {315: 47, 316: 12}.get(reseller_id, 0)


# Walk every reseller, one page at a time.
page = None
while True:
    page = client.resellers.list(limit=50, cursor=page.next_cursor if page else None)

    for reseller in page.items:
        sold = lines_sold_by(reseller.id, MONTH)
        if sold == 0:
            continue

        before = client.resellers.billing(id=reseller.id)
        print(f"reseller {reseller.id} ({reseller.username}): "
              f"mode={before.mode} credits={before.credits}")

        try:
            after = client.resellers.adjust_billing(
                id=reseller.id,
                delta=-(sold * UNIT_PRICE),          # negative takes money off
                reason=f"{MONTH} wholesale, {sold} lines",
                idempotency_key=f"reconcile-{MONTH}-{reseller.id}",   # receipt number
            )
            print(f"  charged {sold * UNIT_PRICE:.2f} for {sold} lines, "
                  f"credits now {after.credits}")
        except ValidationException as e:
            print(f"  skipped: {e.slug}")

    if page.next_cursor is None:
        break

You should see:

reseller 315 (bigshop): mode=credits credits=947.0
  charged 47.00 for 47 lines, credits now 900.0
reseller 316 (smallshop): mode=credits credits=30.0
  charged 12.00 for 12 lines, credits now 18.0

Save this output. Pipe it to a file when you run the script from cron, keep the file next to your monthly invoicing PDFs, and archive both together. When a reseller asks you three months later why their August balance dropped by forty-seven dollars, you have the log line with the count and the reason field, ready to paste into your reply.

Two shapes of balance exist in the panel. The credits mode carries a money-like balance in floating point. The users mode carries an integer cap on the number of live subscribers a reseller is allowed to keep enabled at once. This recipe is written for credits mode, which is the common shape. If any of your resellers is on users mode, the delta will move their cap instead of their money, and dropping the cap below the reseller's active users returns the error listed in the table below.

The reason field is short free text. It is stored with the ledger entry and shown to the reseller in their own history view inside the panel. Use it to name the month and the count of lines so that the reseller can reconcile against their own records without emailing you for explanations. A good pattern is <month> wholesale, <count> lines, which is what the recipe writes. Avoid free text longer than one line. The panel truncates long reasons at the storage layer.

When it goes wrong:

What you see What it means What to do
AuthorizationException, slug admin_only_endpoint You used a reseller key. Only admin keys can list resellers or move their balance. Run the script with an admin key.
ValidationException, slug negative_balance_not_allowed The charge would leave the reseller below zero. Charge less, or ask them to pay before you run it again.
ValidationException, slug cap_below_active_users The reseller is sold by slots, and the new limit is under the lines they already have live. Lower the limit by less, or ask them to remove lines first.
ConflictException This month was already charged with a different amount. One receipt number per reseller per month. Fix the amount and use a new month tag if you really must redo it.
403 insufficient_scope Your key is missing a scope this recipe needs. Create a new key and tick the scope named above.

Raw HTTP version

# See current balance
curl -H "Authorization: Bearer $TOKEN" \
     https://<your-panel-domain>/panel-api/v1/resellers/315/billing

# Deduct wholesale for the month
curl -X POST \
     -H "Authorization: Bearer $TOKEN" \
     -H "Idempotency-Key: reconcile_2026-07_reseller_315" \
     -H "Content-Type: application/json" \
     -d '{"delta":-47.00,"reason":"July 2026 wholesale reconciliation"}' \
     https://<your-panel-domain>/panel-api/v1/resellers/315/billing/adjust

If the reseller is on users (slots) mode instead of credits, delta is treated as a slot cap adjustment (integer). Trying to drop the cap below the reseller's currently active users returns 422 cap_below_active_users. The mode field in the billing response tells you which shape to expect.

If you invoice resellers monthly and print a PDF invoice, embed the receipt number from this recipe in the PDF footer. When the reseller queries a specific charge in the future, the receipt number is the fastest way to look it up in the panel's ledger. It is also how you prove that the charge was applied once and only once.

Recipe 5. A self-service signup portal (reseller sub-reseller model)

You run a checkout on your own website where visitors can sign up to resell your service. When a visitor pays, you want to open a partner account for them in the panel and give them a first line to test with. This recipe is the code your checkout runs on the server side after the payment is confirmed. It never runs in the browser. Never. The browser has no business holding your API key.

You only change these things:

  • <your-panel-domain> and <your-api-key>. This recipe needs an admin key.
  • orderId. The payment reference from your checkout. It builds both receipt numbers.
  • signupUser, signupPass, signupEmail. What the visitor typed in your signup form.
  • groupId. The reseller group new partners join. Ask your panel administrator for the number.
  • packageId. The package used for their first line.
  • The admin key needs the subresellers:write scope ticked.

The password must be at least 8 characters. Usernames must be at least 3 characters and cannot contain % & ? # / \ = + @ : ;. Validate on your side before you call the API so that the visitor sees a friendly error, not a raw panel refusal.

How does it work. Two calls happen back to back. The first opens the partner's reseller account under the group you assigned to new signups. The panel returns a numeric partner id, which you keep so that the second call can attach the first line to the partner. The second call creates the line with the panel picking the username and the password. Both calls carry a receipt number derived from the same order id. If the visitor's browser hangs on the response and they hit the pay button a second time, your webhook fires twice, both receipt numbers collide, and the panel returns the exact same partner and the exact same line from the first attempt. No duplicate partner, no duplicate line, no double charge.

If the first call succeeds and the second fails (say a transient network blip after the partner is opened but before the line is attached) a retry passes the first receipt to the panel, which recognizes it and skips the reseller creation, then attempts the line creation with a fresh state. The recipe treats the two calls as a single logical signup even though they are two separate HTTP requests. If you want to be paranoid about the two-call sequence, wrap them in your own state machine on your side that persists the partner id after the first call, so that a retry can pass the id directly to the second call instead of relying on the panel to recognize the receipt again.

The group id decides what the new partner is allowed to do. A partner in a permissive group can open sub-partners of their own, forming a small tree of accounts under yours. A partner in a restrictive group can only sell lines, not open partners. Panel administrators define groups in the panel UI under Settings and their behavior is fixed by the group, not by the API. If you want your signup portal to open partners in one group for basic signups and another group for premium signups, look up the two group ids once, keep them in your code as constants, and branch on the checkout tier your visitor picked.

<?php
// signup_portal.php  --  runs on your server after the payment is confirmed
require 'vendor/autoload.php';

use XtreamAI\PanelApi\PanelApiClient;
use XtreamAI\PanelApi\Exceptions\ValidationException;
use XtreamAI\PanelApi\Exceptions\InsufficientCreditsException;

$client = new PanelApiClient(
    baseUrl: 'https://<your-panel-domain>',
    token:   '<your-api-key>',      // admin key
);

$orderId     = 'pi_3QxYz1234567';   // payment reference from your checkout
$signupUser  = 'newpartner';        // what the visitor typed in your form
$signupPass  = 'ch0sen-by-them';
$signupEmail = 'partner@example.com';
$groupId     = 4;                   // reseller group new partners join
$packageId   = 7;                   // package for their first line

try {
    // 1. Open the partner's reseller account.
    $partner = $client->resellers->create(
        username:       $signupUser,
        password:       $signupPass,
        email:          $signupEmail,
        memberGroupId:  $groupId,
        notes:          "signup $orderId",
        idempotencyKey: "signup-$orderId",        // receipt number for this signup
    );

    // 2. Give them a first line. The panel picks the username and password.
    $line = $client->lines->create(
        packageId:      $packageId,
        memberId:       $partner->id,
        email:          $signupEmail,
        notes:          "first line for $orderId",
        idempotencyKey: "signup-$orderId-line",   // receipt number for this line
    );
} catch (ValidationException $e) {
    // Log the reason for yourself. Show the visitor a short, friendly message.
    fwrite(STDERR, "signup refused: {$e->getMessage()}\n");
    exit(1);
} catch (InsufficientCreditsException $e) {
    fwrite(STDERR, "no credits left to open partner accounts\n");
    exit(1);
}

printf("Partner %d (%s) created. First line: %s / %s, expires %s\n",
    $partner->id, $partner->username,
    $line->username, $line->password,
    $line->expDate?->format('Y-m-d H:i') ?? 'never');
# signup_portal.py  --  runs on your server after the payment is confirmed
from xtream_ai_panel_api import PanelApiClient
from xtream_ai_panel_api.exceptions import (
    ValidationException, InsufficientCreditsException,
)

client = PanelApiClient(
    base_url="https://<your-panel-domain>",
    token="<your-api-key>",       # admin key
)

ORDER_ID     = "pi_3QxYz1234567"   # payment reference from your checkout
SIGNUP_USER  = "newpartner"        # what the visitor typed in your form
SIGNUP_PASS  = "ch0sen-by-them"
SIGNUP_EMAIL = "partner@example.com"
GROUP_ID     = 4                   # reseller group new partners join
PACKAGE_ID   = 7                   # package for their first line

try:
    # 1. Open the partner's reseller account.
    partner = client.resellers.create(
        username=SIGNUP_USER,
        password=SIGNUP_PASS,
        email=SIGNUP_EMAIL,
        member_group_id=GROUP_ID,
        notes=f"signup {ORDER_ID}",
        idempotency_key=f"signup-{ORDER_ID}",         # receipt number for this signup
    )

    # 2. Give them a first line. The panel picks the username and password.
    line = client.lines.create(
        package_id=PACKAGE_ID,
        member_id=partner.id,
        email=SIGNUP_EMAIL,
        notes=f"first line for {ORDER_ID}",
        idempotency_key=f"signup-{ORDER_ID}-line",    # receipt number for this line
    )
except ValidationException as e:
    # Log the reason for yourself. Show the visitor a short, friendly message.
    raise SystemExit(f"signup refused: {e}")
except InsufficientCreditsException:
    raise SystemExit("no credits left to open partner accounts")

print(f"Partner {partner.id} ({partner.username}) created. "
      f"First line: {line.username} / {line.password}, expires {line.exp_date}")

You should see:

Partner 262258 (newpartner) created. First line: u_ca82662a / 2d54c5f1, expires 2026-09-08 04:45

The partner id (262258 in this example) is what you store in your own database as the identifier of the new partner. Every future call about this partner passes that id, not the username. Usernames can be changed later by the partner themselves through the panel UI. The id cannot. Save it, index it, and refer to it forever.

When it goes wrong:

What you see What it means What to do
ValidationException with a message like Password must be at least 8 characters The username, password or email was refused. Read the message, show the visitor a clear hint, and let them try again.
AuthorizationException, slug sub_reseller_creation_not_allowed The key's group is not allowed to open partner accounts. Ask your panel administrator to allow it for that group.
InsufficientCreditsException Opening a partner account costs credits and there are none left. Top up the account that owns the key.
ConflictException The same order was already processed with different details. One order, one receipt number. Look the partner up instead of creating them again.
403 insufficient_scope Your key is missing a scope this recipe needs. Create a new key and tick the scope named above.

Never expose your reseller key to the browser. The signup portal is a server-side handler that talks to the Panel API on the customer's behalf. The browser only talks to your handler.

Sending the visitor a confirmation email with the new partner login is your responsibility. The panel does not send any emails to the visitor on your behalf. If your checkout system already sends order confirmation emails, pipe the partner username and password into that template. If it does not, wire the panel response into a simple mail send at the end of the recipe. Do not put the raw password in the email subject line, and do not log it to any file that leaves your server.

Raw HTTP version

The block below shows a simpler variant with a reseller key: it sells a line straight to a visitor instead of opening a partner account. For the sub-reseller flow above, add a POST /panel-api/v1/resellers call before the line call and use an admin key.

# 1. Catalog (call from the server, cache the result for a few minutes)
curl -H "Authorization: Bearer $RESELLER_TOKEN" \
     https://<your-panel-domain>/panel-api/v1/packages

# 2. Create the line
curl -X POST \
     -H "Authorization: Bearer $RESELLER_TOKEN" \
     -H "Idempotency-Key: checkout_$STRIPE_PI_ID" \
     -H "Content-Type: application/json" \
     -d '{"package_id":7,"username":"cust_a1b2c3","password":"picked_by_customer","email":"buyer@example.com"}' \
     https://<your-panel-domain>/panel-api/v1/lines

Recipe 6. Migrating existing subscribers from Xtream Codes or XUI.one

You are moving off another panel and onto this one. You have a CSV export of your old lines and you want every one of those lines to appear here with the same username, the same password, and the same end date, so that your customers never touch their apps. This recipe is a one-shot import. It reads the CSV row by row and creates each line here with a receipt number derived from the row id in the old system. Rows that already went through are recognized by their receipt number and are not created a second time. You can rerun the same script safely if the network drops halfway through.

You only change these things:

  • <your-panel-domain> and <your-api-key>. This recipe needs an admin key.
  • sourceTag. Any short name for the old panel. It keeps the receipt numbers unique in case you later import from another source.
  • csvPath. Path to your export. Expected columns are id,username,password,exp_date,package_id,max_connections.
  • memberId. The panel account that will own the imported lines.
  • packageMap. Old package id on the left, new package id on the right.

How does it work. The script loops over the CSV rows and, for each one, calls the same line creation endpoint you already met in Recipe 1. Two differences matter compared with fresh provisioning. The recipe sends password, expDate and maxConnections from the row, instead of letting the panel choose new values. Keeping the customer's own credentials and end date is the whole point of a migration. The panel refuses to accept a chosen password or a chosen end date from a reseller key, so this recipe needs an admin key.

The receipt number is derived from the row id in the old panel, prefixed by the source tag. That combination guarantees that the same row cannot be imported twice, even if you rerun the script after a crash, and even if two rows in different old panels happen to share a numeric id. The tiny sleep between rows is a pacing measure. The panel enforces a per-key request limit, and pacing keeps you comfortably under it while still finishing a large import in a reasonable time. Two hundred milliseconds gives you five lines per second, which comes out to eighteen thousand lines per hour. For an export of a hundred thousand lines that is a bit under six hours. Run it overnight.

The packageMap at the top of the script is the piece you have to think about the most. Old panels usually have many packages that map to only a handful of packages on the new panel. Sit down with your product list open in one window and your old panel's package list in another, and write out the mapping row by row. Rows in the CSV whose old package id is not in the map get skipped with a warning, so a partial map is not a failure. You can rerun the script later after adding the missing entries. Rows already imported will be recognized by receipt and skipped, and the newly-mapped rows will go through.

<?php
// migrate_from_xui.php
require 'vendor/autoload.php';

use XtreamAI\PanelApi\PanelApiClient;
use XtreamAI\PanelApi\Exceptions\ValidationException;
use XtreamAI\PanelApi\Exceptions\PanelApiException;

$client = new PanelApiClient(
    baseUrl: 'https://<your-panel-domain>',
    token:   '<your-api-key>',      // admin key
);

$sourceTag  = 'oldpanel-eu-1';      // short name for the old panel
$csvPath    = 'legacy_export.csv';  // your export
$memberId   = 260595;               // account that will own the imported lines
$packageMap = [                     // old package id => new package id
    1 => 7,
    2 => 9,
];

$fp = fopen($csvPath, 'r');
$header = fgetcsv($fp);
$col = array_flip($header);
$done = 0; $skipped = 0; $failed = 0;

while (($row = fgetcsv($fp)) !== false) {
    $sourceId = (int) $row[$col['id']];
    $newPackage = $packageMap[(int) $row[$col['package_id']]] ?? null;
    if ($newPackage === null) {
        $skipped++;
        continue;
    }

    try {
        $client->lines->create(
            packageId:      $newPackage,
            memberId:       $memberId,
            username:       $row[$col['username']],
            password:       $row[$col['password']],
            expDate:        (int) $row[$col['exp_date']],          // keep the old end date
            maxConnections: (int) $row[$col['max_connections']],
            notes:          "imported from $sourceTag line $sourceId",
            idempotencyKey: "migrate-$sourceTag-line-$sourceId",   // one receipt per old line
        );
        $done++;
    } catch (ValidationException $e) {
        fwrite(STDERR, "row $sourceId skipped: {$e->slug} field={$e->field()}\n");
        $skipped++;
    } catch (PanelApiException $e) {
        fwrite(STDERR, "row $sourceId failed: {$e->slug}\n");
        $failed++;
    }

    usleep(200_000);   // about five lines per second
}
fclose($fp);

printf("Done. imported=%d skipped=%d failed=%d\n", $done, $skipped, $failed);
# migrate_from_xui.py
import csv
import sys
import time

from xtream_ai_panel_api import PanelApiClient
from xtream_ai_panel_api.exceptions import ValidationException, PanelApiException

client = PanelApiClient(
    base_url="https://<your-panel-domain>",
    token="<your-api-key>",        # admin key
)

SOURCE_TAG  = "oldpanel-eu-1"      # short name for the old panel
CSV_PATH    = "legacy_export.csv"  # your export
MEMBER_ID   = 260595               # account that will own the imported lines
PACKAGE_MAP = {                    # old package id: new package id
    1: 7,
    2: 9,
}

done = skipped = failed = 0

with open(CSV_PATH, newline="") as fp:
    for row in csv.DictReader(fp):
        source_id = int(row["id"])
        new_package = PACKAGE_MAP.get(int(row["package_id"]))
        if new_package is None:
            skipped += 1
            continue

        try:
            client.lines.create(
                package_id=new_package,
                member_id=MEMBER_ID,
                username=row["username"],
                password=row["password"],
                exp_date=int(row["exp_date"]),                    # keep the old end date
                max_connections=int(row["max_connections"]),
                notes=f"imported from {SOURCE_TAG} line {source_id}",
                idempotency_key=f"migrate-{SOURCE_TAG}-line-{source_id}",   # one receipt per old line
            )
            done += 1
        except ValidationException as e:
            print(f"row {source_id} skipped: {e.slug} field={e.field}", file=sys.stderr)
            skipped += 1
        except PanelApiException as e:
            print(f"row {source_id} failed: {e.slug}", file=sys.stderr)
            failed += 1

        time.sleep(0.2)   # about five lines per second

print(f"Done. imported={done} skipped={skipped} failed={failed}")

You should see:

row 45999 skipped: validation_error field=username
Done. imported=3184 skipped=6 failed=0

The three counters at the end give you the shape of the run at a glance. imported is what the panel created new. skipped is what the script chose not to send (missing package mapping) plus what the panel refused (username collision, bad end date). failed is what the script could not classify at all. In a healthy run the third number is zero. Any nonzero value there deserves a look, because it means the script hit an error class it did not have a specific handler for.

Migrations are noisy on purpose. A row that fails is not a crash. The script logs the reason to standard error and moves on. Look at your log at the end, decide what to do about the skipped rows (a common case is a username that a customer of yours here already picked), fix them in the source CSV, and rerun. The rows that already went through will be recognized and skipped by the panel.

For a first run against a big export we recommend a dry pass. Run the script pointed at a small test CSV of ten to twenty rows first, check that the lines appear in your panel the way you expect, and only then point it at the full export. A ten thousand row import at five per second takes about half an hour. There is no atomic mode. If you cut the script off halfway, the rows that were already created stay in the panel and a rerun picks up from where you stopped.

When it goes wrong:

What you see What it means What to do
ValidationException, field username That username already exists in this panel. Rename the line in your export, or leave it on the old panel.
ValidationException, field exp_date The end date is in the past, or more than five years away. Fix the date in your export. Lines that already expired do not need importing.
AuthorizationException, slug admin_only_field You used a reseller key. Keeping the old end date and connection count needs an admin key. Run the import with an admin key.
The script slows down and finishes anyway You hit the per-key request limit and the SDK waited for you. Raise the usleep or time.sleep value. Ask us to raise the limit if you have tens of thousands of lines.

Raw HTTP version

curl -X POST \
     -H "Authorization: Bearer $TOKEN" \
     -H "Idempotency-Key: migrate_oldpanel-eu-1_line_45812" \
     -H "Content-Type: application/json" \
     -d '{"package_id":7,"member_id":42,"username":"legacyuser","password":"originalpass","exp_date":1793923200,"bouquets":[3,7,9]}' \
     https://<your-panel-domain>/panel-api/v1/lines

If you already have an XUI-compatible export tool that emits action=create_line requests, you can point it at /panel-api/xc/{accesscode}/admin/index.php?api_key=$TOKEN and skip rewriting the loop. See the Xtream Codes / XUI.one compatibility page for the full mapping. The idempotency guidance above still applies: your tool needs to emit a stable Idempotency-Key header per source row.

Keep the migration receipt tags forever. Even after the migration is done, the receipts that ran migrate-oldpanel-eu-1-line-<id> remain in the panel's ledger. If a year later a customer disputes when their line was created, that entry proves it came in from a specific row in a specific old-panel export, on a specific date. That evidence often ends debates before they start.

Recipe 7. The canonical safe-retry helper

Using the SDK? Skip this recipe. The SDK retries safely for you. This recipe is only for raw HTTP integrations.

The recipes above lean on the SDK to handle network hiccups, transient panel outages, and the panel's own request throttling. If you are calling the panel from bash, from a language without an SDK, or from an old codebase you cannot refit today, you need to write the retry rules by hand. This section gives you a compact helper in both PHP and Python that follows the rules the SDK follows internally. Copy it into your project, wrap every call through it, and you get the same safe-retry behavior for free.

You only change these things:

  • The panel base URL and the token you pass to the constructor.
  • The path, method, body and receipt number you pass to request(). Everything else is generic.

How does it work. The helper reads the response and decides what to do about it. A 429 means the panel is asking you to slow down. It sends a Retry-After header with the number of seconds to wait, and the helper honors that value with an added random offset so that many callers do not all wake up at once. A 5xx means the panel had a transient issue on its side. The helper waits and tries again, up to a few attempts. A network timeout means the request never made it in a form the panel could confirm. Retrying with the same receipt number is safe, because if the first request did complete on the panel side, the panel will simply return the same answer to the retry instead of doing the work twice.

Any other 4xx response is a permanent refusal. Retrying does not change the answer and only burns your rate allowance. The helper stops and returns the response so your caller can decide how to react. This is the single most important rule of writing your own retry code and the one most often gotten wrong. A quick loop that catches every non-2xx response and retries three times looks harmless in a script that runs against a healthy panel. The moment a customer sends bad input or your key runs out of scope, the same loop hammers the panel three times per event, contributes to whatever congestion caused the failure, and produces a log full of noise instead of a single clear error. Stop on 4xx other than 429. Let the caller decide.

The random offset added to every wait is called jitter. Without it, a fleet of servers that all hit a rate limit at the same second will all wake up at the same second and all hit the limit again. With it, they wake up spread across the retry window and the load smooths out. The helper adds a few hundred milliseconds of random offset on each retry, which is enough to break up any accidental synchronization without slowing the caller down noticeably.

Raw HTTP version

The rules the helper enforces:

  • On network timeout or connection error, retry with the same Idempotency-Key. Up to 3 attempts.
  • On 429, honor the Retry-After header, add jitter, back off exponentially. Up to 5 attempts.
  • On 5xx, exponential back-off. Up to 3 attempts.
  • On 4xx other than 429, stop and return the error. These are your integration bugs. Retry will not fix them.
<?php
// panel_api_client.php
class SimplePanelClient
{
    public function __construct(
        private string $base,
        private string $token,
    ) {}

    /**
     * @return array{status:int, body:array<string,mixed>|null, headers:string}
     */
    public function request(string $method, string $path, ?array $body = null, ?string $idemKey = null): array
    {
        $url = $this->base . $path;
        $headers = ['Authorization: Bearer ' . $this->token];
        if ($body !== null)  $headers[] = 'Content-Type: application/json';
        if ($idemKey !== null) $headers[] = 'Idempotency-Key: ' . $idemKey;

        $attempt = 0;
        $maxRateLimit = 5;
        $max5xx = 3;
        $maxNetwork = 3;

        while (true) {
            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST  => $method,
                CURLOPT_HTTPHEADER     => $headers,
                CURLOPT_POSTFIELDS     => $body !== null ? json_encode($body) : null,
                CURLOPT_HEADER         => true,
                CURLOPT_TIMEOUT        => 30,
            ]);
            $raw = curl_exec($ch);
            $err = curl_errno($ch);
            $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            $hdrSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
            curl_close($ch);

            // Network / timeout errors: retry with the same Idempotency-Key.
            if ($err !== 0) {
                if ($attempt >= $maxNetwork) throw new RuntimeException("network error: " . curl_strerror($err));
                usleep((int) ((2 ** $attempt) * 500_000 + rand(0, 300_000)));
                $attempt++;
                continue;
            }

            $headerStr = substr($raw, 0, $hdrSize);
            $bodyStr   = substr($raw, $hdrSize);
            $bodyJson  = json_decode($bodyStr, true);

            if ($status === 429 && $attempt < $maxRateLimit) {
                preg_match('/^retry-after:\s*(\d+)/im', $headerStr, $m);
                $wait = (int) ($m[1] ?? 60);
                $jitter = rand(0, 500) / 1000.0;
                sleep((int) ceil($wait * (2 ** $attempt) + $jitter));
                $attempt++;
                continue;
            }
            if ($status >= 500 && $status < 600 && $attempt < $max5xx) {
                usleep((int) ((2 ** $attempt) * 500_000 + rand(0, 300_000)));
                $attempt++;
                continue;
            }
            return ['status' => $status, 'body' => $bodyJson, 'headers' => $headerStr];
        }
    }
}

// Usage:
// $client = new SimplePanelClient('https://<your-panel-domain>', getenv('PANEL_API_TOKEN'));
// $r = $client->request('POST', '/panel-api/v1/lines',
//     ['package_id' => 7, 'username' => 'demo', 'password' => 'demo'],
//     'invoice_INV-2026-00814');
# panel_api_client.py
import time, random, requests

class SimplePanelClient:
    def __init__(self, base: str, token: str):
        self.base = base
        self.session = requests.Session()
        self.session.headers["Authorization"] = f"Bearer {token}"

    def request(self, method: str, path: str, body: dict | None = None,
                idem_key: str | None = None):
        url = self.base + path
        headers = {}
        if body is not None:  headers["Content-Type"] = "application/json"
        if idem_key:          headers["Idempotency-Key"] = idem_key

        max_rate_limit, max_5xx, max_network = 5, 3, 3
        attempt = 0

        while True:
            try:
                r = self.session.request(method, url, json=body, headers=headers, timeout=30)
            except (requests.ConnectionError, requests.Timeout) as e:
                if attempt >= max_network:
                    raise
                time.sleep((2 ** attempt) * 0.5 + random.uniform(0, 0.3))
                attempt += 1
                continue

            if r.status_code == 429 and attempt < max_rate_limit:
                wait = int(r.headers.get("Retry-After", "60"))
                time.sleep(wait * (2 ** attempt) + random.uniform(0, 0.5))
                attempt += 1
                continue
            if 500 <= r.status_code < 600 and attempt < max_5xx:
                time.sleep((2 ** attempt) * 0.5 + random.uniform(0, 0.3))
                attempt += 1
                continue
            return r

# Usage:
# client = SimplePanelClient("https://<your-panel-domain>", os.environ["PANEL_API_TOKEN"])
# r = client.request("POST", "/panel-api/v1/lines",
#                    body={"package_id": 7, "username": "demo", "password": "demo"},
#                    idem_key="invoice_INV-2026-00814")

The helper does not retry 4xx responses other than 429. Slugs like validation_error, insufficient_credits, package_not_accessible, or idempotency_conflict mean your caller sent something the server refused. Blindly retrying wastes rate-limit budget and papers over real bugs. Handle them explicitly, as each recipe above does.

The counter values in the helper (three network attempts, three server-error attempts, five rate-limit attempts) are conservative defaults, not magic numbers. If your integration is very sensitive to slow calls and you would rather fail fast, drop them to one or two. If your integration is asynchronous and you can tolerate longer waits, raise the rate-limit counter to eight or ten and let the caller sit through longer back-offs. The SDKs use the same defaults as this helper, and they have proven robust across a wide range of production deployments.

Where each recipe belongs in your codebase

The seven recipes above cover the whole life of a subscription. Recipe 1 runs the moment a payment lands. Recipe 2 runs on every renewal. Recipe 3 runs on the dunning schedule when a payment goes overdue and again when the customer catches up. Recipe 4 runs on a monthly cron for reseller wholesale. Recipe 5 runs on your public checkout when someone signs up to resell. Recipe 6 runs once, when you move from another panel. Recipe 7 is a library, not a script.

The natural home for Recipes 1, 2, 3, and 5 is inside your billing system's webhook handler, wherever it sits. If your billing is WHMCS, they belong in a module hook in modules/servers/. If it is Blesta, they belong in a plugin under plugins/. If your billing is Stripe direct, they belong in the endpoint that receives charge.succeeded, invoice.paid and related events. Wherever they sit, the pattern is the same. The billing system knows something happened, the webhook fires, the recipe runs, and the panel state changes to reflect the billing state.

The natural home for Recipe 4 is a scheduled job. Cron on Linux, a scheduled task in your CI, or a cloud-native scheduler like AWS EventBridge all work. Run it once a month, capture the log, and archive it as evidence for the reseller invoices you send next. The natural home for Recipe 6 is a one-shot script you run manually from a shell, watching the log scroll past. Nothing about it needs to run inside your billing system, because the source of truth is a static CSV export, not a live event stream.

Recipe 7 belongs in a shared library file that every raw-HTTP integration in your codebase imports. Do not paste it into every script. When you improve the retry behavior later (adding metrics, adjusting the counters, integrating with your existing tracing) you only have to edit one place. This is standard defensive programming and it is what the SDKs do internally. If you are on Python or PHP and can use the SDK, do so. Recipe 7 exists for readers whose environment forbids installing libraries and who need to inline the transport layer inside a single script.

How to test any of these recipes before you point them at production

Do not run any of these scripts against your live panel the first time. Every recipe writes real state. A miscopied line id in Recipe 3 suspends a live customer. A miscopied unit price in Recipe 4 charges every reseller three times what you meant. The panel has no undo button. What it does have is a receipt-number ledger, which is why every write in this cookbook takes one, so a webhook that fires many times still lands on the panel once.

The right sequence is a small test line first. Create a real line with Recipe 1 against a package you know is cheap, using an obviously fake invoice like INV-DOC-TEST-001 and a nonsense customer email. Confirm the line appears in your panel UI with the values you expected. Then run Recipe 2 against that same line and verify the new end date matches what the panel returned. Then run Recipe 3 on the same line and verify the enabled flag flips off and on in the panel UI. When all three answers match what you see in the UI, delete the test line and start pointing the same code at real invoices. A safely-retried write that was already wrong is still wrong, and the receipt number only stops you from doing it a second time.

For Recipe 4, run the loop with the linesSoldBy function returning zero for every reseller. Nothing gets charged. What you learn is that the page-by-page walk works, the resellers show up in the log, and your admin key has the scope it needs. Only when the loop completes cleanly do you swap in your real billing lookup.

For Recipe 5, run one signup against a real payment reference from your checkout in test mode, log in with the partner credentials the script printed, and confirm the panel view looks the way you expect a new partner to look. Log out, hit the checkout again with a different payment reference, and confirm a second, fully separate partner shows up. If the second run collides with the first (same partner returned, same line returned) you built the receipt from something too broad and every visitor is landing in the same partner account. Fix the receipt, delete both partners, and try again.

For Recipe 6, take twenty rows out of your export into a small CSV and run the import against those first. Inspect the resulting lines in the panel. Only when the sample looks right do you point the script at the full export. Deliberately corrupt one row in the sample (a bad exp_date, a username with a forbidden character) and confirm the script logs the row and moves on instead of aborting the whole batch. The recipes above are already written that way, but seeing it happen on your side once builds confidence for the full run.

A key that gets used only for testing is also worth having. Issue it in the panel with the same scopes as your production key, but label it something like staging so that it stands out in the audit log. When you find a bug during testing, the events show up under the staging key and are easy to distinguish from real customer traffic. Keys are cheap. Rotate them every few months.

Once a recipe has passed its manual test, wire it into your billing system's happy path but keep the log at a verbose level for the first week in production. Every real event that fires the recipe leaves a line in your log. You skim the log at the end of the day to catch anything the panel refused, feed those refusals back into your billing UI as work items for your support team, and slowly turn the log verbosity back down as you learn which categories of refusal are expected and which are surprises. The refusal categories change as your customer base grows. A brand-new integration has almost no username collisions. A one-year-old integration has plenty. Adjust your handling as you learn.

Questions that come up over and over

Can I run all seven recipes with one API key? Yes, as long as that key has all the scopes each recipe needs. For most integrations, a single admin key with the read and write scopes for lines, packages, and resellers covers every recipe on this page. The reason to split into more than one key is auditability. A key labeled webhook-provisioning in the panel's audit log makes it obvious which piece of your integration created a given line. A key labeled monthly-reconciliation makes it obvious which piece charged a given reseller. Same power, cleaner history.

Do I need a webhook for every recipe? No. Recipes 4 and 6 do not run from a webhook. Recipe 4 runs on a schedule. Recipe 6 runs by hand. Recipe 5 does run in a webhook, but the webhook is your own checkout, not something the panel sends. The panel itself does not push events to your server. It answers when you call, and that is the whole interaction model. If you want to react to a change on the panel side (say, a line the panel disabled because credits ran out) poll the line's state on the schedule your business needs, or wait for the customer to complain about their app. There is no push channel from the panel to you.

What happens if the panel is down? Every call the recipes make goes through the SDK's retry helper. A brief outage looks like a longer response time to your caller and does not raise an error. A longer outage eventually gives up and raises ServiceUnavailableException, which you catch the same way you catch any other panel exception. The recipes on this page do not add extra retries on top of the SDK, because the SDK already retries the right way. If you are on raw HTTP, use Recipe 7 as your retry layer and the same guarantees apply.

Which recipes need an admin key and which can run with a reseller key? Recipes 1, 2, and 3 run with either. If you use a reseller key for Recipe 1, delete the memberId line and the panel will attach the new line to the reseller who owns the key. Recipes 4, 5, and 6 need an admin key, because they touch resellers or use fields (like a custom end date) that the panel only accepts from an admin key. Recipe 7 is transport code and does not care which key it carries.

How long does the panel remember receipt numbers? Twenty-four hours. During that window, a repeat of the same receipt is recognized and the panel returns the first answer without doing the work again. After the window closes, the same receipt is treated as a fresh event. If your billing system can fire a repeat webhook more than a day after the original event (rare, but possible with catastrophic outages) the dedup for that repeat needs to live on your side, not on the panel side. Store the receipt numbers of successful calls in your own table for as long as your business retention policy demands.

What happens if two of my scripts hit the same line at the same time? The panel serializes writes per line. One will land first, the other will land second, and the response of the second will reflect the state after the first's effect. There is no error and no rollback. If the second write depended on a value from before the first (a stale read into an unconditional write) your logic has a race and you should fix it with a smaller unit of work, not with retries. In practice this comes up in Recipe 4 if you accidentally schedule two reconciliation runs at the same time. The receipt numbers save you, but only for the exact same amount. Different amounts on the same receipt raise ConflictException, which is your signal to look at the schedule.

About receipt numbers, one more time

We have mentioned receipt numbers in every recipe, and it is worth pulling the guidance into one place. Under the hood the string is a header the SDK adds to every request that changes state. In the recipes we call it a receipt number because that is exactly what it is: the number of the receipt that ties one payment (or one webhook, or one order) to one panel operation.

A good receipt number is derived from something your billing system already has. Invoice numbers, order ids, payment intent ids from Stripe, all work. A bad receipt number is a random string generated at retry time, because the retry will pick a different random string and the panel will treat it as a fresh event. A worse receipt number is the current time in milliseconds, because a retry a second later will pick a different time and, again, the panel treats it as a fresh event. If you find yourself typing uuid.uuid4() or time.time() into the receipt slot, stop. The whole point of the slot is that it stays the same across every retry of the same underlying event.

The receipt namespace is per API key. Two different keys can use the same receipt number for two different events without colliding. You never need to coordinate receipt numbers across your resellers or across your own admin key and your resellers' keys. The panel keeps receipts for twenty-four hours. After that window, a repeat of the same receipt is treated as a fresh event. If your billing system can fire a webhook more than a day after the original charge, wire it to a job that dedups on your side, not on the panel side.

One last habit worth naming. Include the operation type in the receipt itself, not only the invoice number. A charge that provisions and a charge that renews may share an invoice, and if both write to the panel with the exact same receipt, the second one is misinterpreted as a repeat of the first. Prefixing the receipt with provision-, renew-, suspend-, enable-, reconcile-, or migrate- prevents the collision. It also makes the audit log inside the panel readable at a glance, because the first token of the receipt tells you what the caller intended without you having to read the URL of the call.

If your business grows to the point where you have many keys issuing many receipts, consider adopting a fixed format across your whole codebase. Something like <operation>-<system>-<year>-<external_id>. The panel does not enforce any format, so you have full freedom, and the discipline pays off when you are debugging an odd interaction between your webhook queue and the panel's ledger at midnight.

See also

  • Quickstart. Install the SDK and make your first call.
  • SDKs. Everything the PHP and Python packages can do.
  • Errors. Every error you might see and what to do about it.
  • Xtream Codes / XUI.one compatibility. The full mapping of legacy actions if you are keeping an old export tool.
  • Authentication. How the token is validated on every call and how to rotate a key without downtime.