HMAC em PHP

Receita hash_hmac + cURL/Guzzle (PHP 8+)

HMAC-SHA256 em PHP 8+ (hash_hmac + cURL / Guzzle)

Receita reutilizável em PHP 8.0+. A função hash_hmac vem com qualquer PHP — você só precisa escolher entre cURL puro (sem dependências) ou Guzzle (mais ergonômico).

Pré-requisitos

1. Função utilitária — pura, reusável

<?php
declare(strict_types=1);

const API_KEY    = 'svp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
const API_SECRET = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';

/**
 * Calcula os 3 headers HMAC para uma requisicao.
 *
 * @param string $method      'GET', 'POST', etc.
 * @param string $urlOrPath   URL absoluta ou path com query.
 * @param string $body        Corpo cru (string). Vazio em GET.
 * @return array<int,string> Headers no formato cURL ('Nome: valor').
 */
function signRequest(string $method, string $urlOrPath, string $body = ''): array {
    $ts = (string) time();

    $parts  = parse_url($urlOrPath);
    $pathQ  = ($parts['path'] ?? '/')
            . (isset($parts['query']) ? '?' . $parts['query'] : '');

    $payload   = $ts . '.' . strtoupper($method) . ' ' . $pathQ . "\n" . $body;
    $signature = hash_hmac('sha256', $payload, API_SECRET); // hex lowercase

    return [
        'X-API-Key: '   . API_KEY,
        'X-Timestamp: ' . $ts,
        'X-Signature: ' . $signature,
    ];
}

2. Uso com cURL puro — GET

<?php
$url     = 'https://api.sivoe.med.br/v1/ping';
$headers = signRequest('GET', $url);

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => $headers,
    CURLOPT_TIMEOUT        => 10,
    // CURLOPT_FOLLOWLOCATION => false  (default; ver secao 4)
]);

$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

echo "HTTP {$code}\n";
echo $body, PHP_EOL;

Saída esperada

HTTP 200
{"data":{"ok":true,"service":"Sivoe API Gateway","version":"v1", ...},
 "meta":{"request_id":"...","duracao_ms":12},
 "error":null}

3. POST com body JSON

<?php
$url = 'https://api.sivoe.med.br/v1/ping';

// Serialize ANTES de assinar -- mesma string vai pela rede.
// JSON_UNESCAPED_UNICODE evita escape duplo de acentos.
$body = json_encode(
    ['hello' => 'world', 'numero' => 42],
    JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);

$headers = signRequest('POST', $url, $body);
$headers[] = 'Content-Type: application/json';

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $body,   // exatamente a string assinada
    CURLOPT_HTTPHEADER     => $headers,
    CURLOPT_TIMEOUT        => 10,
]);

$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

echo "HTTP {$code}\n", $resp, PHP_EOL;

4. Endpoints /arquivo — não siga redirects

Os endpoints GET /v1/exames/{id}/arquivo e GET /v1/laudos/{id}/arquivo retornam 302 Found com Location: apontando para uma presigned S3. Se CURLOPT_FOLLOWLOCATION estiver ligado, o cliente segue o redirect e tenta assinar o S3 com seu api_secret — quebra tudo.

<?php
$url     = 'https://api.sivoe.med.br/v1/exames/99/arquivo?tipo=exame';
$headers = signRequest('GET', $url);

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HEADER         => true,
    CURLOPT_FOLLOWLOCATION => false,   // CRITICO: nao seguir
    CURLOPT_HTTPHEADER     => $headers,
    CURLOPT_TIMEOUT        => 10,
]);

curl_exec($ch);
$code     = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$location = curl_getinfo($ch, CURLINFO_REDIRECT_URL);
curl_close($ch);

if ($code === 302) {
    echo "Presigned S3: {$location}\n";
    // Baixe SEM HMAC -- presigned ja autentica contra o S3.
    // Validade: 300 segundos.
    file_put_contents('exame-99.pdf', file_get_contents($location));
}
Não cache a Location:. A presigned S3 expira em 5 minutos. Em retry, sempre refaça o GET /v1/.../arquivo para obter uma URL nova.

5. Alternativa com Guzzle

<?php
// composer require guzzlehttp/guzzle
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;

$client = new Client([
    'timeout'         => 10,
    'allow_redirects' => false,    // mesma logica da secao 4
]);

$url = 'https://api.sivoe.med.br/v1/ping';

// signRequest() devolve no formato cURL ('Nome: valor').
// Convertemos para o formato associativo do Guzzle.
$headersAssoc = [];
foreach (signRequest('GET', $url) as $line) {
    [$name, $val] = explode(':', $line, 2);
    $headersAssoc[trim($name)] = trim($val);
}

try {
    $resp = $client->request('GET', $url, ['headers' => $headersAssoc]);
    print_r(json_decode((string) $resp->getBody(), true));
} catch (RequestException $e) {
    // Guzzle eleva 4xx/5xx por padrao
    echo $e->getResponse()?->getStatusCode(), "\n";
    echo (string) $e->getResponse()?->getBody(), "\n";
}

Pegadinhas comuns

Troubleshooting

Erro recebidoCausa provável
AUTH_INVALID_SIGNATURE CURLOPT_POSTFIELDS recebeu array; body com escape \/ não-assinado; query string fora do pathQ
AUTH_TIMESTAMP_EXPIRED Relógio fora ±300s — sincronize via NTP
cURL error 28 (timeout) Cold call do gateway > CURLOPT_TIMEOUT — aumente para 10s+
SSL certificate problem CA bundle desatualizado — atualize cacert.pem do PHP

Catálogo completo: Códigos de erro.