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:
- CreateAccount — the customer paid; spin up the account.
- SuspendAccount — payment failed or admin requested suspension; lock them out.
- UnsuspendAccount — they paid; turn them back on.
- TerminateAccount — service cancelled; delete the account cleanly.
- ChangePassword — admin or client triggered a password reset.
- 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:
- Every function must return the string
'success'on success, or an error string on failure. Returningtrueornullor throwing is treated as failure. This trips up everyone the first time. - 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:
| Key | Description |
|---|---|
$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
$paramsarray WHMCS passed in (sanitize sensitive fields likeserverpasswordfirst!) - 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:
- Stand up a test product in WHMCS that uses your module.
- Create a test client and place an order against that product, paying with a free coupon so no real money moves.
- 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.
- 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
AutomatedTaskcalls 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
- Official provisioning module docs
- Sample module on GitHub — useful skeleton
- ChangePackage docs — the function people skip and then regret
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.