πŸ“¨

Resend Webhook Handler β€” NutriFlow SaaS

Handler de producciΓ³n Β· Express + Svix HMAC-SHA256 Β· Generado con webhooks-resend-email skill

Node.js Resend Svix
NutriFlow SaaS
nutriflow.app
POST /webhooks/resend
4000
PostgreSQL (tabla email_logs)
Slack webhook (bounces)
Flujo de arquitectura
πŸ“€
Resend
Email SaaS
β†’
πŸ”’
Svix Verify
HMAC-SHA256
β†’
⚑
Event Router
webhook.js
← este archivo
β†’
πŸ—„οΈ
PostgreSQL
email_logs
β†’
πŸ“Š
MΓ©tricas
+ Slack alerts
Eventos manejados
βœ…
email.sent
Email enviado exitosamente por Resend
β†’ UPDATE DB
πŸ“¬
email.delivered
Entregado en bandeja del destinatario
β†’ UPDATE DB
⏳
email.delivery_delayed
Retardo en entrega detectado
β†’ LOG + metric
↩️
email.bounced
DirecciΓ³n invΓ‘lida o buzΓ³n lleno
β†’ SLACK ALERT
🚫
email.complained
Marcado como spam por el usuario
β†’ SLACK ALERT
πŸ‘οΈ
email.opened
Email abierto β€” tracking pixel
β†’ METRIC
πŸ–±οΈ
email.clicked
Enlace clickado en el email
β†’ METRIC
πŸ“₯
email.received
Email inbound recibido en dominio
β†’ PROCESS
Variables de entorno
# .env β€” NutriFlow production
RESEND_API_KEY=re_NutriFlow_k4m9x2vL8pQrTn7wBsYjHa
RESEND_WEBHOOK_SECRET=whsec_NutriFlow_3fGhJkLmNpQrStUvWxYz01
DATABASE_URL=postgresql://nutriflow:secret@db.nutriflow.app:5432/nutriflow_prod
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T0NF/B0XK/nutriflow_alerts
PORT=4000
CΓ³digo generado β€” webhook.js
src/webhook.js
147 lΓ­neas Β· ES2022
// 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;

βœ… GarantΓ­as de producciΓ³n

πŸ› οΈ Setup local (Hookdeck)