baseUrl === '' || $this->integrationId === '' || $this->apiSecret === '') { throw new RuntimeException('API URL, integration ID and secret are required'); } } public function health(): array { return $this->request('GET', '/api/v1/integrations/whmcs/health'); } public function dashboardStats(): array { return $this->request('GET', '/api/v1/integrations/whmcs/dashboard/stats'); } public function listAccounts(): array { return $this->request('GET', '/api/v1/integrations/whmcs/accounts'); } public function listEvents(?string $cursor = null, int $limit = 50): array { $query = $cursor ? ('?cursor=' . rawurlencode($cursor) . '&limit=' . $limit) : ('?limit=' . $limit); return $this->request('GET', '/api/v1/integrations/whmcs/events' . $query); } public function acknowledgeEvents(array $eventIds, bool $deadLetter = false, ?string $reason = null): array { $payload = ['eventIds' => $eventIds, 'deadLetter' => $deadLetter]; if ($reason !== null) { $payload['deadLetterReason'] = $reason; } return $this->request('POST', '/api/v1/integrations/whmcs/events/ack', $payload); } public function reconcileAll(array $payload = []): array { return $this->request( 'POST', '/api/v1/integrations/whmcs/reconcile', $payload, 'reconcile:all:' . date('Y-m-d-His') ); } private function request(string $method, string $pathWithQuery, array $body = [], ?string $idempotencySuffix = null): array { $pathParts = explode('?', $pathWithQuery, 2); $path = $pathParts[0]; $canonicalQuery = ''; if (isset($pathParts[1]) && $pathParts[1] !== '') { parse_str($pathParts[1], $queryParams); ksort($queryParams); $pairs = []; foreach ($queryParams as $key => $value) { $pairs[] = rawurlencode((string) $key) . '=' . rawurlencode((string) $value); } $canonicalQuery = implode('&', $pairs); } $bodyJson = $body === [] ? '' : json_encode($body, JSON_THROW_ON_ERROR); $timestamp = (string) (int) (microtime(true) * 1000); $nonce = bin2hex(random_bytes(16)); $idempotencyKey = $idempotencySuffix ?: $method . ':' . $path . ':' . $timestamp; $signatureBase = implode("\n", [ strtoupper($method), $path, $canonicalQuery, hash('sha256', $bodyJson), $timestamp, $nonce, $idempotencyKey, ]); $signature = hash_hmac('sha256', $signatureBase, $this->apiSecret); $requestUrl = rtrim($this->baseUrl, '/') . $path . ($canonicalQuery !== '' ? ('?' . $canonicalQuery) : ''); $ch = curl_init($requestUrl); if ($ch === false) { throw new RuntimeException('Unable to initialize HTTP client'); } curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Accept: application/json', 'X-HGC-Integration-ID: ' . $this->integrationId, 'X-HGC-Timestamp: ' . $timestamp, 'X-HGC-Nonce: ' . $nonce, 'X-HGC-Idempotency-Key: ' . $idempotencyKey, 'X-HGC-Signature: ' . $signature, ], CURLOPT_POSTFIELDS => $bodyJson, ]); $responseBody = curl_exec($ch); $statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); curl_close($ch); if ($responseBody === false) { throw new RuntimeException('GameCloud request failed: ' . $curlError); } $decoded = json_decode($responseBody, true); if ($statusCode >= 400) { $message = is_array($decoded) ? ($decoded['detail'] ?? $decoded['title'] ?? $responseBody) : $responseBody; throw new RuntimeException('GameCloud API error (' . $statusCode . '): ' . $message); } return is_array($decoded) ? $decoded : []; } }