C

Cursor Webhooks — Recepción y Verificación

Referencia de código lista para producción · CULTIVA IA · Proyecto: automatizacion-clientes

HMAC-SHA256 Express + FastAPI MIT
Seguridad
Timing-Safe
Frameworks
2 implementaciones
Eventos cubiertos
FINISHED · ERROR
Dependencias externas
0 (built-in)

Flujo de verificacion

Cursor Cloud Agent
envia POST
Leer cabeceras
x-webhook-signature
extraer SHA256=…
HMAC-SHA256
raw body + secret
calcular expected
timingSafeEqual
/ compare_digest
comparacion segura
Procesar payload
200 OK
statusChange
401 Unauthorized
firma invalida
CRITICO — Express: usa express.raw(), NO express.json() La verificacion HMAC requiere el cuerpo en bytes sin parsear. Si usas express.json() primero, la firma siempre fallara porque el body ya fue transformado.

Node.js — Express

JavaScript
cursor-webhook.js JavaScript
const crypto  = require('crypto');
const express = require('express');

const app = express();

// ——— Verificacion de firma HMAC-SHA256 ———
function verifyCursorWebhook(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;

  // Cursor envia: sha256=<hex>
  const [algorithm, signature] = signatureHeader.split('=');
  if (algorithm !== 'sha256') return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  try {
    // timingSafeEqual previene timing attacks
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  } catch {
    return false;
  }
}

// ——— Endpoint POST /webhooks/cursor ———
app.post(
  '/webhooks/cursor',
  express.raw({ type: 'application/json' }), // CRITICO: raw body
  (req, res) => {
    const signature  = req.headers['x-webhook-signature'];
    const webhookId  = req.headers['x-webhook-id'];
    const event      = req.headers['x-webhook-event'];
    const secret     = process.env.CURSOR_WEBHOOK_SECRET;

    // 1. Verificar firma
    if (!verifyCursorWebhook(req.body, signature, secret)) {
      console.error(`[CULTIVA] Firma invalida — webhook rechazado (id: ${webhookId})`);
      return res.status(401).json({ error: 'Firma invalida' });
    }

    // 2. Parsear payload DESPUES de verificar
    const payload = JSON.parse(req.body.toString());
    console.log(`[CULTIVA] Evento recibido: ${event} (id: ${webhookId})`);

    // 3. Manejar statusChange
    if (event === 'statusChange') {
      const { id, status, summary, target } = payload;

      if (status === 'FINISHED') {
        console.log(`[CULTIVA] Agente ${id} FINALIZADO. PR: ${target?.prUrl}`);
        console.log(`[CULTIVA] Resumen: ${summary}`);
        // TODO: actualizar dashboard, notificar Slack success
      } else if (status === 'ERROR') {
        console.error(`[CULTIVA] Agente ${id} con ERROR — notificando al lead tecnico`);
        // TODO: notifySlack({ text: `ERROR en agente ${id}`, channel: '#alertas-ia' });
      }
    }

    // 4. Responder inmediatamente (antes de procesamiento largo)
    return res.json({ received: true });
  }
);

app.listen(3000, () => console.log('[CULTIVA] Servidor webhooks en :3000'));

Python — FastAPI

Python
cursor_webhook.py Python
import hmac
import hashlib
import json
import os
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse

app = FastAPI(title="CULTIVA IA — Cursor Webhooks")


def verify_cursor_webhook(
    body: bytes,
    signature_header: str,
    secret: str
) -> bool:
    """Verifica HMAC-SHA256 con comparacion timing-safe."""
    if not signature_header or not secret:
        return False

    parts = signature_header.split("=")
    if len(parts) != 2 or parts[0] != "sha256":
        return False

    signature = parts[1]
    expected = hmac.new(
        secret.encode(),
        body,
        hashlib.sha256
    ).hexdigest()

    # hmac.compare_digest previene timing attacks
    return hmac.compare_digest(signature, expected)


@app.post("/webhooks/cursor")
async def cursor_webhook(request: Request):
    body      = await request.body()
    sig       = request.headers.get("x-webhook-signature", "")
    wh_id     = request.headers.get("x-webhook-id", "")
    event     = request.headers.get("x-webhook-event", "")
    secret    = os.getenv("CURSOR_WEBHOOK_SECRET", "")

    if not verify_cursor_webhook(body, sig, secret):
        print(f"[CULTIVA] Firma invalida (id={wh_id})")
        raise HTTPException(status_code=401,
                            detail="Firma invalida")

    payload = json.loads(body)
    print(f"[CULTIVA] Evento: {event} (id={wh_id})")

    if event == "statusChange":
        status  = payload.get("status")
        agent   = payload.get("id")
        summary = payload.get("summary", "")
        pr_url  = payload.get("target", {}).get("prUrl")

        if status == "FINISHED":
            print(f"[CULTIVA] {agent} FINALIZADO. PR={pr_url}")
            print(f"[CULTIVA] Resumen: {summary}")
            # await notify_slack_success(agent, pr_url, summary)
        elif status == "ERROR":
            print(f"[CULTIVA] ERROR en agente {agent}")
            # await notify_slack_error(agent)

    return JSONResponse({"received": True})
.env ENV
# Signing secret desde Cursor Dashboard
CURSOR_WEBHOOK_SECRET=cltv_whsec_a1b2c3d4e5f6g7h8i9j0
payload-ejemplo.json JSON
{
  "event": "statusChange",
  "timestamp": "2026-06-15T09:14:00.000Z",
  "id": "agent_cultiva_7f2a1d",
  "status": "FINISHED",
  "source": {
    "repository": "github.com/cultiva-ia/automatizacion",
    "ref": "main"
  },
  "target": {
    "url": "github.com/cultiva-ia/automatizacion/pull/42",
    "branchName": "cursor/fix-migracion-db",
    "prUrl": "github.com/.../pull/42"
  },
  "summary": "Actualizo 4 migraciones SQL y corrio tests"
}

Cabeceras HTTP del webhook

Cabecera Ejemplo Descripcion Obligatorio
x-webhook-signature sha256=3a4b5c… HMAC-SHA256 del raw body. Siempre empieza por sha256= Si
x-webhook-id wh_01J3X… Identificador unico del webhook para idempotencia Si
x-webhook-event statusChange Tipo de evento. Actualmente solo statusChange Si
Content-Type application/json El body siempre es JSON serializado Siempre

Valores de status

StatusSignificadoAccion recomendada
FINISHED Agente completo con exito Leer summary y target.prUrl, actualizar dashboard, notificar equipo
ERROR El agente encontro un error irrecuperable Alertar al lead tecnico (Slack), registrar en log, revisar PR si existe
Desarrollo local — Hookdeck CLI (sin cuenta) Ejecuta npx hookdeck-cli listen 3000 cursor --path /webhooks/cursor para obtener una URL publica que reenvie eventos a tu servidor local. Incluye UI de inspeccion de peticiones.