Verificar a assinatura
O seu endpoint é um endereço público: qualquer um pode mandar um POST para ele. A assinatura é o que prova que a entrega veio do Faturei Hoje, com o segredo do seu endpoint, e que o corpo não foi alterado no caminho. Confira a assinatura em toda entrega, antes de usar o corpo.
A assinatura segue o padrão Standard Webhooks, então as bibliotecas desse padrão também servem. Os exemplos abaixo usam só a biblioteca padrão de cada linguagem.
A receita
Seção intitulada “A receita”-
Leia os cabeçalhos
webhook-id,webhook-timestampewebhook-signature. -
Recuse se
webhook-timestampestiver a mais de 5 minutos do seu relógio, para cima ou para baixo. É o que impede alguém de guardar uma entrega verdadeira e mandá-la de novo mais tarde. -
Monte o conteúdo assinado:
webhook-id, ponto,webhook-timestamp, ponto, e o corpo cru da requisição.evt_9b2f4c7e1a3d4f6b8c0e2a4d6f8b1c3e.1789574400.{"id":"evt_9b2f... -
Tire o prefixo
whsec_do segredo e decodifique o resto de base64. Esses bytes são a chave. -
Calcule o HMAC-SHA256 do conteúdo com a chave e codifique o resultado em base64.
-
O cabeçalho
webhook-signaturetraz uma ou mais assinaturas separadas por espaço, cada uma no formatov1,<base64>. Aceite a entrega se qualquer uma delas for igual à que você calculou, comparando em tempo constante.
Os três erros que mais aparecem
Seção intitulada “Os três erros que mais aparecem”Assinar o JSON em vez do corpo cru. O conteúdo assinado é o texto exato que chegou, byte a byte. Se o seu framework já transformou o corpo em objeto e você o converte de volta para texto, espaços, ordem de campos e acentos podem mudar, e a assinatura não confere mais. Leia o corpo cru antes de qualquer interpretação.
Comparar com ==. A comparação comum para no primeiro caractere diferente, e o tempo que ela leva diz a um atacante quantos caracteres ele acertou. Use a comparação em tempo constante da linguagem: crypto.timingSafeEqual no Node.js, hash_equals no PHP, hmac.compare_digest no Python.
Esperar uma assinatura só. Durante a rotação do segredo o cabeçalho traz duas. Separe por espaço e confira cada uma.
O código
Seção intitulada “O código”Cada exemplo é um servidor completo, sem dependência nenhuma: recebe o POST, confere a assinatura, responde 400 quando ela não confere e 200 quando confere. O segredo vem da variável de ambiente FH_WEBHOOK_SECRET.
import { createHmac, timingSafeEqual } from 'node:crypto';import { createServer } from 'node:http';
const SECRET = process.env.FH_WEBHOOK_SECRET; // whsec_...const TOLERANCE_SECONDS = 5 * 60;
export function verifyWebhook(secret, headers, rawBody, nowMs = Date.now()) { const id = headers['webhook-id']; const timestamp = headers['webhook-timestamp']; const signatures = headers['webhook-signature']; if (!id || !timestamp || !signatures) return false; if (!/^\d+$/.test(timestamp)) return false; if (Math.abs(nowMs / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const key = Buffer.from(secret.slice('whsec_'.length), 'base64'); const expected = createHmac('sha256', key) .update(`${id}.${timestamp}.`) .update(rawBody) // o corpo CRU, como chegou .digest();
return signatures.split(' ').some((entry) => { const [version, value] = entry.split(','); if (version !== 'v1' || !value) return false; const received = Buffer.from(value, 'base64'); return received.length === expected.length && timingSafeEqual(received, expected); });}
createServer((req, res) => { const chunks = []; req.on('data', (chunk) => chunks.push(chunk)); req.on('end', () => { const rawBody = Buffer.concat(chunks); if (!verifyWebhook(SECRET, req.headers, rawBody)) { res.writeHead(400).end(); return; } const event = JSON.parse(rawBody.toString('utf8')); // Guarde o evento e processe depois. Responda logo. console.log(event.type, event.id); res.writeHead(200).end(); });}).listen(Number(process.env.PORT ?? 3000));Rode com node webhook-receiver.mjs. No Express, use express.raw({ type: 'application/json' }) na rota do webhook, para req.body chegar como o Buffer cru, e passe req.body para verifyWebhook.
<?php
const TOLERANCE_SECONDS = 300;
function verifyWebhook(string $secret, array $headers, string $rawBody, ?int $now = null): bool{ $id = $headers['webhook-id'] ?? ''; $timestamp = $headers['webhook-timestamp'] ?? ''; $signatures = $headers['webhook-signature'] ?? ''; if ($id === '' || $timestamp === '' || $signatures === '') { return false; } if (!ctype_digit($timestamp)) { return false; } if (abs(($now ?? time()) - (int) $timestamp) > TOLERANCE_SECONDS) { return false; }
$key = base64_decode(substr($secret, strlen('whsec_')), true); if ($key === false) { return false; } // O corpo CRU, como chegou. $expected = hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true);
foreach (explode(' ', $signatures) as $entry) { [$version, $value] = array_pad(explode(',', $entry, 2), 2, ''); if ($version !== 'v1' || $value === '') { continue; } $received = base64_decode($value, true); if ($received !== false && hash_equals($expected, $received)) { return true; } } return false;}
$headers = array_change_key_case(getallheaders(), CASE_LOWER);$rawBody = file_get_contents('php://input');
if (!verifyWebhook(getenv('FH_WEBHOOK_SECRET'), $headers, $rawBody)) { http_response_code(400); exit;}
$event = json_decode($rawBody, true);// Guarde o evento e processe depois. Responda logo.error_log($event['type'] . ' ' . $event['id']);http_response_code(200);Para experimentar, php -S 0.0.0.0:3000 webhook-receiver.php. No Laravel, passe $request->getContent() como corpo cru e monte o array com os três cabeçalhos lidos por $request->header('webhook-id'), $request->header('webhook-timestamp') e $request->header('webhook-signature').
import base64import hashlibimport hmacimport jsonimport osimport timefrom http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["FH_WEBHOOK_SECRET"] # whsec_...TOLERANCE_SECONDS = 5 * 60
def verify_webhook(secret, headers, raw_body, now=None): msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") signatures = headers.get("webhook-signature") if not msg_id or not timestamp or not signatures: return False if not (timestamp.isascii() and timestamp.isdigit()): return False now = time.time() if now is None else now if abs(now - int(timestamp)) > TOLERANCE_SECONDS: return False
key = base64.b64decode(secret.removeprefix("whsec_")) # O corpo CRU, como chegou. signed = f"{msg_id}.{timestamp}.".encode() + raw_body expected = hmac.new(key, signed, hashlib.sha256).digest()
for entry in signatures.split(" "): version, _, value = entry.partition(",") if version != "v1" or not value: continue try: received = base64.b64decode(value, validate=True) except ValueError: continue if hmac.compare_digest(received, expected): return True return False
class WebhookHandler(BaseHTTPRequestHandler): def do_POST(self): length = int(self.headers.get("Content-Length", 0)) raw_body = self.rfile.read(length) if not verify_webhook(SECRET, self.headers, raw_body): self.send_response(400) self.end_headers() return event = json.loads(raw_body) # Guarde o evento e processe depois. Responda logo. print(event["type"], event["id"]) self.send_response(200) self.end_headers()
if __name__ == "__main__": port = int(os.environ.get("PORT", "3000")) HTTPServer(("", port), WebhookHandler).serve_forever()Rode com python3 webhook_receiver.py (Python 3.9 ou mais novo). No Flask, use request.get_data() como corpo cru; no Django, request.body.
Os três exemplos foram executados contra entregas assinadas pelo próprio código que envia os webhooks: aceitam a entrega verdadeira, com uma ou com duas assinaturas, e recusam corpo alterado, assinatura alterada, segredo errado e carimbo de tempo velho.
Rotacionar o segredo
Seção intitulada “Rotacionar o segredo”POST /public/v1/webhook_endpoints/{id}/rotate_secret gera um segredo novo e o devolve em secret. Pela API, ele só aparece nessa resposta, como na criação; no painel, dono e administradores podem revelá-lo de novo, confirmando a senha.
Por 24 horas depois da rotação, cada entrega sai com duas assinaturas no webhook-signature, separadas por espaço: a do segredo novo primeiro, depois a do anterior. Assim o seu servidor continua aceitando as entregas enquanto você troca o segredo, seja qual for o que ele ainda usa. Passadas as 24 horas, só o segredo novo assina.
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= v1,3oZaGW1LqbpVYbfVQ5Yz8XJHsd3Wj/2f0sK7QpTnC1U=Passo a passo:
- Rotacione e guarde o segredo novo.
- Troque o segredo no seu servidor.
- Confirme pelo log de entregas que as entregas seguem com sucesso.
Rotacionar de novo dentro das 24 horas descarta o segredo mais antigo: convivem no máximo dois, o atual e o imediatamente anterior. Se você rotacionou duas vezes seguidas, o seu servidor precisa estar com um dos dois últimos.
Rotacione quando alguém que conhecia o segredo sai da equipe ou do fornecedor, ou quando o segredo passou por um lugar que você não controla (chamado, conversa, log).
Próximo passo
Seção intitulada “Próximo passo”- Entregas e novas tentativas: o que a sua resposta significa para o Faturei Hoje.
- Boas práticas: responder rápido e ignorar duplicata pelo
webhook-id.