Skip to content
CWPControl Web PanelDocumentation
  1. Home
  2. Docs
  3. CWP Billing
  4. Developer and reference
  5. Writing your own server module

Writing your own server module

Updated

CWP Billing creates, suspends, unsuspends and terminates accounts through server modules (CWP, cPanel, DirectAdmin, Plesk, Proxmox, Virtualizor, SolusVM, Webuzo). You can add a module for any other panel or system yourself, as an extension. The same works for domain registrars.

An extension is a folder of plain PHP files in extensions/. It runs next to the encoded billing code, and updates never change it. If you would rather not write it yourself, ask us: we develop missing modules for free.

The parts of an extension

extensions/
  mypanel/                     the extension name: a-z, 0-9 and _ only
    extension.php              tells the billing what the extension adds
    MyPanelModule.php          the module class

extension.php returns an array. To add a module, list it under modules with its code:

<?php
declare(strict_types=1);

require_once __DIR__ . '/MyPanelModule.php';

return [
    'name' => 'My Panel module',
    'modules' => ['mypanel' => \MyCompany\MyPanelModule::class],
];

Then enable the extension in config/config.php:

'extensions' => ['mypanel'],

The module now appears under Panel type when you add a server, and under Module in the product editor.

Rules:

  • The module code (here mypanel) is 2 to 32 characters long: a-z, 0-9 and _ only. It is stored with every server and product that uses it, so don’t change it later.
  • A code that a built-in module already uses (cwp, cpanel, directadmin, plesk, proxmox, virtualizor, solusvm, webuzo) is ignored.
  • Give the extension folder a name of your own, such as your company name. A folder with the name of an extension that ships with the billing would be overwritten by an update.
  • Use your own PHP namespace, not CwpBilling\....
  • Requires PHP 8.2 or newer, like the billing.

The module class

The class implements CwpBilling\Modules\Module. Every method returns a CwpBilling\Modules\Result: Result::ok('message', [data]) or Result::fail('message'). A module never throws for a remote error. It returns Result::fail() with a message staff can act on, and the billing shows that message and writes it to the module log.

<?php
declare(strict_types=1);

namespace MyCompany;

use CwpBilling\Modules\Http;
use CwpBilling\Modules\Module;
use CwpBilling\Modules\Result;

final class MyPanelModule implements Module
{
    /** Set by the billing: writes each API call to the module log (Services → service → Module log). */
    public ?\Closure $logger = null;

    public static function info(): array
    {
        return [
            'name' => 'My Panel',               // shown under Panel type / Module
            'port' => 8443,                     // default API port of a new server
            'key_help' => 'My Panel → Settings → API: create a key and enter it here',
            // settings of a product that uses this module: key => [label, type, help]
            // type: text, number, checkbox, or package (a list filled by packages() below)
            'product_config' => [
                'package' => ['Plan', 'package', 'The plan on My Panel'],
                'backups' => ['Daily backups', 'checkbox', ''],
            ],
        ];
    }

    public function test(array $server): Result
    {
        $r = $this->call($server, 'GET', '/api/ping', [], 'test');
        return $r->ok ? Result::ok('Connected.') : $r;
    }

    public function create(array $service, array $server): Result
    {
        $cfg = $service['product']['config'];
        $r = $this->call($server, 'POST', '/api/accounts', [
            'domain' => $service['domain'],
            'username' => $service['username'],
            'password' => $service['password'],
            'email' => $service['client']['email'],
            'plan' => (string)($cfg['package'] ?? ''),
            'backups' => !empty($cfg['backups']),
        ], 'create', (int)$service['id']);
        if (!$r->ok) return $r;
        // optional: what the panel assigned (only username and dedicated_ip are taken over)
        return Result::ok('Account created.', ['service_update' => ['dedicated_ip' => (string)($r->data['ip'] ?? '')]]);
    }

    public function suspend(array $service, array $server, string $reason): Result
    {
        return $this->call($server, 'POST', '/api/accounts/' . rawurlencode($service['username']) . '/suspend', ['reason' => $reason], 'suspend', (int)$service['id']);
    }

    public function unsuspend(array $service, array $server): Result
    {
        return $this->call($server, 'POST', '/api/accounts/' . rawurlencode($service['username']) . '/unsuspend', [], 'unsuspend', (int)$service['id']);
    }

    public function terminate(array $service, array $server): Result
    {
        return $this->call($server, 'DELETE', '/api/accounts/' . rawurlencode($service['username']), [], 'terminate', (int)$service['id']);
    }

    public function changePackage(array $service, array $server, string $package): Result
    {
        return $this->call($server, 'POST', '/api/accounts/' . rawurlencode($service['username']) . '/plan', ['plan' => $package], 'change_package', (int)$service['id']);
    }

    public function changePassword(array $service, array $server, string $password): Result
    {
        return $this->call($server, 'POST', '/api/accounts/' . rawurlencode($service['username']) . '/password', ['password' => $password], 'change_password', (int)$service['id']);
    }

