← Volver al catálogo
AutomatizacionesReferenciaIntermedioGratis

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.

Descargar skill (.zip)

Descarga abierta · sin registro · para Node.js, Express, Next.js

// resultado_de_ejemplo

// 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

Node.jsExpressNext.jsFastAPIPython
Categoría
Automatizaciones
Tipo
Referencia
Nivel
Intermedio
Licencia
MIT
Seguridad
seguro · riesgo bajo
Versión
1.0.0

// 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 €.