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
// 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;
๐
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' })
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