Handler de producciΓ³n Β· Express + Svix HMAC-SHA256 Β· Generado con webhooks-resend-email skill
// Generated with: resend-webhooks skill (webhooks-resend-email) // Cliente: NutriFlow SaaS β nutriflow.app // https://github.com/hookdeck/webhook-skills const express = require('express'); const { Resend } = require('resend'); const { Pool } = require('pg'); const router = express.Router(); const resend = new Resend(process.env.RESEND_API_KEY); const db = new Pool({ connectionString: process.env.DATABASE_URL }); // βββ HELPERS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ async function updateEmailLog(emailId, status, meta = {}) { await db.query( `UPDATE email_logs SET status = $1, updated_at = NOW(), meta = meta || $2 WHERE resend_email_id = $3`, [status, JSON.stringify(meta), emailId] ); } async function sendSlackAlert(type, data) { const emoji = type === 'bounced' ? 'β©οΈ' : 'π«'; const msg = { text: `${emoji} *NutriFlow email ${type}*\nβ’ ID: ${data.email_id}\nβ’ To: ${data.to?.[0]}\nβ’ Subject: ${data.subject}` }; await fetch(process.env.SLACK_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(msg), }); } async function incrementMetric(event, campaignId) { await db.query( `INSERT INTO email_metrics (campaign_id, event_type, count, date) VALUES ($1, $2, 1, CURRENT_DATE) ON CONFLICT (campaign_id, event_type, date) DO UPDATE SET count = email_metrics.count + 1`, [campaignId, event] ); } // βββ WEBHOOK ENDPOINT ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ router.post( '/webhooks/resend', express.raw({ type: 'application/json' }), // raw body required for Svix async (req, res) => { let event; // 1. Verificar firma Svix (HMAC-SHA256) try { event = resend.webhooks.verify({ payload: req.body.toString(), headers: { id: req.headers['svix-id'], timestamp: req.headers['svix-timestamp'], signature: req.headers['svix-signature'], }, webhookSecret: process.env.RESEND_WEBHOOK_SECRET, }); } catch (err) { console.error({ event: 'webhook.verify.failed', error: err.message }); return res.status(400).json({ error: 'Signature verification failed' }); } const { type, data } = event; console.info({ event: type, email_id: data.email_id }); // 2. Idempotency: devolver 200 inmediatamente si ya se procesΓ³ const { rows } = await db.query( 'SELECT 1 FROM webhook_events WHERE svix_id = $1', [req.headers['svix-id']] ); if (rows.length) return res.json({ received: true, duplicate: true }); // 3. Enrutar por tipo de evento try { switch (type) { case 'email.sent': await updateEmailLog(data.email_id, 'sent'); break; case 'email.delivered': await updateEmailLog(data.email_id, 'delivered', { delivered_at: new Date().toISOString() }); await incrementMetric('delivered', data.tags?.campaign_id); break; case 'email.delivery_delayed': await updateEmailLog(data.email_id, 'delayed'); break; case 'email.bounced': await updateEmailLog(data.email_id, 'bounced', { bounce_type: data.bounce?.type }); await sendSlackAlert('bounced', data); break; case 'email.complained': await updateEmailLog(data.email_id, 'complained'); await sendSlackAlert('complained', data); // Suprimir direcciΓ³n del futuro envΓo await db.query( 'UPDATE contacts SET email_suppressed = TRUE WHERE email = $1', [data.to?.[0]] ); break; case 'email.opened': await incrementMetric('opened', data.tags?.campaign_id); break; case 'email.clicked': await incrementMetric('clicked', data.tags?.campaign_id); await db.query( 'INSERT INTO link_clicks (email_id, url, clicked_at) VALUES ($1,$2,NOW())', [data.email_id, data.click?.link] ); break; case 'email.received': await processInboundEmail(data); break; default: console.warn({ event: 'webhook.unhandled', type }); } // 4. Registrar evento procesado (idempotency store) await db.query( 'INSERT INTO webhook_events (svix_id, type, processed_at) VALUES ($1,$2,NOW())', [req.headers['svix-id'], type] ); res.json({ received: true }); } catch (err) { console.error({ event: 'webhook.handler.error', type, error: err.message }); res.status(500).json({ error: 'Internal server error' }); } } ); module.exports = router;
npm install resend express pg.env.example β .envnode src/index.js (puerto 4000)npx hookdeck-cli listen 4000 resendwebhook_events para idempotency