NutriFlow AI
Receptor de Webhooks OpenAI โ€” Produccion
โœ“ Verificacion HMAC Anti-Replay 5min Express + Node 20
Eventos manejados
7
fine-tuning + batch + realtime
Algoritmo firma
HMAC-SHA256
Standard Webhooks v1
Ventana anti-replay
ยฑ300s
5 minutos, time-safe compare
Respuesta HTTP
200 < 50ms
Procesamiento asincrono
Flujo de verificacion
๐Ÿค–
OpenAI
POST /webhooks
โ†’
๐Ÿ”
Verify Signature
HMAC-SHA256
โ†’
โฑ๏ธ
Check Timestamp
Anti-replay ยฑ5min
โ†’
๐Ÿ”€
Event Router
switch(event.type)
โ†’
โš™๏ธ
Async Handler
setImmediate()
โ†’
๐Ÿ—„๏ธ
DB + Notify
PostgreSQL + Email
Eventos configurados para NutriFlow
Evento OpenAI
Categoria
Accion en NutriFlow
Estado
fine_tuning.job.succeeded
Fine-tuning
updateClinicModel() + notifyNutritionist()
โœ“ Manejado
fine_tuning.job.failed
Fine-tuning
alertEngineeringTeam(jobId, error)
โš  Alerta
fine_tuning.job.cancelled
Fine-tuning
updateClinicJobStatus('cancelled')
โœ“ Manejado
batch.completed
Batch API
processBatchResults(batchId)
โœ“ Manejado
batch.failed
Batch API
alertEngineeringTeam(batchId, 'batch_failed')
โš  Alerta
batch.expired
Batch API
scheduleRetry(batchId)
โš  Reintento
realtime.call.incoming
Realtime API
routeRealtimeSession(callId)
โœ“ Manejado
Implementacion Express โ€” src/webhooks/openai.js
src/webhooks/openai.js
Node 20 Express 4 Production-ready
openai.js
.env
tests/
// Generated with: openai-webhooks skill
// https://github.com/hookdeck/webhook-skills
// Cliente: NutriFlow AI โ€” Receptor de Webhooks OpenAI

const express = require('express');
const crypto  = require('crypto');
const { db }   = require('../db');
const { mailer } = require('../mailer');

const router = express.Router();

// โ”€โ”€โ”€ Verificacion de firma HMAC-SHA256 (Standard Webhooks) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function verifyOpenAISignature(payload, webhookId, webhookTimestamp, webhookSignature, secret) {
  if (!webhookSignature || !webhookSignature.includes(',')) return false;

  // Anti-replay: rechazar si el timestamp supera ยฑ5 minutos
  const now  = Math.floor(Date.now() / 1000);
  const diff = now - parseInt(webhookTimestamp, 10);
  if (Math.abs(diff) > 300) {
    console.warn(`[openai-webhook] Replay detectado: diff=${diff}s`);
    return false;
  }

  const [version, signature] = webhookSignature.split(',');
  if (version !== 'v1') return false;

  const payloadStr    = payload instanceof Buffer ? payload.toString('utf8') : payload;
  const signedContent = `${webhookId}.${webhookTimestamp}.${payloadStr}`;

  const secretKey   = secret.startsWith('whsec_') ? secret.slice(6) : secret;
  const secretBytes = Buffer.from(secretKey, 'base64');

  const expected = crypto
    .createHmac('sha256', secretBytes)
    .update(signedContent, 'utf8')
    .digest('base64');

  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

// โ”€โ”€โ”€ Handlers de negocio NutriFlow โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
async function handleFineTuningSucceeded(event) {
  const { id: jobId, fine_tuned_model: modelId, metadata } = event.data;
  const clinicId = metadata?.clinicId;

  console.info(`[fine_tuning.succeeded] clinicId=${clinicId} modelId=${modelId}`);

  await db.query(
    `UPDATE clinics SET openai_model_id=$1, fine_tune_status='ready', updated_at=NOW() WHERE id=$2`,
    [modelId, clinicId]
  );
  await mailer.send({
    to: await getNutritionistEmail(clinicId),
    subject: 'โœ… Tu modelo NutriFlow personalizado esta listo',
    text: `El fine-tuning ha completado. Modelo: ${modelId}`,
  });
}

