rawurlencode($p[0]) . '=' . rawurlencode($p[1]), $pairs)); } function provibetRequest(string $method, string $target, string $rawBody = '', string $idempotencyKey = ''): array { $key = getenv('PROVIBET_PUBLIC_KEY'); $secret = getenv('PROVIBET_SECRET'); $origin = rtrim(getenv('PROVIBET_ORIGIN') ?: 'https://api.provibet.com', '/'); if (!$key || !$secret) throw new RuntimeException('Configure las credenciales en su gestor de secretos'); $originParts = parse_url($origin); if (!$originParts || ($originParts['scheme'] ?? '') !== 'https' || empty($originParts['host']) || isset($originParts['user'], $originParts['pass']) || isset($originParts['user']) || isset($originParts['query']) || isset($originParts['fragment']) || !empty($originParts['path'])) { throw new RuntimeException('PROVIBET_ORIGIN debe ser un origen HTTPS'); } if (!str_starts_with($target, '/api/b2b/v1/') || str_contains($target, '#') || preg_match('/[\x00-\x20\x7f\\\\]/', $target)) { throw new RuntimeException('Ruta pública inválida'); } $method = strtoupper($method); if (!in_array($method, ['GET', 'POST'], true)) throw new RuntimeException('Método no admitido'); if ($method === 'GET' && $rawBody !== '') throw new RuntimeException('GET debe tener body vacío'); $parts = explode('?', $target, 2); $timestamp = (string) time(); $nonce = rtrim(strtr(base64_encode(random_bytes(24)), '+/', '-_'), '='); $idempotencyKey = trim($idempotencyKey); $canonical = implode("\n", ['B2B-HMAC-SHA256', $key, $timestamp, $nonce, $method, $parts[0], provibetCanonicalQuery($parts[1] ?? ''), $idempotencyKey, hash('sha256', $rawBody)]); $headers = ['X-B2B-Key: ' . $key, 'X-B2B-Timestamp: ' . $timestamp, 'X-B2B-Nonce: ' . $nonce, 'X-B2B-Signature: ' . hash_hmac('sha256', $canonical, $secret), 'X-Request-Id: ' . bin2hex(random_bytes(16))]; if ($idempotencyKey !== '') $headers[] = 'Idempotency-Key: ' . $idempotencyKey; if ($method === 'POST') $headers[] = 'Content-Type: application/json'; $responseHeaders = []; $ch = curl_init($origin . $target); curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HEADERFUNCTION => static function ($handle, string $line) use (&$responseHeaders): int { if (str_contains($line, ':')) { [$name, $value] = explode(':', $line, 2); $responseHeaders[strtolower(trim($name))] = trim($value); } return strlen($line); }]); if ($method === 'POST') curl_setopt($ch, CURLOPT_POSTFIELDS, $rawBody); $rawResponse = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_errno($ch); curl_close($ch); if ($rawResponse === false) throw new RuntimeException('Resultado de transporte incierto; consultar o reintentar con la misma clave. Código: ' . $error); return ['status' => $status, 'requestId' => $responseHeaders['x-request-id'] ?? null, 'retryAfter' => $responseHeaders['retry-after'] ?? null, 'data' => json_decode($rawResponse, true, 512, JSON_THROW_ON_ERROR)]; } // $wallet = provibetRequest('GET', '/api/b2b/v1/wallet'); // Persist rawBody and the business key before sending; use decimal strings, never floats. // $rawBody = json_encode(['amountCredits' => '50', 'currency' => 'CRD'], JSON_THROW_ON_ERROR); // $result = provibetRequest('POST', '/api/b2b/v1/players/jugador-123/credits', $rawBody, 'credit-order-5001'); // Check status and operation.state. HTTP 202 is pending; HTTP 422 carries a rejected operation.