Shahid Malla

Building Custom WHMCS Provisioning Modules: A Real-World Developer Guide

The complete model for building production-quality WHMCS provisioning modules. The contract, the params array, idempotency rules, and the patterns that prevent 90% of incidents I have seen in 200+ modules.

S Shahid Malla
· Dec 6, 2025 · 9 min read · 130 views
shahidmalla.com/blog/building-custom-whmcs-provisioning-modules-a-real-world-developer-guide
Building Custom WHMCS Provisioning Modules: A Real-World Developer Guide
On this page (13 sections)

A WHMCS provisioning module is what turns "the customer clicked Buy" into "the customer has a working account on your service." Get it right, and your business runs hands-off — orders flow in, accounts get created, suspensions happen on time, terminations clean up properly. Get it wrong, and your support inbox becomes a job.

I've built WHMCS provisioning modules for cPanel clusters, custom SaaS platforms, IPTV panels (Xtream Codes), and a handful of weirder things. This is the model that actually works in production.

What a provisioning module actually does

A provisioning module is a contract between WHMCS and your service. WHMCS calls your module's functions at six lifecycle moments:

  1. CreateAccount — the customer paid; spin up the account.
  2. SuspendAccount — payment failed or admin requested suspension; lock them out.
  3. UnsuspendAccount — they paid; turn them back on.
  4. TerminateAccount — service cancelled; delete the account cleanly.
  5. ChangePassword — admin or client triggered a password reset.
  6. ChangePackage — upgrade or downgrade.

Plus optional extras: custom client-area pages, admin-side service buttons, single sign-on URLs.

Your job: implement the contract, idempotently, with proper error handling.

Where modules live

/modules/servers/your_module_name/
  your_module_name.php   # the entry file (name MUST match folder name)
  hooks.php              # optional — module-specific hooks
  templates/
    overview.tpl         # optional — client area override
  README.md

The folder name is the module name throughout WHMCS. Pick it carefully: it's hard to rename later because it's referenced from the database (tblproducts.servertype) and from URLs.

The absolute minimum viable module

Let's build the smallest module that WHMCS will accept and successfully call. Create /modules/servers/demo_panel/demo_panel.php:

<?php

if (!defined('WHMCS')) {
    die('This file cannot be accessed directly');
}

function demo_panel_MetaData()
{
    return [
        'DisplayName'         => 'Demo Panel',
        'APIVersion'          => '1.1',
        'RequiresServer'      => true,
        'DefaultNonSSLPort'   => '80',
        'DefaultSSLPort'      => '443',
    ];
}

function demo_panel_ConfigOptions()
{
    return [
        'Plan' => [
            'Type'        => 'dropdown',
            'Options'     => 'starter,pro,enterprise',
            'Description' => 'Which plan to provision on the remote panel',
        ],
        'API Region' => [
            'Type'    => 'text',
            'Default' => 'us-east',
        ],
    ];
}

function demo_panel_CreateAccount(array $params)
{
    try {
        $api = new DemoPanelClient(
            $params['serverhostname'],
            $params['serverusername'],
            $params['serverpassword']
        );

        $result = $api->createUser([
            'username' => $params['username'],
            'password' => $params['password'],
            'email'    => $params['clientsdetails']['email'],
            'plan'     => $params['configoption1'],   // matches ConfigOptions order
            'region'   => $params['configoption2'],
        ]);

        logModuleCall(
            'demo_panel',
            'CreateAccount',
            $params,
            $result
        );

        return 'success';
    } catch (\Throwable $e) {
        logModuleCall('demo_panel', 'CreateAccount', $params, $e->getMessage(), $e->getTraceAsString());
        return $e->getMessage();
    }
}

function demo_panel_SuspendAccount(array $params)    { /* ... */ return 'success'; }
function demo_panel_UnsuspendAccount(array $params)  { /* ... */ return 'success'; }
function demo_panel_TerminateAccount(array $params)  { /* ... */ return 'success'; }
function demo_panel_ChangePassword(array $params)    { /* ... */ return 'success'; }
function demo_panel_ChangePackage(array $params)     { /* ... */ return 'success'; }

Two non-obvious rules baked into this code:

  1. Every function must return the string 'success' on success, or an error string on failure. Returning true or null or throwing is treated as failure. This trips up everyone the first time.
  2. Wrap every external call in try/catch. Unhandled exceptions are turned into stack traces in the WHMCS module log, which is fine for you but leaks code paths if a customer ever sees the error. Return clean error messages.

The $params array — what you actually get

WHMCS passes a fat associative array to every module function. The keys you'll use most:

KeyDescription
$params['serviceid']WHMCS service ID — your primary key.
$params['username']Username WHMCS generated or admin entered.
$params['password']Password (already decrypted from tblhosting).
$params['domain']Domain attached to the service.
$params['serverhostname']Server's hostname from the WHMCS server config.
$params['serverusername']API username for the server (e.g. root).
$params['serverpassword']API password / token (decrypted).
$params['serveraccesshash']Some panels use this instead of password.
$params['configoption1..24']Your product config options, in declaration order.
$params['customfields']Array of product custom fields.
$params['configoptions']Array of upgrade/downgrade configurable options.
$params['clientsdetails']Full client record — email, name, country, etc.

The full reference is in the official docs, but these are the 90%.

Idempotency — the rule that prevents 90% of incidents

Every module function must be safe to call twice. WHMCS will retry. Admins will click the button again "to see if it works." If CreateAccount creates a second user on the remote panel because someone double-clicked, you have a problem.

The right pattern:

