CultivaFlow · Webhook Service
Vercel Webhooks — Verificación HMAC-SHA1
● Activo Express + Next.js Production-ready
247
Webhooks recibidos hoy
100%
Firmas verificadas correctamente
3
Despliegues fallidos (alertados)
Flujo de verificación de webhook
Vercel Platform
POST evento
🔑
Header
x-vercel-signature
⚙️
HMAC-SHA1
raw body + secret
🛡️
timingSafeEqual
anti timing-attack
Evento validado
handler + Slack
🗄️
DB + Audit log
PostgreSQL
🟢 Express — Servidor webhook dedicado server.js
server.js
.env
package.json
// cultivaflow-webhooks/server.js
const express = require('express');
const crypto  = require('crypto');
const pg      = require('pg');
const fetch   = require('node-fetch');

const app = express();
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

// ─── Verificador de firma ───────────────────────────────────
function verifyVercelSignature(body, signature, secret) {
  const expected = crypto
    .createHmac('sha1', secret)
    .update(body)
    .digest('hex');
  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  } catch { return false; }
}

// ─── Notificador Slack ──────────────────────────────────────
async function notifySlack(event, payload) {
  const icons = {
    'deployment.created':   '🚀',
    'deployment.succeeded': '✅',
    'deployment.error':     '🔴',
    'deployment.canceled':  '⏹️',
    'project.created':     '📁',
  };
  await fetch(process.env.SLACK_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: `${icons[event] || '📌'} *${event}*`,
      attachments: [{
        color: event.includes('error') ? '#f85149' : '#3fb950',
        fields: [
          { title: 'Deployment', value: payload.deployment?.id, short: true },
          { title: 'URL', value: payload.deployment?.url, short: true },
        ]
      }]
    })
  });
}

// ─── Endpoint webhook ───────────────────────────────────────
app.post('/webhooks/vercel',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const sig = req.headers['x-vercel-signature'];
    if (!sig) return res.status(400).send('Missing signature');

    if (!verifyVercelSignature(req.body, sig, process.env.VERCEL_WEBHOOK_SECRET)) {
      console.error('[SECURITY] Firma inválida rechazada');
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(req.body.toString());

    // Audit log en PostgreSQL
    await pool.query(
      `INSERT INTO webhook_events (id, type, payload, received_at)
       VALUES ($1, $2, $3, NOW())`,
      [event.id, event.type, event.payload]
    );

    switch (event.type) {
      case 'deployment.created':
      case 'deployment.succeeded':
      case 'deployment.error':
        await notifySlack(event.type, event.payload);
        break;
      case 'project.created':
        console.log('Nuevo proyecto:', event.payload.project.name);
        break;
      default:
        console.log('Evento no manejado:', event.type);
    }

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

app.listen(3001, () =>
  console.log('🎣 Webhook server en puerto 3001')
);
Next.js — API Route (App Router) route.ts
app/api/webhooks/vercel/route.ts
// app/api/webhooks/vercel/route.ts — CultivaFlow
import { NextRequest, NextResponse } from 'next/server';
import { createHmac, timingSafeEqual } from 'crypto';
import { db } from '@/lib/db';

// CRÍTICO: desactivar body parser de Next.js
export const config = { api: { bodyParser: false } };

function verify(body: Buffer, sig: string): boolean {
  const expected = createHmac('sha1', process.env.VERCEL_WEBHOOK_SECRET!)
    .update(body)
    .digest('hex');
  try {
    return timingSafeEqual(
      Buffer.from(sig),
      Buffer.from(expected)
    );
  } catch { return false; }
}

export async function POST(req: NextRequest) {
  const signature = req.headers.get('x-vercel-signature');
  if (!signature) {
    return NextResponse.json(
      { error: 'Missing signature' }, { status: 400 }
    );
  }

  const body = Buffer.from(await req.arrayBuffer());
  if (!verify(body, signature)) {
    return NextResponse.json(
      { error: 'Invalid signature' }, { status: 401 }
    );
  }

  const event: VercelWebhookEvent = JSON.parse(body.toString());

  // Idempotencia: evitar procesar duplicados
  const exists = await db.webhookEvents.findUnique({ where: { id: event.id } });
  if (exists) return NextResponse.json({ received: true, duplicate: true });

  await db.webhookEvents.create({
    data: { id: event.id, type: event.type, payload: event.payload }
  });

  // Encola procesamiento asíncrono (no bloquear respuesta)
  void processEvent(event);

  return NextResponse.json({ received: true, id: event.id });
}
📋 Log de eventos recibidos — últimas 24h ● En vivo
Timestamp Tipo de evento Deployment / Proyecto Firma Estado
14:52:03 deployment.succeeded dpl_Ax9mQ2kRvB · cultivaflow.vercel.app ✓ válida 200 OK
14:51:47 deployment.created dpl_Ax9mQ2kRvB · main → production ✓ válida 200 OK
13:18:22 deployment.error dpl_Bz3nP7sLwC · feat/dashboard ✓ válida 200 OK
12:05:11 project.created cultivaflow-staging ✓ válida 200 OK
09:44:01 ⚠ Intento inválido — payload manipulado ✗ inválida 401
🕐 Último despliegue — dpl_Ax9mQ2kRvB
14:51:47
🚀
deployment.created
Branch: main → production · Webhook recibido y verificado en 12ms
14:51:49
⚙️
deployment.building
Slack notificado · Audit log guardado en PostgreSQL
14:52:03
deployment.succeeded
URL: cultivaflow.vercel.app · Slack alertado · 16s total
14:52:04
🗄️
Post-deploy hook ejecutado
Cache invalidada · CDN purgado · Monitorización activa
🔐 Variables de entorno requeridas
VariableDescripciónObligatoria
VERCEL_WEBHOOK_SECRET Secreto del webhook (Dashboard Vercel)
SLACK_WEBHOOK_URL Incoming webhook de Slack para alertas
DATABASE_URL PostgreSQL para audit log de eventos
VERCEL_TOKEN API token (rollbacks automáticos) Opcional
📡 Eventos soportados
EventoAcción CultivaFlow
deployment.created Slack "Iniciando deploy..."
deployment.succeeded Slack OK + URL + DB log
deployment.error Slack urgente + abrir issue
deployment.canceled DB log + limpiar recursos
project.created Auto-config monitorización
domain.created Verificar DNS + SSL
🛡️ Checklist de seguridad — Producción
✅ HMAC-SHA1 con timingSafeEqual
Evita timing attacks. Comparación bit a bit en tiempo constante, nunca === directo.
✅ Raw body antes del parse
Express.raw() o arrayBuffer(). Nunca verificar con body ya parseado como JSON.
✅ Idempotencia por event.id
Vercel reenvía si no recibe 2xx. Guardar event.id en DB evita procesamiento doble.
✅ Secreto en variable de entorno
Nunca hardcoded en código. Rotar desde Vercel Dashboard si hay exposición.
✅ Respuesta 2xx inmediata
Responder antes de procesar. Encolar trabajo pesado con void processEvent().
⚡ Dev: hookdeck-cli tunnel
npx hookdeck-cli listen 3001 vercel --path /webhooks/vercel — sin cuenta, inspeccionable.
CultivaFlow · Webhook Service v1.0 · Generado con CULTIVA IA · Skill: webhooks-vercel-verificacion-firma