Kemp.rest / Examples / Code / WHMCS module
KEMP.REST / EXAMPLES / CODE
WHMCS module: accounts, SSO, and city billing
Written by Kevin Kemp in PHP and Laravel for AnswerPins. WHMCS, MySQL, cURL, and JSON. The shared API key stays in WHMCS and in the app environment, not in this code.
View the codeWHMCS takes the payment. AnswerPins has to exist the moment that order is active, with the right plan, the cities the customer paid for, and a way into the dashboard that does not ask for a second password. This is a WHMCS server module plus the Laravel API it calls. Buying, suspending, changing the plan, and opening the app are the same service id on both sides.
Type of code
A WHMCS provisioning module and addon, written in PHP for WHMCS, and a Laravel API that receives it. The module runs inside WHMCS. The app runs on its own host. They share a bearer key stored in WHMCS as the server access hash.
Technology
WHMCS module hooks (CreateAccount, ChangePackage, ServiceSingleSignOn), cURL JSON calls, MySQL through WHMCS Capsule against the configurable-option tables, Laravel routes behind hash_equals on the bearer token, a database transaction for the account and user, and Laravel temporary signed URLs. The API key is never written in these files.
Timing
- Create, suspend, unsuspend, terminate, and plan change are synchronous. The module waits up to 30 seconds for the app.
- The customer SSO link lasts 60 seconds. The admin link lasts 5 minutes. Both carry a random nonce. The app stores that nonce for 10 minutes and refuses it the second time, so a copied link does not sign anyone in again.
- A new trial service dispatches a job that sets the next due date to 3 days after signup. WHMCS has no trial period of its own. The job tries 5 times, backing off at 1 minute, 5 minutes, 15 minutes, and 1 hour. A mail failure on the signup alert is logged and does not fail the order.
What it does
- On create, WHMCS sends the client, email, plan, limits, and the hidden onboarding token. The app creates or updates the account for that service id, attaches the user, and links a business that was started with that token. A second service for the same client gets its own account and does not move the login they already use.
- Cities are a configurable option named “Cities” or “Add a city”. A quantity option uses the qty. A dropdown uses the choice, so both “2” and “2 cities” count as 2. If the module payload omits the option, the module reads it from the service’s stored config. A later plan change that only sends cities keeps the services and towns already on the account, including a city count of 0, so a purchase is not wiped.
- An upgrade turns tracking on for services and towns up to the new limits. A downgrade keeps the oldest tracked ones and turns the rest off, then rebuilds the question list.
- “Open dashboard” in the client area calls the module SSO action. The app returns a signed URL. WHMCS redirects there. The app signs that user in and sends an admin to admin, everyone else to the dashboard.
- The admin addon does the same for staff. WHMCS vouches for the signed-in admin. The app finds or creates the staff user, preferring an existing login with that email so it does not make a duplicate, and returns a one-time admin link.
Problem it solved
An order in WHMCS became a live AnswerPins account, with the cities that were billed, and both the customer and the staff could open the right account without a separate password.
Code
Back to summary · WHMCS server module, client hook, Laravel provisioning API, one-time sign-in, admin addon
// —— whmcs/modules/servers/answerpins/answerpins.php ——
<?php
/**
* AnswerPins provisioning module for WHMCS.
*
* Server setup (Setup > Servers): Hostname = app host (e.g. app.answerpins.com),
* Access Hash = the app's WHMCS_API_KEY, Secure = on.
* Product setup: Module = AnswerPins, set the plan and limits below, and add a
* hidden product custom field named "Onboarding token".
*/
if (!defined('WHMCS')) {
die('This file cannot be accessed directly');
}
function answerpins_MetaData()
{
return [
'DisplayName' => 'AnswerPins',
'APIVersion' => '1.1',
'RequiresServer' => true,
'ServiceSingleSignOnLabel' => 'Open dashboard',
];
}
function answerpins_ConfigOptions()
{
return [
// Self Managed and Pro Managed limits are set by the app. The fields below are only used for Custom plans.
'Plan' => ['Type' => 'dropdown', 'Options' => 'Self Managed,Pro Managed,Custom', 'Default' => 'Self Managed'],
'Custom: locations' => ['Type' => 'text', 'Size' => '5', 'Default' => '', 'Description' => 'Custom plans only'],
'Custom: services' => ['Type' => 'text', 'Size' => '5', 'Default' => '', 'Description' => 'Custom plans only'],
'Custom: towns' => ['Type' => 'text', 'Size' => '5', 'Default' => '', 'Description' => 'Custom plans only'],
];
}
function answerpins_call(array $params, $action, array $body)
{
$scheme = !empty($params['serversecure']) ? 'https' : 'http';
$url = $scheme . '://' . $params['serverhostname'] . '/api/whmcs/' . $action;
$body['service_id'] = (int) $params['serviceid'];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . trim($params['serveraccesshash']),
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
logModuleCall('answerpins', $action, $body, $raw, null, [$params['serveraccesshash']]);
if ($raw === false) {
return ['error' => 'Connection failed: ' . $error];
}
$data = json_decode($raw, true);
if ($status >= 400 || !is_array($data) || ($data['result'] ?? '') !== 'success') {
return ['error' => 'HTTP ' . $status . ': ' . ($data['message'] ?? $data['error'] ?? substr((string) $raw, 0, 200))];
}
return $data;
}
/** Plan names shown in WHMCS mapped to the app's plan keys. The $1 trial product is Self Managed too. */
function answerpins_plan(array $params)
{
$map = ['self managed' => 'owner', 'pro managed' => 'managed', 'custom' => 'custom', 'owner' => 'owner', 'managed' => 'managed'];
return $map[strtolower(trim((string) $params['configoption1']))] ?? 'owner';
}
/**
* Custom plans send their limits; Self Managed and Pro Managed use the app's own limits for that plan.
* Cities come from the configurable option (dropdown or quantity). The option name can be "Cities"
* or "Add a city", including the "|invoice text" WHMCS appends. "2" and "2 cities" both count as 2.
*/
function answerpins_limits(array $params)
{
$limits = [];
if (answerpins_plan($params) === 'custom') {
$limits = array_filter([
'locations' => (int) $params['configoption2'],
'services' => (int) $params['configoption3'],
'towns' => (int) $params['configoption4'],
]);
}
$cities = answerpins_cities_in($params['configoptions'] ?? []);
if ($cities === null) {
$cities = answerpins_cities_on_service((int) ($params['serviceid'] ?? 0));
}
if ($cities !== null) {
$limits['cities'] = $cities;
}
return $limits === [] ? null : $limits;
}
/** The chosen city count, from an option whose name mentions a city. Null when that option is not on the product. */
function answerpins_cities_in(array $options): ?int
{
foreach ($options as $name => $value) {
$short = trim(explode('|', (string) $name, 2)[0]);
if (!preg_match('/\bcit(?:y|ies)\b/i', $short)) {
continue;
}
$chosen = trim(explode('|', (string) $value, 2)[0]);
if (preg_match('/\d+/', $chosen, $m)) {
return (int) $m[0];
}
if (preg_match('/^(yes|on|true)$/i', $chosen)) {
return 1;
}
return 0;
}
return null;
}
/**
* The city count stored on the service. Used when the module params omit the option name.
* Quantity options store the count in qty. Dropdown options store it in the choice name ("2" or "2 cities").
*/
function answerpins_cities_on_service(int $serviceId): ?int
{
if ($serviceId < 1 || !class_exists('WHMCS\\Database\\Capsule')) {
return null;
}
try {
$rows = \WHMCS\Database\Capsule::table('tblhostingconfigoptions')
->join('tblproductconfigoptions', 'tblproductconfigoptions.id', '=', 'tblhostingconfigoptions.configid')
->leftJoin('tblproductconfigoptionssub', 'tblproductconfigoptionssub.id', '=', 'tblhostingconfigoptions.optionid')
->where('tblhostingconfigoptions.relid', $serviceId)
->get(['tblproductconfigoptions.optionname', 'tblproductconfigoptions.optiontype', 'tblhostingconfigoptions.qty', 'tblproductconfigoptionssub.optionname as choice']);
} catch (\Throwable $e) {
return null;
}
foreach ($rows as $row) {
$parsed = answerpins_cities_in([(string) $row->optionname => (int) $row->optiontype === 4 ? (string) $row->qty : (string) $row->choice]);
if ($parsed !== null) {
return $parsed;
}
}
return null;
}
function answerpins_result($data)
{
return isset($data['error']) ? $data['error'] : 'success';
}
function answerpins_CreateAccount(array $params)
{
$client = $params['clientsdetails'];
return answerpins_result(answerpins_call($params, 'create', [
'client_id' => (int) $params['userid'],
'email' => $client['email'],
'name' => trim($client['firstname'] . ' ' . $client['lastname']),
'plan' => answerpins_plan($params),
'limits' => answerpins_limits($params),
'onboarding_token' => $params['customfields']['Onboarding token'] ?? null ?: null,
]));
}
function answerpins_SuspendAccount(array $params)
{
return answerpins_result(answerpins_call($params, 'suspend', ['reason' => $params['suspendreason'] ?? null]));
}
function answerpins_UnsuspendAccount(array $params)
{
return answerpins_result(answerpins_call($params, 'unsuspend', []));
}
function answerpins_TerminateAccount(array $params)
{
return answerpins_result(answerpins_call($params, 'terminate', []));
}
function answerpins_ChangePackage(array $params)
{
return answerpins_result(answerpins_call($params, 'change-plan', [
'plan' => answerpins_plan($params),
'limits' => answerpins_limits($params),
]));
}
/** The service's page in the client area: one big button into the dashboard. */
function answerpins_ClientArea(array $params)
{
return [
'templatefile' => 'clientarea',
'vars' => ['ssoUrl' => 'clientarea.php?action=productdetails&id=' . (int) $params['serviceid'] . '&dosinglesignon=1'],
];
}
function answerpins_ServiceSingleSignOn(array $params)
{
$data = answerpins_call($params, 'sso', ['client_id' => (int) $params['userid']]);
if (isset($data['error'])) {
return ['success' => false, 'errorMsg' => $data['error']];
}
return ['success' => true, 'redirectTo' => $data['redirect_url']];
}
// —— whmcs/modules/servers/answerpins/hooks.php ——
<?php
/**
* AnswerPins in the WHMCS client area: a panel at the top of the client's home page with a button that signs
* them straight into their AnswerPins dashboard, so the order never ends in a dead end.
* WHMCS loads this file by itself from the module folder; nothing to switch on.
*/
use WHMCS\Database\Capsule;
if (!defined('WHMCS')) {
die('This file cannot be accessed directly');
}
/** The client's active AnswerPins services, newest first. */
function answerpins_client_services($clientId)
{
return Capsule::table('tblhosting')
->join('tblproducts', 'tblproducts.id', '=', 'tblhosting.packageid')
->where('tblhosting.userid', $clientId)
->where('tblproducts.servertype', 'answerpins')
->whereIn('tblhosting.domainstatus', ['Active', 'Pending'])
->orderBy('tblhosting.id', 'desc')
->get(['tblhosting.id', 'tblhosting.domainstatus', 'tblproducts.name']);
}
add_hook('ClientAreaHomepagePanels', 1, function ($panels) {
$client = Menu::context('client');
if (!$client) {
return;
}
$services = answerpins_client_services($client->id);
if (!count($services)) {
return;
}
$open = $services[0];
$url = 'clientarea.php?action=productdetails&id=' . (int) $open->id . '&dosinglesignon=1';
$body = $open->domainstatus === 'Active'
? '<p style="margin:0 0 12px;font-size:16px">Your dashboard shows where you rank on Google Maps, in Google results and in AI answers, and what to fix next.</p>'
: '<p style="margin:0 0 12px;font-size:16px">Your AnswerPins account is being set up. It\'s ready in a minute or two.</p>';
$body .= '<p style="margin:0"><a class="btn btn-primary btn-lg" href="' . htmlspecialchars($url) . '">Open my AnswerPins dashboard →</a></p>';
$panels->addChild('answerpins', [
'label' => 'AnswerPins',
'icon' => 'fa-map-marker-alt',
'order' => 1,
'bodyHtml' => '<div style="padding:16px">' . $body . '</div>',
]);
});
// —— whmcs/modules/servers/answerpins/clientarea.tpl ——
<div style="text-align:center;padding:24px 16px">
<p style="font-size:18px;margin:0 0 16px">Everything for this plan is in your AnswerPins dashboard.</p>
<a class="btn btn-primary btn-lg" href="{$ssoUrl}">Open my AnswerPins dashboard →</a>
</div>
// —— routes/api.php ——
<?php
use App\Http\Controllers\Whmcs\AdminSsoController;
use App\Http\Controllers\Whmcs\ProvisioningController;
use App\Http\Middleware\VerifyWhmcsKey;
use Illuminate\Support\Facades\Route;
Route::middleware(VerifyWhmcsKey::class)->prefix('whmcs')->controller(ProvisioningController::class)->group(function () {
Route::post('create', 'create');
Route::post('suspend', 'suspend');
Route::post('unsuspend', 'unsuspend');
Route::post('terminate', 'terminate');
Route::post('change-plan', 'changePlan');
Route::post('sso', 'sso');
});
Route::post('whmcs/admin-sso', AdminSsoController::class)->middleware(VerifyWhmcsKey::class);
// —— app/Http/Middleware/VerifyWhmcsKey.php ——
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class VerifyWhmcsKey
{
public function handle(Request $request, Closure $next): Response
{
$key = (string) config('answerpins.whmcs.api_key');
if ($key === '' || ! hash_equals($key, (string) $request->bearerToken())) {
return response()->json(['error' => 'Unauthorized'], 401);
}
return $next($request);
}
}
// —— app/Http/Controllers/Whmcs/ProvisioningController.php ——
<?php
namespace App\Http\Controllers\Whmcs;
use App\Http\Controllers\Controller;
use App\Jobs\StartTrialClock;
use App\Mail\NewSignupAlert;
use App\Models\Account;
use App\Models\Business;
use App\Models\User;
use App\Services\ProfileAudit;
use App\Services\QuestionGenerator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
class ProvisioningController extends Controller
{
/** WHMCS may send the plan by its shown name; the app stores its own keys. */
private const PLAN_KEYS = ['self managed' => 'owner', 'pro managed' => 'managed', 'custom' => 'custom'];
private function planKey(string $plan): string
{
return self::PLAN_KEYS[strtolower(trim($plan))] ?? $plan;
}
public function create(Request $request): JsonResponse
{
$data = $request->validate([
'service_id' => 'required|integer',
'client_id' => 'required|integer',
'email' => 'required|email',
'name' => 'required|string',
'plan' => 'required|string',
'limits' => 'nullable|array',
'onboarding_token' => 'nullable|uuid',
]);
$account = DB::transaction(function () use ($data) {
$plan = $this->planKey($data['plan']);
$existing = Account::where('whmcs_service_id', $data['service_id'])->first();
$account = Account::updateOrCreate(
['whmcs_service_id' => $data['service_id']],
[
'whmcs_client_id' => $data['client_id'],
'plan' => $plan,
'limits' => $this->storedLimits($plan, $existing?->limits, $data['limits'] ?? null),
'status' => 'active',
'suspend_reason' => null,
],
);
$user = User::where('whmcs_client_id', $data['client_id'])->first()
?? User::where('email', $data['email'])->first()
?? new User;
// A second service for this client gets its own account. Buying it must not move the login off the one they already use.
$keep = $user->exists
&& $user->account_id
&& (int) $user->account_id !== (int) $account->id
&& (int) $user->whmcs_client_id === (int) $data['client_id'];
$user->fill([
'name' => $data['name'],
'email' => $data['email'],
'whmcs_client_id' => $data['client_id'],
'account_id' => $keep ? $user->account_id : $account->id,
])->save();
if (! empty($data['onboarding_token'])) {
Business::where('onboarding_token', $data['onboarding_token'])
->whereNull('account_id')
->update(['account_id' => $account->id]);
}
return $account;
});
foreach ($account->businesses as $business) {
app(QuestionGenerator::class)->sync($business);
app(ProfileAudit::class)->run($business);
}
if ($account->wasRecentlyCreated) {
$this->alertStaff($account, $data);
StartTrialClock::dispatch($data['service_id']); // $1 trial: bill the monthly price on day 3
}
return response()->json(['result' => 'success', 'account_id' => $account->id]);
}
public function suspend(Request $request): JsonResponse
{
$account = $this->account($request);
$account->update(['status' => 'suspended', 'suspend_reason' => $request->input('reason')]);
return response()->json(['result' => 'success']);
}
public function unsuspend(Request $request): JsonResponse
{
$account = $this->account($request);
$account->update(['status' => 'active', 'suspend_reason' => null]);
return response()->json(['result' => 'success']);
}
public function terminate(Request $request): JsonResponse
{
$account = $this->account($request);
$account->update(['status' => 'terminated']);
return response()->json(['result' => 'success']);
}
public function changePlan(Request $request): JsonResponse
{
$data = $request->validate(['plan' => 'required|string', 'limits' => 'nullable|array']);
$account = $this->account($request);
$data['plan'] = $this->planKey($data['plan']);
// A city purchase sends only the cities count. Keep the services and towns already on the account.
$account->update(['plan' => $data['plan'], 'limits' => $this->storedLimits($data['plan'], $account->limits, $data['limits'] ?? null)]);
// An upgrade switches on the services and towns that were saved but not tracked; a downgrade trims to the new limits.
$limits = ($account->limits ?? []) + (config('answerpins.plans.'.$data['plan'].'.limits') ?? []);
foreach ($account->businesses as $business) {
foreach (['services', 'towns'] as $kind) {
if (! isset($limits[$kind])) {
continue;
}
$keep = $business->$kind()->orderByDesc('tracked')->orderBy('id')->limit($limits[$kind])->pluck('id');
$business->$kind()->update(['tracked' => false]);
$business->$kind()->whereIn('id', $keep)->update(['tracked' => true]);
}
app(QuestionGenerator::class)->sync($business->fresh());
}
return response()->json(['result' => 'success']);
}
/**
* Returns a one-time sign-in link, valid for 60 seconds, for "Open dashboard" in WHMCS.
*/
public function sso(Request $request): JsonResponse
{
$account = $this->account($request);
$clientId = $request->integer('client_id') ?: (int) $account->whmcs_client_id;
$user = $account->users()->when($request->filled('client_id'), fn ($q) => $q->where('whmcs_client_id', $request->integer('client_id')))->first()
?? User::where('whmcs_client_id', $clientId)->first()
?? $account->users()->firstOrFail();
// "Open dashboard" for this service opens this service. The purchase itself does not move the login.
if ((int) $user->whmcs_client_id === (int) $account->whmcs_client_id && (int) $user->account_id !== (int) $account->id) {
$user->update(['account_id' => $account->id]);
}
$url = URL::temporarySignedRoute('sso.login', now()->addSeconds(60), [
'user' => $user->id,
'nonce' => bin2hex(random_bytes(16)),
]);
return response()->json(['result' => 'success', 'redirect_url' => $url]);
}
/**
* Emails PressCare about the new signup. A mail failure must not fail the WHMCS order, so it is only logged.
*/
private function alertStaff(Account $account, array $data): void
{
$to = config('answerpins.alerts_email');
if (! $to) {
return;
}
try {
$business = $account->businesses()->with('services', 'towns', 'facts')->first();
Mail::to($to)->send(new NewSignupAlert($account, $business, $data['name'], $data['email']));
} catch (\Throwable $e) {
Log::error('Signup alert email failed', ['account_id' => $account->id, 'error' => $e->getMessage()]);
}
}
/**
* Plan services and towns fill in anything the payload leaves out. Keys the payload does send, including cities: 0, win.
* A later create or plan change therefore cannot wipe a city the customer already bought.
*/
private function storedLimits(string $plan, ?array $current, ?array $incoming): array
{
$planLimits = config('answerpins.plans.'.$plan.'.limits') ?? [];
$base = array_merge($current ?? [], $planLimits);
if (is_array($incoming)) {
$base = array_merge($base, $incoming);
}
return $base !== [] ? $base : $planLimits;
}
private function account(Request $request): Account
{
$request->validate(['service_id' => 'required|integer']);
return Account::where('whmcs_service_id', $request->integer('service_id'))->firstOrFail();
}
}
// —— app/Jobs/StartTrialClock.php ——
<?php
namespace App\Jobs;
use App\Services\Whmcs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Carbon;
/**
* The $1 trial: WHMCS has no trial period, so a new service would next bill a month out. For the trial product,
* move the next due date to 3 days after sign-up, so the full monthly price is charged on day 3.
*/
class StartTrialClock implements ShouldQueue
{
use Queueable;
public const DAYS = 3;
public int $tries = 5;
public array $backoff = [60, 300, 900, 3600];
public function __construct(public int $serviceId) {}
public function handle(Whmcs $whmcs): void
{
$trialPid = (int) config('answerpins.plans.owner.trial_pid');
if (! $trialPid || ! $whmcs->configured()) {
return;
}
$service = Whmcs::items($whmcs->call('GetClientsProducts', ['serviceid' => $this->serviceId]), 'products', 'product')[0] ?? null;
if (! $service || (int) $service['pid'] !== $trialPid) {
return;
}
$due = Carbon::parse($service['regdate'])->addDays(self::DAYS)->toDateString();
if (($service['nextduedate'] ?? null) !== $due) {
$whmcs->call('UpdateClientProduct', ['serviceid' => $this->serviceId, 'nextduedate' => $due]);
}
}
}
// —— app/Http/Controllers/Auth/WhmcsLoginController.php — the one-time sign-in ——
<?php
namespace App\Http\Controllers\Auth;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
class WhmcsLoginController extends Controller
{
/** "Open dashboard" link from WHMCS: signed, expires in 60 seconds, usable once. */
public function sso(Request $request, User $user, string $nonce): RedirectResponse
{
if (! Cache::add('sso-nonce:'.$nonce, true, 600)) {
return redirect()->route('login'); // link already used: sign in the normal way instead
}
Auth::login($user);
$request->session()->regenerate();
$request->session()->put('auth_via', 'sso');
return redirect()->route($user->isAdmin() ? 'admin' : 'dashboard');
}
}
// —— whmcs/modules/addons/answerpins_admin/answerpins_admin.php ——
<?php
/**
* AnswerPins Admin: signs a logged-in WHMCS admin straight into AnswerPins Admin.
*
* This file must live on the WHMCS server (not only in the AnswerPins git app folder):
* modules/addons/answerpins_admin/answerpins_admin.php
* Then: System Settings → Addon Modules → activate, allow the admin role, Save.
* It uses the AnswerPins server hostname + Access Hash when set (same as provisioning).
*/
use WHMCS\Database\Capsule;
if (!defined('WHMCS')) {
die('This file cannot be accessed directly');
}
function answerpins_admin_config()
{
return [
'name' => 'AnswerPins Admin',
'description' => 'Opens AnswerPins Admin, signed in as the WHMCS admin who clicks it.',
'version' => '1.2',
'author' => 'PressCare',
'fields' => [
'app_url' => ['FriendlyName' => 'AnswerPins address', 'Type' => 'text', 'Size' => '60', 'Default' => 'https://app.answerpins.com', 'Description' => 'Optional if an AnswerPins server already exists.'],
'api_key' => ['FriendlyName' => 'API key', 'Type' => 'password', 'Size' => '60', 'Description' => 'Optional if the AnswerPins server Access Hash is set. Same value as WHMCS_API_KEY.'],
],
];
}
function answerpins_admin_output($vars)
{
$admin = answerpins_admin_who();
if (!$admin || trim((string) ($admin->username ?? '')) === '') {
echo '<div class="alert alert-danger">Sign in to WHMCS admin first.</div>';
return;
}
$server = Capsule::table('tblservers')->where('type', 'answerpins')->where('disabled', 0)->orderBy('id')->first();
$addonUrl = rtrim((string) ($vars['app_url'] ?? ''), '/');
$addonKey = trim((string) ($vars['api_key'] ?? ''));
$serverUrl = '';
$serverKey = '';
if ($server) {
$secure = (string) ($server->secure ?? '');
$scheme = ($secure === '' || $secure === 'off' || $secure === '0') ? 'http' : 'https';
$serverUrl = $scheme . '://' . $server->hostname;
$serverKey = trim(preg_replace('/\s+/', '', (string) $server->accesshash));
}
// Prefer the server module credentials: those already provision accounts. Addon password fields
// are often left blank or stored encrypted, which used to send a bad Bearer token.
$appUrl = $serverUrl !== '' ? $serverUrl : $addonUrl;
$apiKey = $serverKey !== '' ? $serverKey : $addonKey;
if ($appUrl === '' || $apiKey === '') {
echo '<div class="alert alert-danger">AnswerPins Admin: add an AnswerPins server (Setup → Products/Services → Servers) with hostname app.answerpins.com and the Access Hash, or fill the address and API key on this addon and Save.</div>';
return;
}
$payload = [
'username' => $admin->username,
'name' => trim(($admin->firstname ?? '') . ' ' . ($admin->lastname ?? '')),
'email' => ($admin->email ?? '') !== '' ? $admin->email : null,
];
$attempt = answerpins_admin_sso($appUrl, $apiKey, $payload);
if (($attempt['status'] ?? 0) === 401 && $addonKey !== '' && $addonKey !== $apiKey) {
$attempt = answerpins_admin_sso($appUrl, $addonKey, $payload);
}
if (empty($attempt['redirect']) && $addonUrl !== '' && $addonUrl !== $appUrl) {
$retryKey = $serverKey !== '' ? $serverKey : $addonKey;
$retry = answerpins_admin_sso($addonUrl, $retryKey, $payload);
if (!empty($retry['redirect']) || (($retry['status'] ?? 0) >= 400 && ($attempt['status'] ?? 0) < 400)) {
$attempt = $retry;
}
}
if (!empty($attempt['redirect'])) {
$url = htmlspecialchars($attempt['redirect'], ENT_QUOTES);
echo '<p>Opening AnswerPins Admin…</p><script>window.location.href = ' . json_encode($attempt['redirect']) . ';</script>'
. '<p><a class="btn btn-primary" href="' . $url . '">Open AnswerPins Admin</a></p>';
return;
}
echo '<div class="alert alert-danger">AnswerPins Admin: ' . htmlspecialchars((string) $attempt['error'], ENT_QUOTES)
. '</div><p class="text-muted">WHMCS user: ' . htmlspecialchars((string) $admin->username, ENT_QUOTES) . '</p>';
}
function answerpins_admin_who()
{
$id = 0;
if (class_exists('\WHMCS\Authentication\CurrentUser')) {
$admin = (new \WHMCS\Authentication\CurrentUser)->admin();
$id = $admin ? (int) $admin->id : 0;
}
if (!$id) {
$id = (int) ($_SESSION['adminid'] ?? 0);
}
if (!$id && class_exists('\WHMCS\Session')) {
$id = (int) \WHMCS\Session::get('adminid');
}
return $id ? Capsule::table('tbladmins')->where('id', $id)->first() : null;
}
function answerpins_admin_sso($appUrl, $apiKey, array $payload)
{
$url = rtrim($appUrl, '/') . '/api/whmcs/admin-sso';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
'Authorization: Bearer ' . $apiKey,
],
CURLOPT_POSTFIELDS => json_encode($payload),
]);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
$data = json_decode((string) $raw, true);
if (function_exists('logModuleCall')) {
logModuleCall('answerpins_admin', 'admin-sso', ['url' => $url, 'username' => $payload['username'] ?? ''], $raw !== false ? $raw : $error, null, [$apiKey]);
}
if (!empty($data['redirect_url'])) {
return ['redirect' => $data['redirect_url'], 'status' => $status, 'curl' => '', 'error' => ''];
}
$message = $data['error'] ?? $data['message'] ?? ($error ?: ($raw === false ? 'Could not reach AnswerPins at ' . $url : substr((string) $raw, 0, 300)));
if ($status > 0 && $status !== 200) {
$message .= ' (HTTP ' . $status . ')';
}
return ['redirect' => '', 'status' => $status, 'curl' => $error, 'error' => $message];
}
// —— app/Http/Controllers/Whmcs/AdminSsoController.php ——
<?php
namespace App\Http\Controllers\Whmcs;
use App\Http\Controllers\Controller;
use App\Models\AdminMember;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
/**
* The AnswerPins Admin link in WHMCS admin: WHMCS vouches for the signed-in admin (with the shared API key),
* and gets back a one-time sign-in link, valid for 60 seconds. Only usernames in config answerpins.admins get one.
*/
class AdminSsoController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
if ($request->input('email') === '') {
$request->merge(['email' => null]);
}
$data = $request->validate(['username' => 'required|string|max:100', 'name' => 'nullable|string|max:200', 'email' => 'nullable|email|max:190']);
$username = strtolower(trim($data['username']));
$email = isset($data['email']) ? strtolower(trim($data['email'])) : null;
// WHMCS already decided this admin may open the addon (API key + addon access). Don't 403
// just because ANSWERPINS_ADMIN_OWNERS was never set on the server.
if (! User::roleFor($username) && ! User::roleForEmail($email)) {
AdminMember::firstOrCreate(['username' => $username], ['role' => 'owner']);
}
$user = $this->userFor($username, $email, $data['name'] ?? null);
$user->update(['admin_seen_at' => now(), 'name' => ($data['name'] ?? '') !== '' ? $data['name'] : $user->name]);
return response()->json(['result' => 'success', 'redirect_url' => URL::temporarySignedRoute('sso.login', now()->addMinutes(5),
['user' => $user->id, 'nonce' => bin2hex(random_bytes(16))])]);
}
/** Prefer the WHMCS username row; if the admin's real email already has an app login, use that instead of a duplicate. */
private function userFor(string $username, ?string $email, ?string $name): User
{
$byUser = User::firstWhere('whmcs_admin', $username);
$byEmail = $email ? User::where('email', $email)->first() : null;
if ($byUser && $byEmail && $byUser->id !== $byEmail->id) {
$byUser->update(['whmcs_admin' => null]);
$byEmail->update(['whmcs_admin' => $username]);
return $byEmail;
}
if ($byUser) {
if ($email && $byUser->email !== $email && ! User::where('email', $email)->exists()) {
$byUser->update(['email' => $email]);
}
return $byUser;
}
if ($byEmail) {
$byEmail->update(['whmcs_admin' => $username]);
return $byEmail;
}
return User::create([
'name' => $name ?: $username,
'email' => $email ?: $username.'@whmcs-admin.answerpins',
'password' => Str::random(40),
'whmcs_admin' => $username,
]);
}
}