function demo_panel_CreateAccount(array $params)
{
    try {
        $api = new DemoPanelClient(/* ... */);

        // Check first — does this username already exist?
        $existing = $api->getUser($params['username']);
        if ($existing) {
            // Account exists — verify it matches what we expect, then succeed.
            if ($existing['email'] !== $params['clientsdetails']['email']) {
                return "Username conflict: a user with this name exists with a different email.";
            }
            return 'success';   // already provisioned, no-op
        }

        $api->createUser([/* ... */]);
        return 'success';
    } catch (\Throwable $e) {
        return $e->getMessage();
    }
}

Same principle for TerminateAccount: if the account no longer exists, return success (it's already in the desired state) — don't return an error.

Logging — use logModuleCall, not file_put_contents

WHMCS gives you logModuleCall($module, $action, $request, $response, $trace = null). It writes to the Module Log accessible from Utilities → Logs → Module Log. Use it on every external call.

What I include in every logModuleCall:

  • The $params array WHMCS passed in (sanitize sensitive fields like serverpassword first!)
  • The HTTP request body you sent
  • The full response (status code, body)
  • If it failed, a stack trace

This is what saves you at 2 AM when a customer says "my account isn't working." You open the log, find the exact request, see the API error, fix it.

Custom client area output

The ClientArea function lets you render content on the service detail page. Build it to look like part of your client area, not like a wall of debug output.

function demo_panel_ClientArea(array $params)
{
    try {
        $api = new DemoPanelClient(/* ... */);
        $stats = $api->getUsage($params['username']);

        return [
            'templatefile'   => 'overview',  // resolves to templates/overview.tpl
            'vars'           => [
                'disk_used'   => $stats['disk_used_mb'],
                'disk_total'  => $stats['disk_total_mb'],
                'bandwidth_used' => $stats['bw_used_gb'],
                'panel_login_url' => $api->getSsoUrl($params['username']),
            ],
        ];
    } catch (\Throwable $e) {
        return ['templatefile' => 'error', 'vars' => ['message' => 'Could not fetch usage data.']];
    }
}

Then /modules/servers/demo_panel/templates/overview.tpl renders the data with Smarty.

Single sign-on to your panel

The function name is LoginLink (returns a URL string) or, for richer control, ServiceSingleSignOn. I use ServiceSingleSignOn because it returns success/failure cleanly:

function demo_panel_ServiceSingleSignOn(array $params)
{
    try {
        $api = new DemoPanelClient(/* ... */);
        $url = $api->createOneTimeLoginUrl($params['username']);  // expires in 60s

        return [
            'success'  => true,
            'redirectTo' => $url,
        ];
    } catch (\Throwable $e) {
        return ['success' => false, 'errorMsg' => $e->getMessage()];
    }
}

WHMCS now shows a "Login to Panel" button in the client area and on the admin service page. Both call this function and 302 the user to the URL you return.

Testing locally without breaking anything

Steps I follow on every new module:

  1. Stand up a test product in WHMCS that uses your module.
  2. Create a test client and place an order against that product, paying with a free coupon so no real money moves.
  3. From the admin service page, manually click Create, Suspend, Unsuspend, Terminate. Verify the module log shows the calls and the remote panel reflects each state.
  4. Run the WHMCS cron with verbose output: php /path/to/whmcs/crons/cron.php do --verbose. This surfaces issues your manual testing missed.

If you're calling a third-party API, build a "dry run" mode into your module — a config option that, when enabled, logs what would have been sent but doesn't actually call the API. Saves you from accidentally provisioning real accounts during dev.

How to verify your module works end-to-end

  • Utilities → Logs → Module Log — shows every module call, request, response.
  • Setup → Products/Services → Servers → your server → Test Connection — confirms WHMCS can reach the remote server with the configured credentials.
  • From the service page, run each lifecycle action and watch the log entries appear.
  • Test from cron: trigger a renewal scenario and watch AutomatedTask calls run cleanly.

Common pitfalls

"Module not detected." Folder name doesn't match file name, or you forgot defined('WHMCS'). WHMCS scans /modules/servers/ at startup; if the file errors out, the module disappears from the dropdown silently.

"Encrypted password is garbled in my module." WHMCS automatically decrypts $params['password'] and $params['serverpassword'] for you. If you see ciphertext, you're reading the raw tblhosting.password instead of the $params array.

"It works manually but not from cron." The cron user's environment is different. Most often: $_SERVER['HTTP_HOST'] is empty under CLI. Don't rely on it inside module functions.

"Long-running CreateAccount times out." WHMCS cron has a wall-clock limit per service. If your remote API takes 20+ seconds to provision, return success immediately and finish the work asynchronously via a queue.

My take — when to write a module vs. a hook

  • Module: the integration has a clear lifecycle (create/suspend/terminate), needs its own configuration, and you'll bind WHMCS products to it.
  • Hook: the integration is "react to X event, do Y." No lifecycle, no product binding.
  • Both: rare but useful — module for the lifecycle, hooks for cross-cutting concerns like "notify Slack on every provisioning success/failure."

If you find yourself building a module that does several unrelated things to the customer's account, you're probably actually building two modules. Split early; it's harder to split later.

Going further


I build provisioning modules for hosting panels, SaaS platforms, IPTV systems, and unusual one-off APIs. If your business needs WHMCS to talk to something it doesn't already, tell me what you're building — I'll send a quote in 24 hours.

Share this article

S

Written by

Shahid Malla

WHMCS expert, full-stack developer, technical lead at Fada.cloud. 10+ years building hosting platforms, custom modules, and automation that ships.

Trusted platforms

Prefer to hire through a platform?

Not sure about working directly? Hire me through Fiverr or Upwork instead - same me, same work, with the platform's buyer protection and escrow.

Got a project like this?

Tell me what you need - I'll send a real quote within 24 hours.