async function handleFineTuningFailed(event) {
  const { id: jobId, error, metadata } = event.data;
  console.error(`[fine_tuning.failed] jobId=${jobId} error=${error?.message}`);

  await db.query(
    `UPDATE clinics SET fine_tune_status='failed', updated_at=NOW() WHERE openai_job_id=$1`,
    [jobId]
  );
  await mailer.send({
    to: 'engineering@nutriflow.ai',
    subject: `๐Ÿšจ Fine-tuning fallido: ${jobId}`,
    text: JSON.stringify(error, null, 2),
  });
}

async function handleBatchCompleted(event) {
  const { id: batchId, output_file_id } = event.data;
  console.info(`[batch.completed] batchId=${batchId} file=${output_file_id}`);

  await db.query(
    `UPDATE batch_jobs SET status='completed', output_file_id=$1, completed_at=NOW() WHERE openai_batch_id=$2`,
    [output_file_id, batchId]
  );
  // Encolar procesamiento de resultados (no bloquea el webhook)
  setImmediate(() => processBatchResults(batchId, output_file_id));
}

// โ”€โ”€โ”€ Endpoint POST /webhooks/openai โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
router.post('/openai',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const { 'webhook-id': wId, 'webhook-timestamp': wTs, 'webhook-signature': wSig } = req.headers;

    if (!verifyOpenAISignature(req.body, wId, wTs, wSig, process.env.OPENAI_WEBHOOK_SECRET)) {
      return res.status(400).json({ error: 'Invalid signature' });
    }

    const event = JSON.parse(req.body.toString());
    console.info(`[openai-webhook] type=${event.type} id=${event.id}`);

    // Responder 200 inmediatamente โ€” procesar en background
    res.json({ received: true });

    setImmediate(async () => {
      try {
        switch (event.type) {
          case 'fine_tuning.job.succeeded':  await handleFineTuningSucceeded(event); break;
          case 'fine_tuning.job.failed':     await handleFineTuningFailed(event);    break;
          case 'fine_tuning.job.cancelled':  await updateClinicJobStatus(event.data.id, 'cancelled'); break;
          case 'batch.completed':            await handleBatchCompleted(event);       break;
          case 'batch.failed':               await alertEngineeringTeam(event.data.id, 'batch_failed'); break;
          case 'batch.expired':              await scheduleRetry(event.data.id);       break;
          case 'realtime.call.incoming':     await routeRealtimeSession(event.data.id); break;
          default: console.warn(`[openai-webhook] Evento no manejado: ${event.type}`);
        }
      } catch (err) {
        console.error(`[openai-webhook] Handler error type=${event.type}`, err);
      }
    });
  }
);

module.exports = router;
Garantias de seguridad implementadas
๐Ÿ”
Verificacion HMAC-SHA256
Cada request se verifica criptograficamente contra el secreto whsec_ proporcionado por OpenAI. Sin firma valida, devuelve 400.
โœ“ Comparacion en tiempo constante (timingSafeEqual)
โฑ๏ธ
Proteccion Anti-Replay
El timestamp del header se valida dentro de una ventana de ยฑ5 minutos. Webhooks con timestamps fuera del rango son rechazados antes de calcular la firma.
โœ“ Ventana: ยฑ300 segundos
๐Ÿ›ก๏ธ
Body Raw Preservado
Se usa express.raw() en el endpoint para recibir el payload sin parsear. Parsear el JSON antes de verificar la firma causaria fallos silenciosos.
โœ“ express.raw({ type: 'application/json' })
Variables de entorno requeridas (Railway.app)
OPENAI_WEBHOOK_SECRET
whsec_โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข
โ€” Secreto de firma desde el dashboard de OpenAI
OPENAI_API_KEY
sk-proj-โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข
โ€” Clave API para descargar resultados de batch
DATABASE_URL
postgresql://nutriflow:โ€ขโ€ขโ€ขโ€ข@db.railway.app:5432/prod
โ€” PostgreSQL en Railway
SMTP_HOST / SMTP_USER / SMTP_PASS
smtp.resend.com / โ€ขโ€ขโ€ขโ€ข
โ€” Nodemailer para notificaciones a nutricionistas