Webhooks são chamadas HTTP automáticas que um sistema dispara para outro quando um evento acontece (por exemplo: pagamento confirmado, pedido criado, assinatura cancelada). Eles são simples e poderosos porque eliminam a necessidade de “ficar consultando” uma API o tempo todo: o sistema emissor te avisa assim que algo muda.
O problema é que um endpoint de webhook exposto na internet vira um alvo óbvio para falsificações. Qualquer pessoa pode tentar enviar um POST “imitando” o provedor, e se o seu sistema confiar no conteúdo sem validar a origem, você abre brechas graves (marcar pedido como pago, liberar acesso, registrar ações indevidas, etc.).
A forma mais comum (e eficiente) de proteger webhooks é com HMAC (Hash-based Message Authentication Code): o emissor assina o corpo da requisição com um segredo compartilhado e o receptor recalcula essa assinatura. Se bater, você tem uma forte garantia de integridade (o corpo não foi alterado) e autenticidade (quem assinou conhece o segredo).
Como funciona a verificação HMAC em webhooks
O fluxo é este:
- O emissor monta um JSON com o evento e envia via
POSTpara a sua URL de webhook. - Antes de enviar, ele gera uma assinatura HMAC do corpo bruto (
raw body) e inclui em um header (ex.:X-Signature). - Seu receptor lê o
raw body, recalcula o HMAC usando o mesmo segredo e compara com a assinatura recebida. - Se não bater: responde
401/403. Se bater: processa o evento e responde200.
Boas práticas (que evitam dor de cabeça)
- Nunca use
$_POSTpara validar assinatura: HMAC deve ser calculado em cima do corpo bruto (php://input). - Use
hash_equals()para comparar assinaturas (evita ataques por timing). - Inclua timestamp e valide janela de tempo (anti-replay).
- Inclua um event id e faça deduplicação (webhooks podem ser reenviados).
- Logue
request-id, latência e resultados (sem vazar segredos).
1) Definindo o padrão de headers
Neste guia vamos usar estes headers (você pode adaptar):
X-Webhook-Id: ID único do evento (UUID, por exemplo).X-Webhook-Timestamp: timestamp Unix (segundos).X-Webhook-Signature: assinatura HMAC (hex ou base64).
A assinatura será calculada em cima do texto: {timestamp}.{rawBody}. Isso impede replay fácil com corpo reaproveitado em outro momento.
2) Exemplo do emissor: gerando e enviando um webhook assinado (PHP puro + cURL)
<?php
/**
* Emissor: dispara webhook com HMAC (PHP puro + cURL)
*/
$webhookUrl = 'https://seu-dominio.com/webhook/receber.php';
$secret = getenv('WEBHOOK_SECRET') ?: 'troque-por-um-segredo-forte';
$event = [
'id' => 'evt_' . bin2hex(random_bytes(8)),
'type' => 'pedido.pago',
'createdAt' => date('c'),
'data' => [
'pedido_id' => 123,
'valor' => 199.90,
'moeda' => 'BRL',
],
];
$rawBody = json_encode($event, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($rawBody === false) {
throw new RuntimeException('Falha ao gerar JSON');
}
$ts = time();
// Assina: "{timestamp}.{rawBody}"
$payloadToSign = $ts . '.' . $rawBody;
// Assinatura em HEX
$signatureHex = hash_hmac('sha256', $payloadToSign, $secret);
$headers = [
'Content-Type: application/json',
'X-Webhook-Id: ' . $event['id'],
'X-Webhook-Timestamp: ' . $ts,
'X-Webhook-Signature: ' . $signatureHex,
];
$ch = curl_init($webhookUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $rawBody,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($response === false) {
$err = curl_error($ch);
curl_close($ch);
throw new RuntimeException('Erro cURL ao enviar webhook: ' . $err);
}
curl_close($ch);
echo "HTTP {$httpCode}\n";
echo $response . "\n";
3) Exemplo do receptor: validando HMAC com segurança (PHP puro)
Agora o principal: o endpoint que recebe o POST. Ele precisa (1) ler o raw body, (2) extrair headers, (3) validar timestamp, (4) recalcular HMAC e comparar com hash_equals e (5) só então processar.
<?php
/**
* Receptor: endpoint que valida HMAC e processa o webhook
*/
$secret = getenv('WEBHOOK_SECRET') ?: 'troque-por-um-segredo-forte';
// janela anti-replay (segundos) — ex.: 5 minutos
$maxSkew = 300;
/**
* Lê headers HTTP de forma compatível (Apache/Nginx/PHP-FPM)
*/
function get_headers_lower(): array
{
$headers = [];
// Se existir, é o melhor caminho
if (function_exists('getallheaders')) {
foreach (getallheaders() as $k => $v) {
$headers[strtolower($k)] = $v;
}
return $headers;
}
// Fallback via $_SERVER
foreach ($_SERVER as $k => $v) {
if (strpos($k, 'HTTP_') === 0) {
$name = str_replace('_', '-', substr($k, 5));
$headers[strtolower($name)] = $v;
}
}
return $headers;
}
// 1) raw body (fundamental: HMAC é sobre o corpo bruto)
$rawBody = file_get_contents('php://input');
if ($rawBody === false || $rawBody === '') {
http_response_code(400);
echo 'Body vazio';
exit;
}
// 2) headers
$headers = get_headers_lower();
$webhookId = $headers['x-webhook-id'] ?? '';
$timestamp = $headers['x-webhook-timestamp'] ?? '';
$signature = $headers['x-webhook-signature'] ?? '';
if ($webhookId === '' || $timestamp === '' || $signature === '') {
http_response_code(400);
echo 'Headers obrigatórios ausentes';
exit;
}
if (!ctype_digit((string)$timestamp)) {
http_response_code(400);
echo 'Timestamp inválido';
exit;
}
$timestamp = (int)$timestamp;
// 3) anti-replay
$now = time();
if (abs($now - $timestamp) > $maxSkew) {
http_response_code(401);
echo 'Timestamp fora da janela';
exit;
}
// 4) recalcular assinatura (HEX)
$payloadToSign = $timestamp . '.' . $rawBody;
$expectedHex = hash_hmac('sha256', $payloadToSign, $secret);
// 5) comparar com timing-safe
if (!hash_equals($expectedHex, $signature)) {
http_response_code(401);
echo 'Assinatura inválida';
exit;
}
// 6) validar JSON
$data = json_decode($rawBody, true);
if (!is_array($data) || empty($data['type']) || empty($data['id'])) {
http_response_code(400);
echo 'JSON inválido';
exit;
}
// 7) dedupe simples por arquivo (produção: prefira DB/Redis)
$dedupeDir = __DIR__ . '/.webhook_dedupe';
if (!is_dir($dedupeDir)) {
@mkdir($dedupeDir, 0750, true);
}
// sanitiza ID p/ virar nome de arquivo
$eventIdSafe = preg_replace('/[^a-zA-Z0-9_\-]/', '_', $data['id']);
$dedupeKey = $dedupeDir . '/' . $eventIdSafe;
if (file_exists($dedupeKey)) {
http_response_code(200);
echo 'Duplicado (ok)';
exit;
}
$start = microtime(true);
try {
// 8) processar evento
switch ($data['type']) {
case 'pedido.pago':
$pedidoId = (int)($data['data']['pedido_id'] ?? 0);
// TODO: atualizar seu banco, liberar acesso etc.
break;
default:
// Evento desconhecido: normalmente responda 200 p/ não gerar retry infinito
break;
}
// marcar como processado
file_put_contents($dedupeKey, date('c'));
// 9) log (sem vazar segredo)
$ms = (int)((microtime(true) - $start) * 1000);
$logLine = sprintf(
"[%s] webhook_id=%s event_id=%s type=%s ms=%d ip=%s\n",
date('c'),
$webhookId,
$data['id'],
$data['type'],
$ms,
$_SERVER['REMOTE_ADDR'] ?? ''
);
file_put_contents(__DIR__ . '/webhook.log', $logLine, FILE_APPEND);
http_response_code(200);
echo 'OK';
} catch (Throwable $e) {
http_response_code(500);
echo 'Erro ao processar';
}
4) Versão com assinatura em Base64 (opcional)
Alguns provedores enviam assinatura em Base64. É a mesma ideia, só muda o formato:
Emissor:
$payloadToSign = $ts . '.' . $rawBody;
// hash_hmac com saída binária (true)
$signatureRaw = hash_hmac('sha256', $payloadToSign, $secret, true);
$signatureB64 = base64_encode($signatureRaw);
// header:
'X-Webhook-Signature: ' . $signatureB64,
Receptor:
$payloadToSign = $timestamp . '.' . $rawBody;
$expectedRaw = hash_hmac('sha256', $payloadToSign, $secret, true);
$expectedB64 = base64_encode($expectedRaw);
if (!hash_equals($expectedB64, $signature)) {
http_response_code(401);
echo 'Assinatura inválida';
exit;
}
5) Erros comuns que fazem a assinatura “não bater”
- Recalcular HMAC a partir de JSON reformatado: se você faz
json_decodee depoisjson_encode, muda espaços/ordem e a assinatura quebra. Assine sempre oraw body. - Encoding: garanta que o corpo é tratado como UTF-8 e enviado como foi recebido.
- Proxy/CDN: headers podem ter nomes diferentes. Por isso é bom padronizar e logar o que chega.
- Comparação insegura: usar
==em vez dehash_equalsé má prática e pode vazar informação por tempo de execução. - Sem anti-replay: se você não valida timestamp e id do evento, alguém pode reenviar um payload válido e repetir ações.
Se precisar de apoio prático para implementar este padrão de webhooks com HMAC (incluindo armazenamento seguro de segredo, deduplicação em banco/Redis, filas e reprocessamento), os serviços da Saldaris Consultoria estão disponíveis e você pode entrar em contato pelo formulário abaixo.

