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
inputvalida 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_...