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
- PHP 8.0+ (qualquer SAPI: CLI, FPM, Apache)
- Extensão
curlhabilitada (já vem com PHP padrão) - Opcional:
composer require guzzlehttp/guzzlepara a versão Guzzle - Relógio sincronizado por NTP (janela ±300s)
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
-
time()em PHP é UTC desde sempre — sem ajuste de timezone necessário. -
hash_hmac()retorna hex lowercase por padrão quando o 4º parâmetro é omitido (defaultfalse) — não precisa converter. -
parse_url()pode quebrar em URLs muito complexas (caracteres exóticos no fragment). Em produção, valideparse_url($url) !== false. -
cURL e
CURLOPT_POSTFIELDS: passe uma string, não array. Se passar array, o cURL converte paramultipart/form-datae a assinatura quebra. -
JSON_UNESCAPED_SLASHESevita o escape de barras (\/) que muda a string em relação ao que outras linguagens enviariam — bom para consistência cross-stack. -
Para download de PDFs: use
CURLOPT_FOLLOWLOCATION => falsee baixe a presigned S3 separadamente (sem HMAC).
Troubleshooting
| Erro recebido | Causa 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.