Verificar la firma
Su endpoint es una dirección pública: cualquiera puede enviarle un POST. La firma es lo que prueba que la entrega vino de Faturei Hoje, con el secreto de su endpoint, y que el cuerpo no se alteró en el camino. Verifique la firma en cada entrega, antes de usar el cuerpo.
La firma sigue el estándar Standard Webhooks, así que las bibliotecas de ese estándar también sirven. Los ejemplos de abajo usan solo la biblioteca estándar de cada lenguaje.
La receta
Sección titulada «La receta»-
Lea los encabezados
webhook-id,webhook-timestampywebhook-signature. -
Rechace si
webhook-timestampestá a más de 5 minutos de su reloj, hacia arriba o hacia abajo. Es lo que impide que alguien guarde una entrega verdadera y la envíe de nuevo más tarde. -
Arme el contenido firmado:
webhook-id, punto,webhook-timestamp, punto, y el cuerpo crudo de la solicitud.evt_9b2f4c7e1a3d4f6b8c0e2a4d6f8b1c3e.1789574400.{"id":"evt_9b2f... -
Quite el prefijo
whsec_del secreto y decodifique el resto de base64. Esos bytes son la clave. -
Calcule el HMAC-SHA256 del contenido con la clave y codifique el resultado en base64.
-
El encabezado
webhook-signaturetrae una o más firmas separadas por espacio, cada una en el formatov1,<base64>. Acepte la entrega si cualquiera de ellas es igual a la que usted calculó, comparando en tiempo constante.
Los tres errores más comunes
Sección titulada «Los tres errores más comunes»Firmar el JSON en lugar del cuerpo crudo. El contenido firmado es el texto exacto que llegó, byte a byte. Si su framework ya convirtió el cuerpo en objeto y usted lo vuelve a convertir en texto, los espacios, el orden de los campos y los acentos pueden cambiar, y la firma deja de coincidir. Lea el cuerpo crudo antes de cualquier interpretación.
Comparar con ==. La comparación común se detiene en el primer carácter distinto, y el tiempo que tarda le dice a un atacante cuántos caracteres acertó. Use la comparación en tiempo constante del lenguaje: crypto.timingSafeEqual en Node.js, hash_equals en PHP, hmac.compare_digest en Python.
Esperar una sola firma. Durante la rotación del secreto el encabezado trae dos. Separe por espacio y verifique cada una.
El código
Sección titulada «El código»Cada ejemplo es un servidor completo, sin ninguna dependencia: recibe el POST, verifica la firma, responde 400 cuando no coincide y 200 cuando coincide. El secreto viene de la variable de entorno 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) // el cuerpo CRUDO, tal como llegó .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 el evento y procéselo después. Responda enseguida. console.log(event.type, event.id); res.writeHead(200).end(); });}).listen(Number(process.env.PORT ?? 3000));Ejecútelo con node webhook-receiver.mjs. En Express, use express.raw({ type: 'application/json' }) en la ruta del webhook, para que req.body llegue como el Buffer crudo, y pase req.body a 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; } // El cuerpo CRUDO, tal como llegó. $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 el evento y procéselo después. Responda enseguida.error_log($event['type'] . ' ' . $event['id']);http_response_code(200);Para probarlo, php -S 0.0.0.0:3000 webhook-receiver.php. En Laravel, pase $request->getContent() como cuerpo crudo y arme el array con los tres encabezados leídos por $request->header('webhook-id'), $request->header('webhook-timestamp') y $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_")) # El cuerpo CRUDO, tal como llegó. 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 el evento y procéselo después. Responda enseguida. 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()Ejecútelo con python3 webhook_receiver.py (Python 3.9 o más nuevo). En Flask, use request.get_data() como cuerpo crudo; en Django, request.body.
Los tres ejemplos se ejecutaron contra entregas firmadas por el mismo código que envía los webhooks: aceptan la entrega verdadera, con una o con dos firmas, y rechazan cuerpo alterado, firma alterada, secreto equivocado y marca de tiempo vieja.
Rotar el secreto
Sección titulada «Rotar el secreto»POST /public/v1/webhook_endpoints/{id}/rotate_secret genera un secreto nuevo y lo devuelve en secret. Por la API solo aparece en esa respuesta, como en la creación; en el panel, el dueño y los administradores pueden revelarlo de nuevo, confirmando la contraseña.
Durante 24 horas después de la rotación, cada entrega sale con dos firmas en webhook-signature, separadas por espacio: primero la del secreto nuevo, después la del anterior. Así su servidor sigue aceptando las entregas mientras usted cambia el secreto, sea cual sea el que todavía usa. Pasadas las 24 horas, solo firma el secreto nuevo.
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= v1,3oZaGW1LqbpVYbfVQ5Yz8XJHsd3Wj/2f0sK7QpTnC1U=Paso a paso:
- Rote y guarde el secreto nuevo.
- Cambie el secreto en su servidor.
- Confirme en el registro de entregas que las entregas siguen con éxito.
Rotar otra vez dentro de las 24 horas descarta el secreto más antiguo: conviven como máximo dos, el actual y el inmediatamente anterior. Si rotó dos veces seguidas, su servidor tiene que estar con uno de los dos últimos.
Rote cuando alguien que conocía el secreto deja el equipo o el proveedor, o cuando el secreto pasó por un lugar que usted no controla (un ticket, una conversación, un log).
Próximo paso
Sección titulada «Próximo paso»- Entregas y reintentos: qué significa su respuesta para Faturei Hoje.
- Buenas prácticas: responder rápido e ignorar duplicados por el
webhook-id.