    public function packages(array $server): array
    {
        $out = [];
        foreach ((array)($this->call($server, 'GET', '/api/plans', [], 'packages')->data['plans'] ?? []) as $p) $out[(string)$p['id']] = (string)$p['name'];
        return $out;   // [value => label]; empty when the server cannot be reached
    }

    public function loginUrl(array $service, array $server): ?string
    {
        return null;   // or a one-time login URL to the client's panel ("Log in to the control panel")
    }

    private function call(array $server, string $method, string $path, array $body, string $action, ?int $serviceId = null): Result
    {
        $url = 'https://' . $server['hostname'] . ':' . (int)$server['port'] . $path;
        [$code, $resp, $err] = Http::request($method, $url, ['Authorization: Bearer ' . $server['api_key'], 'Content-Type: application/json'],
            $method === 'GET' ? null : json_encode($body), (bool)$server['verify_ssl'], $action === 'create' ? 300 : 60);
        $json = $resp !== null ? json_decode($resp, true) : null;
        if ($resp === null) $res = Result::fail('Connection failed: ' . $err);
        elseif ($code >= 400 || !is_array($json)) $res = Result::fail('My Panel answered HTTP ' . $code . ': ' . mb_substr(strip_tags((string)$resp), 0, 200));
        else $res = Result::ok('OK', $json);
        if ($this->logger && $action !== 'packages') {
            unset($body['password']);   // never log passwords
            ($this->logger)($action, $serviceId, (int)$server['id'], $method . ' ' . $path . ' ' . json_encode($body), $res->ok ? 'OK' : $res->message, $res->ok);
        }
        return $res;
    }
}

What the module receives

$server is the servers row, with the API key already decrypted:

KeyMeaning
id, nameThe server in the billing
hostname, ipHost name (the billing calls the API there) and the shared IP for new accounts
portAPI port
api_keyThe API key as staff entered it (decrypted, only in memory)
verify_ssl1 = check the panel’s TLS certificate

$service is the services row, plus:

KeyMeaning
id, domain, username, status, dedicated_ip, billing_cycle, next_due_dateThe service
passwordThe account password (plain, only in memory). The billing sets a random one before create()
clientThe client row: email, firstname, lastname, companyname, country …
productThe product row; product['config'] holds the module settings from info()['product_config']
optionsThe client’s configurable options: option code => value of the chosen choice (quantity: the number)

Configurable options are how clients choose things at order time, such as the operating system, disk size or number of IPs. Give the option a code and each choice a value in the admin area, then read $service['options']['code'] in your module.

How the billing calls the module

  • Create runs when an order is paid, accepted, or created by hand (product setting Create the account). After Result::ok() the service becomes Active and the client gets the welcome e-mail.
  • Suspend and Unsuspend are called by the overdue automation, by staff, and when an overdue invoice is paid.
  • Terminate runs after the termination days, on a cancellation at the end of the period, or by staff.
  • Change package runs on an upgrade or downgrade to a product with another plan; Change password runs when staff change the password on the service page.
  • When the panel runs on the billing’s own server, or Run account actions from cron is ticked on the server, create, suspend, unsuspend, change package and terminate run from the cron job. Your module works the same way in both cases.
  • Calls take up to 300 seconds for create and terminate (the web request continues even when the browser gives up), 60 seconds otherwise. Use the timeouts of Http::request() as in the example.

CwpBilling\Modules\Http::request($method, $url, $headers, $body, $verifySsl, $timeoutSeconds) returns [$httpCode, $responseBody, $error]; $responseBody is null when the connection failed. You can also use PHP’s curl directly.

Testing

  1. Servers → Add server, choose your panel type, enter the host name, port and API key, then Test connection (calls test()).
  2. Create a product with your module, pick the plan (from packages()), and order it as a test client, or add a service by hand and use Create account on the service page.
  3. Every call and its answer is in the service’s Module log. Errors from PHP itself are in storage/logs/php-YYYY-MM-DD.log.

If the module does not show up under Panel type, check the PHP log: the billing logs an extension module that it skips (wrong code, class not found, or the class does not implement CwpBilling\Modules\Module).

Domain registrars

Registrars work the same way: list the class under registrars in extension.php, for example 'registrars' => ['myregistrar' => \MyCompany\MyRegistrar::class]. The class implements CwpBilling\Registrars\Registrar (info(), __construct(array $settings), check(), register(), transfer(), renew(), setNameservers(), epp(), lock(), sync()).

info() returns ['name' => …, 'settings' => [key => [label, text|password|checkbox, help]]]. Staff fill in these settings under Domains → Registrars, and the billing passes them to the constructor. A registrar that also has costPrices(array $tlds): array can be used for Import prices.

More an extension can do

Besides modules, an extension can add admin pages and routes, a panel on the client’s service page, daily jobs, console commands, its own database tables and its own settings. See extensions/README.md in the package.