CULTIVA IA · Automatizaciones · Handler Express listo para producción
express.raw() — Notion requiere el cuerpo crudo (sin parsear) para la verificación de firma. Si usas express.json() el HMAC fallará siempre.
// 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') );
npx hookdeck-cli listen 3000 notion --path /webhooks/notion — Notion no soporta localhost directamente.
| Evento | Estado |
|---|---|
| page.content_updated | activo |
| page.properties_updated | activo |
| page.created | activo |
| page.deleted | activo |
| comment.created | activo |
| data_source.schema_updated | activo |
| database.schema_updated | deprecated |
# 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}
timingSafeEqual / compare_digest) para evitar timing attacks en la verificación HMAC.