Last week I joined a project to build a WHMCS provisioning module that connects a security platform to hosting provider billing systems. Here’s what I learned building it.

The Problem

Hosting providers want to offer additional services to their customers — security, backups, CDN. They use WHMCS for billing. The service has its own API. The two systems need to talk.

The bridge? A provisioning module.

How WHMCS Modules Work

WHMCS uses a callback-based architecture. You create a PHP file with specific function names, and WHMCS calls them at the right moments:

// modules/servers/myservice/myservice.php

function myservice_MetaData()
{
    return [
        'DisplayName' => 'My Service',
        'APIVersion' => '1.1',
        'RequiresServer' => true,
    ];
}

function myservice_ConfigOptions()
{
    return [
        'Plan' => [
            'Type' => 'dropdown',
            'Options' => [
                'basic' => 'Basic',
                'pro' => 'Professional',
                'business' => 'Business',
            ],
            'Description' => 'Subscription plan',
        ],
    ];
}

The naming convention is strict: {modulename}_{FunctionName}. WHMCS discovers and calls these automatically.

The Provisioning Lifecycle

Four functions handle the customer lifecycle:

Purchase  → myservice_CreateAccount()
Suspend   → myservice_SuspendAccount()
Unsuspend → myservice_UnsuspendAccount()
Cancel    → myservice_TerminateAccount()

Each receives a $params array containing everything: customer details, service configuration, server credentials.

CreateAccount: The Heavy Lifting

This is where the magic happens. When a customer completes purchase, WHMCS calls CreateAccount:

function myservice_CreateAccount(array $params)
{
    $api = new ServiceApi($params['serveraccesshash']);
    
    // Step 1: Find or create user
    $user = $api->findUserByEmail($params['clientsdetails']['email']);
    
    if (!$user) {
        $password = generateSecurePassword();
        $user = $api->createUser([
            'firstname' => $params['clientsdetails']['firstname'],
            'lastname' => $params['clientsdetails']['lastname'],
            'email' => $params['clientsdetails']['email'],
            'password' => $password,
            'password_confirmation' => $password,
        ]);
    }
    
    // Step 2: Provision the service
    $service = $api->createService([
        'domain' => $params['domain'],
        'user_id' => $user['id'],
        'plan' => $params['configoption1'],
    ]);
    
    // Step 3: Store credentials for client area
    saveCustomField($params['serviceid'], 'service_id', $service['id']);
    saveCustomField($params['serviceid'], 'license_key', $service['license']['key']);
    
    return 'success';
}

The API Client

Clean separation. The module calls a simple API wrapper:

class ServiceApi
{
    private string $baseUrl;
    private string $token;
    
    public function __construct(string $token, string $baseUrl = 'https://api.example.com')
    {
        $this->token = $token;
        $this->baseUrl = $baseUrl;
    }
    
    public function createUser(array $data): array
    {
        return $this->request('POST', '/users', $data);
    }
    
    public function createService(array $data): array
    {
        return $this->request('POST', '/services', $data);
    }
    
    public function suspendService(int $id): array
    {
        return $this->request('POST', "/services/{$id}/suspend");
    }
    
    public function deleteService(int $id): array
    {
        return $this->request('DELETE', "/services/{$id}");
    }
    
    private function request(string $method, string $endpoint, array $data = []): array
    {
        $ch = curl_init($this->baseUrl . $endpoint);
        
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json',
                'Accept: application/json',
                'Authorization: Bearer ' . $this->token,
            ],
            CURLOPT_POSTFIELDS => $method !== 'GET' ? json_encode($data) : null,
        ]);
        
        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        
        if ($httpCode >= 400) {
            throw new Exception("API error: {$httpCode} - {$response}");
        }
        
        return json_decode($response, true);
    }
}

Suspend and Terminate

These are simpler. Find the service, change its state:

function myservice_SuspendAccount(array $params)
{
    $api = new ServiceApi($params['serveraccesshash']);
    $serviceId = getCustomField($params['serviceid'], 'service_id');
    
    try {
        $api->suspendService($serviceId);
        return 'success';
    } catch (Exception $e) {
        return $e->getMessage();
    }
}

function myservice_TerminateAccount(array $params)
{
    $api = new ServiceApi($params['serveraccesshash']);
    $serviceId = getCustomField($params['serviceid'], 'service_id');
    
    try {
        $api->deleteService($serviceId);
        return 'success';
    } catch (Exception $e) {
        return $e->getMessage();
    }
}

The Gotchas

Every integration has them. Here are the ones that cost me hours:

1. Password Confirmation

If your API uses Laravel, registration requires password_confirmation to match password. Easy to forget when generating passwords programmatically.

// Wrong
$api->createUser(['password' => $pass]);

// Right
$api->createUser([
    'password' => $pass,
    'password_confirmation' => $pass,
]);

2. Foreign Key Constraints

Watch out for required foreign keys with non-obvious defaults. Passing 0 or null gives you a constraint violation — often with an unhelpful 500 error.

// Wrong - foreign key violation
'type_id' => 0

// Right - use a valid default
'type_id' => 1

3. Token Configuration

WHMCS server configuration has two credential fields: “Password” and “Access Hash”. Different WHMCS versions behave differently. Safe approach: tell users to put the API token in both fields.

4. Error Return Format

WHMCS expects specific return values:

  • 'success' — string, literally
  • Any other string — treated as error message
// Success
return 'success';

// Error
return 'Failed to create service: ' . $e->getMessage();

Testing the Integration

This was harder than writing the code. The setup:

  1. Local WHMCS — Install WHMCS on a dev server
  2. ngrok tunnel — Expose local API for webhook testing
  3. Test products — Create products linked to the module
  4. Fake purchases — Walk through the entire customer flow
# Expose local API
ngrok http 8000

# Watch the logs
tail -f storage/logs/laravel.log

The debugging cycle: purchase product, check WHMCS module log, check API logs, fix, repeat.

Client Area Integration

Customers need to see their credentials and access the service. WHMCS supports custom client area output:

function myservice_ClientArea(array $params)
{
    $licenseKey = getCustomField($params['serviceid'], 'license_key');
    $serviceId = getCustomField($params['serviceid'], 'service_id');
    
    return [
        'templatefile' => 'clientarea',
        'vars' => [
            'license_key' => $licenseKey,
            'dashboard_url' => "https://app.example.com/services/{$serviceId}",
        ],
    ];
}

With a simple template:

<div class="service-info">
    <h3>Service Details</h3>
    <p><strong>License Key:</strong> {$license_key}</p>
    <p><a href="{$dashboard_url}" target="_blank" class="btn btn-primary">
        Open Dashboard
    </a></p>
</div>

The Result

A self-contained module that hosting providers drop into /modules/servers/. Five minutes to configure:

  1. Add server with API token
  2. Create product linked to module
  3. Customers purchase, services get provisioned

No manual provisioning. No support tickets asking “where’s my license?”

What I Learned

WHMCS module development is approachable once you understand the callback pattern. The official docs are decent but incomplete — you learn the edge cases by hitting them.

The real complexity isn’t the module. It’s the integration testing. Setting up environments, debugging request/response cycles across two systems, handling the dozen ways things can fail.

But that’s the job. We build bridges between systems that were never designed to talk to each other. And when it works — when a customer clicks “buy” and thirty seconds later their service is active — that’s the payoff.