Un servicio remoto no puede acceder a localhost. El túnel ofrece una URL HTTPS pública y reenvía al servidor local los headers reales y el body original. Conserva el body sin modificar antes del parser JSON. Valida firma y datos, registra la entrega de forma duradera y después ejecuta la lógica de negocio.
Cómo llega un webhook de HubSpot a una aplicación local
Un servicio remoto no puede acceder a localhost. El túnel ofrece una URL HTTPS pública y reenvía al servidor local los headers reales y el body original. >documentación oficial
1. Crear un endpoint que conserve el body sin modificar
Conserva el body sin modificar antes del parser JSON. Valida firma y datos, registra la entrega de forma duradera y después ejecuta la lógica de negocio.
import express from "express";
import crypto from "node:crypto";
const app = express();
const secret = process.env.HUBSPOT_CLIENT_SECRET;
const publicBase = process.env.WEBHOOK_PUBLIC_BASE_URL;
app.post(
"/webhooks/hubspot",
express.raw({ type: "application/json", limit: "1mb" }),
async (req, res) => {
const signature = req.get("x-hubspot-signature-v3");
const timestamp = req.get("x-hubspot-request-timestamp");
const rawBody = req.body.toString("utf8");
if (!verifyHubSpotV3({
signature,
timestamp,
method: req.method,
publicUri: `${publicBase}${req.originalUrl}`,
rawBody,
secret,
})) {
return res.sendStatus(401);
}
let events;
try {
events = JSON.parse(rawBody);
if (!Array.isArray(events)) throw new Error("Expected a batch");
await enqueueBatchIdempotently(events);
} catch (error) {
console.error("HubSpot webhook rejected", error);
return res.sendStatus(500);
}
return res.sendStatus(200);
}
);
app.use(express.json());
app.listen(3000);
Registra la URL HTTPS completa, suscribe solo los eventos necesarios y provoca un caso real en un entorno de prueba. Guarda el secret fuera del repositorio.
2. Exponer el puerto local mediante HTTPS
Inicia el servidor y mantén el túnel abierto en otra terminal. Un 401 para una solicitud sin firma confirma que el routing funciona y la autenticación la rechaza.
npx portpreview 3000
https://example.portpreview.dev/webhooks/hubspot
Inicia el servidor y mantén el túnel abierto en otra terminal. Un 401 para una solicitud sin firma confirma que el routing funciona y la autenticación la rechaza.
3. Configurar el webhook en HubSpot
Registra la URL HTTPS completa, suscribe solo los eventos necesarios y provoca un caso real en un entorno de prueba. Guarda el secret fuera del repositorio. >documentación oficial
Procesa solo los type conocidos, tolera campos adicionales y deduplica con un identificador de entrega estable en almacenamiento duradero.
Validar la firma sobre los bytes exactos
Calcula el HMAC con el secret del webhook y los datos exactos definidos por el proveedor. Compara en tiempo constante y nunca registres el secret. >documentación oficial
function decodeHubSpotQuery(uri) {
const [base, query] = uri.split("?", 2);
if (query === undefined) return base;
const map = {
"%3A": ":", "%2F": "/", "%3F": "?", "%40": "@",
"%21": "!", "%24": "$", "%27": "'", "%28": "(",
"%29": ")", "%2A": "*", "%2C": ",", "%3B": ";",
};
const decoded = query.replace(
/%3A|%2F|%3F|%40|%21|%24|%27|%28|%29|%2A|%2C|%3B/g,
value => map[value]
);
return `${base}?${decoded}`;
}
function verifyHubSpotV3(input) {
if (!input.secret || !input.signature || !input.timestamp) return false;
const sentAt = Number(input.timestamp);
if (!Number.isFinite(sentAt) || Math.abs(Date.now() - sentAt) > 300_000) {
return false;
}
const uri = decodeHubSpotQuery(input.publicUri.split("#")[0]);
const source = `${input.method}${uri}${input.rawBody}${input.timestamp}`;
const expected = crypto
.createHmac("sha256", input.secret)
.update(source, "utf8")
.digest("base64");
const actualBuffer = Buffer.from(input.signature, "utf8");
const expectedBuffer = Buffer.from(expected, "utf8");
return actualBuffer.length === expectedBuffer.length &&
crypto.timingSafeEqual(actualBuffer, expectedBuffer);
}
Comprueba la vigencia del timestamp después de validar la firma y mantén sincronizado el reloj del host.
Calcula el HMAC con el secret del webhook y los datos exactos definidos por el proveedor. Compara en tiempo constante y nunca registres el secret. >guía práctica
Responder rápido y procesar de forma idempotente
Procesa solo los type conocidos, tolera campos adicionales y deduplica con un identificador de entrega estable en almacenamiento duradero.
Reserva el identificador y crea el job en una misma transacción antes de responder 200. Si falla la persistencia, devuelve un error para permitir el reintento. >guía práctica
Solucionar fallos del webhook de HubSpot
- Inicia el servidor y mantén el túnel abierto en otra terminal. Un 401 para una solicitud sin firma confirma que el routing funciona y la autenticación la rechaza.
- Calcula el HMAC con el secret del webhook y los datos exactos definidos por el proveedor. Compara en tiempo constante y nunca registres el secret.
- Comprueba routing, headers, firma, tiempo de respuesta e idempotencia. Usa solicitudes sintéticas principalmente para probar rechazos.
- Revisa el path POST, el port del túnel, el body original, el secret, el reloj y la latencia de la base de datos.
>guía práctica · >guía práctica
Lista de seguridad para desarrollo y producción
- Exige HTTPS, limita tamaño y method, protege secrets, anonimiza logs y elimina URLs de prueba antiguas.
- Conserva el body sin modificar antes del parser JSON. Valida firma y datos, registra la entrega de forma duradera y después ejecuta la lógica de negocio.
- Reserva el identificador y crea el job en una misma transacción antes de responder 200. Si falla la persistencia, devuelve un error para permitir el reintento.
Exige HTTPS, limita tamaño y method, protege secrets, anonimiza logs y elimina URLs de prueba antiguas.
