Hookdeck: Verificación de Webhooks via Event Gateway
Skill de referencia para verificar y procesar webhooks entregados a través del Hookdeck Event Gateway, con ejemplos completos de verificación de firma HMAC SHA-256 en Express, Next.js y FastAPI.
Descarga abierta · sin registro · para Node.js, Express, Next.js
// Generated with: hookdeck-event-gateway-webhooks skill // https://github.com/hookdeck/webhook-skills // // ============================================================ // AutoFlow SaaS — Webhook Handler via Hookdeck Event Gateway // Stack: Node.js 20 + Express 4.x + Redis (idempotency) + PostgreSQL // ============================================================
'use strict';
const express = require('express'); const crypto = require('crypto'); const { createClient } = require('redis'); // npm i redis const { Pool } = require('pg'); // npm i pg
// ─── Configuración desde variables de entorno ──────────────────────────────── // Copiar en Railway → Variables y en .env.local para desarrollo // // HOOKDECK_WEBHOOK_SECRET → Dashboard Hookdeck → Destinations → autoflow-prod → Webhook Secret // REDIS_URL → redis://default:@:6379 // DATABASE_URL → postgresql://user:pass@host:5432/autoflow // const { HOOKDECK_WEBHOOK_SECRET, REDIS_URL = 'redis://localhost:6379', DATABASE_URL = 'postgresql://localhost/autoflow', PORT = 3000, } = process.env;
// ─── Clientes ──────────────────────────────────────────────────────────────── const redis = createClient({ url: REDIS_URL }); const db = new Pool({ connectionString: DATABASE_URL });
// ─── 1. Verificación de firma Hookdeck (HMAC SHA-256, base64) ──────────────── // // Hookdeck firma cada request reenviado con x-hookdeck-signature. // Se compara con timing-safe para evitar ataques de timing. // function verifyHookdeckSignature(rawBody, signature, secret) { if (!signature || !secret) return false;
const hash = crypto .createHmac('sha256', secret) .update(rawBody) // rawBody: Buffer (no parsear antes) .digest('base64'); // ⚠️ base64, NO hex
try { return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(hash), ); } catch { return false; } }
// ─── 2. Idempotencia via Redis ────────────────────────────────────────────────
//
// Cada evento tiene un x-hookdeck-eventid único. Si ya fue procesado
// (key existe en Redis con TTL 24h), devolvemos 200 inmediatamente
// sin reprocesar, evitando duplicados en reintentos de Hookdeck.
//
async function isAlreadyProcessed(eventId) {
const key = hookdeck:processed:${eventId};
const set = await redis.set(key, '1', { NX: true, EX: 86400 }); // TTL 24h
return set === null; // null → ya existía → duplicado
}
// ─── 3. Logging estructurado ────────────────────────────────────────────────── // // Registra todos los headers relevantes de Hookdeck para facilitar // debugging en Railway Logs / Datadog / Sentry. // function logHookdeckHeaders(req, extra = {}) { const headers = { eventId : req.headers['x-hookdeck-eventid'], requestId : req.headers['x-hookdeck-requestid'], sourceName : req.headers['x-hookdeck-source-name'], destinationName : req.headers['x-hookdeck-destination-name'], attemptCount : req.headers['x-hookdeck-attempt-count'], attemptTrigger : req.headers['x-hookdeck-attempt-trigger'], willRetryAfter : req.headers['x-hookdeck-will-retry-after'], eventUrl : req.headers['x-hookdeck-event-url'], verified : req.headers['x-hookdeck-verified'], originalIp : req.headers['x-hookdeck-original-ip'], }; console.log(JSON.stringify({ ts: new Date().toISOString(), ...headers, ...extra })); }
// ─── 4. Handlers por tipo de evento ──────────────────────────────────────────
// 4a. Stripe ────────────────────────────────────────────────────────────────── async function handleStripeEvent(payload) { const { type, data } = payload;
switch (type) {
case 'checkout.session.completed': {
const session = data.object;
// Activar plan en AutoFlow para el customer
await db.query(
INSERT INTO subscriptions (stripe_customer_id, plan, status, created_at) VALUES ($1, $2, 'active', NOW()) ON CONFLICT (stripe_customer_id) DO UPDATE SET plan = EXCLUDED.plan, status = 'active',
[session.customer, session.metadata?.plan ?? 'starter'],
);
console.log([Stripe] Nueva suscripción activada: ${session.customer});
break;
}
case 'invoice.paid': {
const invoice = data.object;
await db.query(
`UPDATE subscriptions SET last_payment_at = NOW()
WHERE stripe_customer_id = $1`,
[invoice.customer],
);
console.log(`[Stripe] Factura pagada: ${invoice.id}`);
break;
}
case 'customer.subscription.deleted': {
const sub = data.object;
await db.query(
`UPDATE subscriptions SET status = 'cancelled', cancelled_at = NOW()
WHERE stripe_customer_id = $1`,
[sub.customer],
);
console.log(`[Stripe] Suscripción cancelada: ${sub.customer}`);
break;
}
default:
console.log(`[Stripe] Evento ignorado: ${type}`);
} }
// 4b. Shopify ─────────────────────────────────────────────────────────────────
async function handleShopifyEvent(payload, topic) {
switch (topic) {
case 'orders/create': {
const { id, email, total_price } = payload;
await db.query(
INSERT INTO shopify_orders (shopify_order_id, email, total_price, received_at) VALUES ($1, $2, $3, NOW()),
[id, email, parseFloat(total_price)],
);
console.log([Shopify] Nuevo pedido #${id} de ${email} — ${total_price}€);
break;
}
case 'products/update': {
console.log(`[Shopify] Producto actualizado: ${payload.id} — ${payload.title}`);
break;
}
default:
console.log(`[Shopify] Topic ignorado: ${topic}`);
} }
// 4c. Typeform ──────────────────────────────────────────────────────────────── async function handleTypeformEvent(payload) { const response = payload.form_response; if (!response) return;
const email = response.answers?.find(a => a.type === 'email')?.email ?? 'desconocido'; const formId = payload.event_id;
await db.query(
INSERT INTO onboarding_responses (typeform_event_id, email, submitted_at, raw) VALUES ($1, $2, NOW(), $3) ON CONFLICT (typeform_event_id) DO NOTHING,
[formId, email, JSON.stringify(payload)],
);
console.log([Typeform] Respuesta de onboarding de ${email});
}
// ─── 5. Express App ─────────────────────────────────────────────────────────── const app = express();
app.post( '/webhooks', // ⚠️ CRÍTICO: express.raw() para obtener el body como Buffer antes de parsear. // Si usas express.json() aquí, la firma no coincidirá. express.raw({ type: 'application/json' }), async (req, res) => { const signature = req.headers['x-hookdeck-signature']; const eventId = req.headers['x-hookdeck-eventid']; const sourceName = req.headers['x-hookdeck-source-name'] ?? '';
// ── a) Verificar firma ───────────────────────────────────────────────────
if (!verifyHookdeckSignature(req.body, signature, HOOKDECK_WEBHOOK_SECRET)) {
console.error('[Hookdeck] Firma inválida — request rechazada');
return res.status(401).json({ error: 'Invalid signature' });
}
// ── b) Registrar headers para observabilidad ─────────────────────────────
logHookdeckHeaders(req, { sourceName });
// ── c) Idempotencia — evitar duplicados en reintentos ────────────────────
if (eventId && await isAlreadyProcessed(eventId)) {
console.log(`[Hookdeck] Evento duplicado ignorado: ${eventId}`);
return res.status(200).json({ received: true, duplicate: true });
}
// ── d) Parsear body (ya verificado) ──────────────────────────────────────
let payload;
try {
payload = JSON.parse(req.body.toString());
} catch {
return res.status(400).json({ error: 'Invalid JSON body' });
}
// ── e) Enrutar por fuente ─────────────────────────────────────────────────
// x-hookdeck-source-name identifica el origen configurado en Hookdeck Dashboard
try {
if (sourceName.includes('stripe')) {
await handleStripeEvent(payload);
} else if (sourceName.includes('shopify')) {
// Shopify envía el topic en su propia cabecera, preservada por Hookdeck
const topic = req.headers['x-shopify-topic'] ?? '';
await handleShopifyEvent(payload, topic);
} else if (sourceName.includes('typeform')) {
await handleTypeformEvent(payload);
} else {
console.warn(`[Hookdeck] Fuente desconocida: ${sourceName}`);
}
} catch (err) {
// Devolver 5xx → Hookdeck reintentará automáticamente (hasta 25 veces)
console.error('[Hookdeck] Error procesando evento:', err);
return res.status(500).json({ error: 'Processing error' });
}
// ── f) 200 OK → Hookdeck marca el evento como entregado ──────────────────
return res.status(200).json({ received: true });
}, );
// ─── Arranque ─────────────────────────────────────────────────────────────────
(async () => {
await redis.connect();
app.listen(PORT, () => {
console.log(AutoFlow Webhook Handler escuchando en puerto ${PORT});
console.log(Túnel local: npx hookdeck-cli listen ${PORT} gateway --path /webhooks);
});
})();
// ─── Variables de entorno (copiar en Railway → Variables) ───────────────────── // // HOOKDECK_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx // REDIS_URL=redis://default:password@redis.railway.internal:6379 // DATABASE_URL=postgresql://postgres:password@postgres.railway.internal:5432/autoflow // PORT=3000 // // Staging (misma app, source diferente en Hookdeck): // HOOKDECK_WEBHOOK_SECRET=whsec_staging_xxxxxxxxxxxxxxxxxxxxxxxx
// qué_hace
Proporciona el código y la lógica necesaria para verificar la autenticidad de webhooks reenviados por Hookdeck mediante la cabecera x-hookdeck-signature.
// cómo_lo_hace
Implementa verificación HMAC SHA-256 con comparación de tiempo constante, con handlers listos para Express, Next.js App Router y FastAPI, incluyendo referencia de todas las cabeceras de Hookdeck.
// ejemplo_de_uso
Úsala cuando uses Hookdeck como gateway de webhooks y necesites validar la autenticidad de los eventos antes de procesarlos. Ej.: verificar la firma x-hookdeck-signature en un endpoint Next.js App Router que recibe eventos de pago de Stripe.
// plataformas
// opiniones_de_la_comunidad
Opiniones
Cargando opiniones…
// pase_cultiva_ia
Llévate todo el arsenal con el Pase
Todas las skills, prompts y automatizaciones del catálogo en un único archivo, listas para usar: un pago, acceso de por vida y las novedades que añadamos. Sin suscripción.
Pago único · IVA incluido · pago seguro con Stripe.
Acceso inmediato · si no es lo que esperabas, te devolvemos los 10 €.