Saltar al contenido principal
Recibes callbacks POST de cualquier servicio externo (pasarelas de pago, GitHub, CRMs, etc.), validas el payload y procesas el evento. Patrón: ruta personalizada + solo POST + MCP desactivado + validación de payload.
index.ts
import { define, z } from "@jelou/functions";

export default define({
  name: "webhook-receiver",
  description: "Recibe y procesa webhooks de servicios externos",
  input: z.object({
    event: z.string(),
    data: z.object({
      id: z.string(),
      status: z.string(),
      metadata: z.record(z.unknown()).optional(),
    }),
  }),
  config: {
    public: true,
    path: "/webhooks/events",
    methods: ["POST"],
    mcp: false,
  },
  handler: async (input, ctx) => {
    ctx.log("Webhook recibido", {
      event: input.event,
      id: input.data.id,
      requestId: ctx.requestId,
    });

    const secret = ctx.env.get("WEBHOOK_SECRET");

    switch (input.event) {
      case "payment.completed": {
        ctx.log("Pago completado", { id: input.data.id });
        return { acknowledged: true, action: "payment_processed" };
      }
      case "user.created": {
        ctx.log("Usuario creado", { id: input.data.id });
        return { acknowledged: true, action: "user_synced" };
      }
      default: {
        ctx.log("Evento no manejado", { event: input.event });
        return { acknowledged: true, action: "ignored" };
      }
    }
  },
});

Prueba local

curl -X POST http://localhost:3000/webhooks/events \
  -H "Content-Type: application/json" \
  -d '{"event": "payment.completed", "data": {"id": "pay_123", "status": "success"}}'

Por qué funciona así

  • config.methods: ["POST"] — rechaza GET, PUT, etc. Los webhooks siempre son POST.
  • config.mcp: false — no tiene sentido exponer un webhook como herramienta de IA.
  • config.path — ruta fija que configuras en el servicio externo.
  • El esquema input valida la estructura del payload antes de que llegue al handler.

Validar firma del webhook

En producción, verifica la firma del servicio externo antes de procesar el evento:
handler: async (input, ctx, request) => {
  const signature = request.headers.get("x-webhook-signature");
  const secret = ctx.env.get("WEBHOOK_SECRET");

  if (!signature || !secret) {
    return { error: "missing_signature" };
  }

  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw",
    encoder.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["verify"],
  );

  const body = await request.clone().text();
  const sigBytes = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0));
  const valid = await crypto.subtle.verify("HMAC", key, sigBytes, encoder.encode(body));

  if (!valid) {
    ctx.log("Firma inválida", { signature });
    return { error: "invalid_signature" };
  }

  // Procesar el evento...
  return { acknowledged: true };
}
Configura WEBHOOK_SECRET como secret: jelou secrets set mi-webhook WEBHOOK_SECRET=whsec_...