base = rtrim($base, '/'); $this->key = $key; $this->secret = $secret; } /** Signature = hex(HMAC-SHA256(secret, METHOD\nPATH\nCANONICAL_QUERY\nTIMESTAMP\nNONCE\nhex(sha256(body)))) */ public static function sign(string $secret, string $method, string $path, string $query, string $ts, string $nonce, string $body): string { $canon = ''; if ($query !== '') { $pairs = []; foreach (explode('&', $query) as $p) { if ($p === '') continue; $kv = explode('=', $p, 2); $pairs[] = [rawurlencode(rawurldecode(str_replace('+', '%20', $kv[0]))), rawurlencode(rawurldecode(str_replace('+', '%20', $kv[1] ?? '')))]; } usort($pairs, function ($a, $b) { return $a[0] === $b[0] ? strcmp($a[1], $b[1]) : strcmp($a[0], $b[0]); }); $canon = implode('&', array_map(function ($p) { return $p[0] . '=' . $p[1]; }, $pairs)); } $sts = strtoupper($method) . "\n" . $path . "\n" . $canon . "\n" . $ts . "\n" . $nonce . "\n" . hash('sha256', $body); return hash_hmac('sha256', $sts, $secret); } public function request(string $method, string $endpoint, array $query = [], ?array $body = null, ?string $idempotencyKey = null): array { $path = '/api/v1/' . ltrim($endpoint, '/'); $qs = $query ? http_build_query($query, '', '&', PHP_QUERY_RFC3986) : ''; $json = $body === null ? '' : json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $ts = (string) time(); $nonce = bin2hex(random_bytes(16)); $headers = [ 'X-API-Key: ' . $this->key, 'X-Timestamp: ' . $ts, 'X-Nonce: ' . $nonce, 'X-Signature: ' . self::sign($this->secret, $method, $path, $qs, $ts, $nonce, $json), 'Accept: application/json', ]; if ($method === 'POST') { $headers[] = 'Content-Type: application/json'; } if ($idempotencyKey !== null) { $headers[] = 'Idempotency-Key: ' . $idempotencyKey; } // optional extra headers, e.g. when your own outbound proxy needs them (JSON array of "Name: value") foreach ((array) json_decode((string) getenv('SPECTO_EXTRA_HEADERS'), true) as $h) { $headers[] = $h; } $url = preg_replace('#/api/v1$#', '', $this->base) . $path . ($qs ? '?' . $qs : ''); $ch = curl_init($url); curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 90, CURLOPT_SSL_VERIFYPEER => true]); if ($method === 'POST') { curl_setopt($ch, CURLOPT_POSTFIELDS, $json); } $raw = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $err = curl_error($ch); curl_close($ch); if ($raw === false) { throw new RuntimeException('Network error: ' . $err); } return ['http' => $status, 'body' => json_decode((string) $raw, true)]; } } /* --------------------------------------------------------------------------- Demo: balance → plans → (optional) activation with a retry-safe idempotency key → transaction lookup. Set SPECTO_DEMO_MOBILE to run the activation. --------------------------------------------------------------------------- */ if (PHP_SAPI === 'cli' && realpath($argv[0]) === __FILE__) { $api = new SpectoClient(getenv('SPECTO_API_BASE') ?: 'https://api.spectotv.com/api/v1', getenv('SPECTO_API_KEY') ?: '', getenv('SPECTO_API_SECRET') ?: ''); $bal = $api->request('GET', 'balance'); echo "balance: HTTP {$bal['http']} ", $bal['body']['code'] ?? '?', ' ', json_encode($bal['body']['data']['wallet_balance'] ?? null), "\n"; $plans = $api->request('GET', 'plans'); $list = $plans['body']['data']['plans'] ?? []; echo "plans: ", count($list), "\n"; $mobile = getenv('SPECTO_DEMO_MOBILE'); if ($mobile && $list) { // Create the key BEFORE the first attempt and store it with your order. $key = 'order-' . bin2hex(random_bytes(8)); $r = null; for ($attempt = 1; $attempt <= 3; $attempt++) { try { $r = $api->request('POST', 'activate', [], ['full_name' => 'Demo Customer', 'mobile' => $mobile, 'plan_id' => $list[0]['id']], $key); break; } catch (RuntimeException $e) { sleep($attempt); } // network error: retry with the SAME key } $code = $r['body']['code'] ?? 'NO_RESPONSE'; echo "activate: HTTP ", $r['http'] ?? 0, " $code\n"; if ($code === 'OK') { $d = $r['body']['data']; echo " transaction {$d['transaction_id']} · subscriber {$d['customer']['subscriber_id']} · expires {$d['expiry']['new']} · wallet {$d['wallet']['before']} → {$d['wallet']['after']}\n"; } elseif ($code === 'INSUFFICIENT_BALANCE') { echo " top up needed: short by ", $r['body']['data']['shortfall'], "\n"; } else { echo " ", $r['body']['message'] ?? '', "\n"; } $t = $api->request('GET', 'transaction', ['idempotency_key' => $key]); echo "lookup: ", $t['body']['data']['status'] ?? ($t['body']['code'] ?? '?'), "\n"; } }