Todos los artículos
Probar webhooks de HubSpot en localhost de forma segura
HubSpotwebhookslocalhostCRM integrations

Probar webhooks de HubSpot en localhost de forma segura

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.

Preguntas frecuentes

¿Cómo pruebo un webhook de HubSpot en localhost?
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.
¿Cómo valido la firma del webhook de HubSpot?
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.
¿Por qué HubSpot reintenta el webhook?
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.
¿Qué clave debo usar para la idempotencia?
Procesa solo los type conocidos, tolera campos adicionales y deduplica con un identificador de entrega estable en almacenamiento duradero.