Notion Webhooks — Verificación e Integración

CULTIVA IA · Automatizaciones · Handler Express listo para producción

Node.js 20 + Express 4
HMAC
SHA-256 · Header X-Notion-Signature
9
Tipos de evento soportados
2-step
Handshake → verificación activa

Flujo de Activación del Webhook

1
Crear integración
Notion UI
2
Tunnel local
hookdeck-cli
3
Handshake POST
sin firma · token
4
Pegar token
en Notion UI
5
Suscripción activa
firma HMAC
6
Verificar + procesar
cada evento
⚠️ Crítico: usar express.raw() — Notion requiere el cuerpo crudo (sin parsear) para la verificación de firma. Si usas express.json() el HMAC fallará siempre.
📄

webhook-handler.js — Handler Completo CULTIVA IA

JavaScript webhook-handler.js
// Generated with: notion-webhooks skill
// https://github.com/hookdeck/webhook-skills
// CULTIVA IA — Integración Notion → Slack + CRM (Huly)

const crypto  = require('crypto');
const express = require('express');
const app     = express();

// ─────────────────────────────────────────────────────────────
// 1. VERIFICACIÓN DE FIRMA HMAC-SHA256
// ─────────────────────────────────────────────────────────────
function verifyNotionSignature(rawBody, signatureHeader, token) {
  if (!signatureHeader || !token) return false;

  const expected = `sha256=${crypto
    .createHmac('sha256', token)
    .update(rawBody)
    .digest('hex')}`;

  try {
    return crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(signatureHeader)
    );
  } catch { return false; }
}

// ─────────────────────────────────────────────────────────────
// 2. HANDLER PRINCIPAL /webhooks/notion
// ─────────────────────────────────────────────────────────────
app.post(
  '/webhooks/notion',
  express.raw({ type: 'application/json' }), // ← CRÍTICO
  (req, res) => {
    const signature = req.headers['x-notion-signature'];
    const token     = process.env.NOTION_VERIFICATION_TOKEN;

    // ── HANDSHAKE: primer POST sin firma contiene el verification_token
    if (!signature) {
      try {
        const parsed = JSON.parse(req.body.toString('utf8'));
        if (parsed?.verification_token) {
          console.log('[NOTION] verification_token (pégalo en Notion UI):',
            parsed.verification_token);
          return res.status(200).json({ received: true });
        }
      } catch { /* ignorar errores de parsing */ }
      return res.status(400).send('Falta X-Notion-Signature');
    }

    // ── VERIFICAR FIRMA
    if (!verifyNotionSignature(req.body, signature, token)) {
      console.warn('[NOTION] Firma inválida — request rechazada');
      return res.status(401).send('Firma inválida');
    }

    const event = JSON.parse(req.body.toString('utf8'));
    console.log(`[NOTION] Evento: ${event.type} · Entidad: ${event.entity?.id}`);

    // ── ENRUTADOR DE EVENTOS
    switch (event.type) {
      case 'page.content_updated':
        notifySlack(`📝 Contenido actualizado: ${event.entity?.id}`);
        break;

      case 'page.properties_updated':
        syncToHuly(event.entity?.id, event.properties);
        notifySlack(`🔄 Propiedades modificadas: ${event.entity?.id}`);
        break;

      case 'comment.created':
        notifySlack(`💬 Nuevo comentario en: ${event.entity?.id}`);
        break;

      default:
        console.log(`[NOTION] Evento no manejado: ${event.type}`);
    }

    auditLog(event);
    res.json({ received: true });
  }
);

// ─────────────────────────────────────────────────────────────
// 3. INTEGRACIONES CULTIVA IA
// ─────────────────────────────────────────────────────────────
async function notifySlack(msg) {
  // POST a SLACK_WEBHOOK_URL con el mensaje
  console.log('[SLACK]', msg);
}

async function syncToHuly(pageId, properties) {
  // Actualiza el proyecto/ticket correspondiente en Huly CRM
  console.log('[HULY] Sincronizando página', pageId);
}

function auditLog(event) {
  const entry = {
    ts:      new Date().toISOString(),
    type:    event.type,
    entity:  event.entity?.id,
  };
  console.log('[AUDIT]', JSON.stringify(entry));
}

app.listen(3000, () =>
  console.log('🚀 CULTIVA IA webhook listener en :3000/webhooks/notion')
);
ℹ️ Para desarrollo local: npx hookdeck-cli listen 3000 notion --path /webhooks/notion — Notion no soporta localhost directamente.
🔐

Variables de Entorno

NOTION_VERIFICATION_TOKEN secret_xxxx…
SLACK_WEBHOOK_URL https://hooks.slack.com/…
HULY_API_KEY huly_xxxxxxxxxxxxxxxxx
⚠️ El token es el capturado en el handshake (NO la API token de la integración Notion).
📡

Eventos Notion (referencia)

Evento Estado
page.content_updatedactivo
page.properties_updatedactivo
page.createdactivo
page.deletedactivo
comment.createdactivo
data_source.schema_updatedactivo
database.schema_updateddeprecated
🐍

Alternativa Python (FastAPI)

Python webhook_handler.py
# Generated with: notion-webhooks skill
import hmac, hashlib, json, os
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

def verify_notion_signature(raw_body: bytes, sig: str, token: str) -> bool:
    if not sig or not token:
        return False
    expected = "sha256=" + hmac.new(
        token.encode("utf-8"), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, sig)

@app.post("/webhooks/notion")
async def notion_webhook(request: Request):
    raw  = await request.body()
    sig  = request.headers.get("x-notion-signature")
    # Handshake
    if not sig:
        data = json.loads(raw)
        if "verification_token" in data:
            print("TOKEN:", data["verification_token"])
            return {"received": True}
        raise HTTPException(400)
    # Verificar firma
    token = os.environ["NOTION_VERIFICATION_TOKEN"]
    if not verify_notion_signature(raw, sig, token):
        raise HTTPException(401)
    event = json.loads(raw)
    # …procesar event["type"]…
    return {"received": True}
Ambas implementaciones usan comparación de tiempo constante (timingSafeEqual / compare_digest) para evitar timing attacks en la verificación HMAC.
STACK
Notion API Node.js 20 Express 4 FastAPI HMAC-SHA256 hookdeck-cli Slack Webhooks Huly CRM