Skip to main content

Webhooks

Los webhooks permiten que tu aplicación reciba notificaciones HTTP en tiempo real cuando ocurren eventos en ZenFlow. En lugar de consultar la API constantemente, los webhooks envían datos a tu servidor tan pronto como algo sucede.

Cómo Funcionan

  1. Te suscribes a uno o más topics (eventos)
  2. Cuando ocurre un evento, ZenFlow envía un POST a tu URL
  3. Tu servidor responde con 2xx para confirmar recepción
  4. Si falla, ZenFlow reintenta con backoff exponencial

Suscribirse a Webhooks

Para recibir notificaciones, crea un webhook especificando tu URL y los eventos que te interesan:
POST /api/v1/webhooks
{
  "name": "Mis notificaciones",
  "url": "https://tu-servidor.com/webhooks/zenflow",
  "events": ["order/created", "order/updated", "stock/updated"]
}
Guarda el secret devuelto en la respuesta. Lo necesitarás para verificar las firmas. No se mostrará nuevamente.

Topics Disponibles

Pedidos (Orders)

Se dispara cuando se crea un nuevo pedido en ZenFlow.Payload:
{
  "id": "evt_abc123",
  "event": "order/created",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    "order_id": 12345,
    "order_tenant_id": "ORD-001",
    "state_id": 1,
    "state_name": "Pendiente",
    "warehouse_id": 1,
    "warehouse_name": "Depósito Central",
    "items_count": 5,
    "total_quantity": 12,
    "source": "api",
    "source_integration": null,
    "created_at": "2024-01-15T10:30:00Z"
  }
}
CampoTipoDescripción
order_idintegerID interno del pedido
order_tenant_idstringID del pedido en tu sistema
state_idintegerID del estado actual
state_namestringNombre del estado
warehouse_idintegerID del almacén asignado
items_countintegerCantidad de líneas/productos
total_quantityintegerCantidad total de unidades
sourcestringOrigen: api, integration, manual
source_integrationstringIntegración origen (si aplica)
Se dispara cuando se modifican los detalles de un pedido (estado, items, etc).Payload:
{
  "id": "evt_def456",
  "event": "order/updated",
  "created_at": "2024-01-15T11:00:00Z",
  "data": {
    "order_id": 12345,
    "order_tenant_id": "ORD-001",
    "state_id": 3,
    "state_name": "En Preparación",
    "previous_state_id": 1,
    "previous_state_name": "Pendiente",
    "warehouse_id": 1,
    "updated_fields": ["state_id"],
    "updated_at": "2024-01-15T11:00:00Z"
  }
}
CampoTipoDescripción
previous_state_idintegerEstado anterior
previous_state_namestringNombre del estado anterior
updated_fieldsarrayCampos que fueron modificados
Se dispara cuando un pedido es cancelado.Payload:
{
  "id": "evt_ghi789",
  "event": "order/cancelled",
  "created_at": "2024-01-15T12:00:00Z",
  "data": {
    "order_id": 12345,
    "order_tenant_id": "ORD-001",
    "state_id": 7,
    "state_name": "Cancelado",
    "previous_state_id": 1,
    "previous_state_name": "Pendiente",
    "cancellation_reason": "Cliente solicitó cancelación",
    "cancelled_by": "[email protected]",
    "cancelled_at": "2024-01-15T12:00:00Z"
  }
}
CampoTipoDescripción
cancellation_reasonstringMotivo de la cancelación
cancelled_bystringUsuario que canceló
Se dispara cuando un pedido completa su fulfillment (preparado y listo para envío).Payload:
{
  "id": "evt_jkl012",
  "event": "order/completed",
  "created_at": "2024-01-15T14:00:00Z",
  "data": {
    "order_id": 12345,
    "order_tenant_id": "ORD-001",
    "state_id": 5,
    "state_name": "Completado",
    "warehouse_id": 1,
    "items_picked": 5,
    "total_quantity_picked": 12,
    "picked_by": "[email protected]",
    "started_at": "2024-01-15T13:00:00Z",
    "completed_at": "2024-01-15T14:00:00Z",
    "duration_minutes": 60
  }
}
CampoTipoDescripción
items_pickedintegerLíneas preparadas
total_quantity_pickedintegerUnidades totales preparadas
picked_bystringOperador que preparó
duration_minutesintegerTiempo de preparación

Productos (Products)

Se dispara cuando se crea un nuevo producto.Payload:
{
  "id": "evt_mno345",
  "event": "product/created",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    "product_id": 100,
    "sku": "PROD-001",
    "barcode": "7891234567890",
    "name": "Widget A",
    "description": "Descripción del producto",
    "category": "Electrónica",
    "price": 1500.00,
    "cost": 800.00,
    "weight": 0.5,
    "created_at": "2024-01-15T10:30:00Z"
  }
}
Se dispara cuando se modifican los detalles de un producto.Payload:
{
  "id": "evt_pqr678",
  "event": "product/updated",
  "created_at": "2024-01-15T11:00:00Z",
  "data": {
    "product_id": 100,
    "sku": "PROD-001",
    "barcode": "7891234567890",
    "name": "Widget A - Actualizado",
    "updated_fields": ["name", "price"],
    "updated_at": "2024-01-15T11:00:00Z"
  }
}
Se dispara cuando se elimina un producto.Payload:
{
  "id": "evt_stu901",
  "event": "product/deleted",
  "created_at": "2024-01-15T12:00:00Z",
  "data": {
    "product_id": 100,
    "sku": "PROD-001",
    "barcode": "7891234567890",
    "name": "Widget A",
    "deleted_at": "2024-01-15T12:00:00Z"
  }
}

Stock

Se dispara cuando cambia el nivel de stock de un producto en una ubicación.Payload:
{
  "id": "evt_vwx234",
  "event": "stock/updated",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    "product_id": 100,
    "sku": "PROD-001",
    "barcode": "7891234567890",
    "product_name": "Widget A",
    "warehouse_id": 1,
    "warehouse_name": "Depósito Central",
    "location_id": "A-01-01",
    "quantity": 50,
    "previous_quantity": 75,
    "change": -25,
    "reason": "order_fulfillment",
    "reference_id": "ORD-001"
  }
}
CampoTipoDescripción
quantityintegerNueva cantidad
previous_quantityintegerCantidad anterior
changeintegerDiferencia (+ o -)
reasonstringMotivo del cambio
reference_idstringID de referencia (pedido, ajuste, etc)
Valores de reason:
  • order_fulfillment - Preparación de pedido
  • manual_adjustment - Ajuste manual
  • stock_receipt - Recepción de mercadería
  • transfer - Transferencia entre ubicaciones
  • return - Devolución
  • inventory_count - Conteo de inventario
Se dispara cuando el stock de un producto cae por debajo del umbral mínimo configurado.Payload:
{
  "id": "evt_yz0567",
  "event": "stock/low_alert",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    "product_id": 100,
    "sku": "PROD-001",
    "barcode": "7891234567890",
    "product_name": "Widget A",
    "warehouse_id": 1,
    "warehouse_name": "Depósito Central",
    "current_quantity": 5,
    "minimum_threshold": 10,
    "suggested_reorder": 50
  }
}
CampoTipoDescripción
current_quantityintegerStock actual
minimum_thresholdintegerUmbral mínimo configurado
suggested_reorderintegerCantidad sugerida a reponer
Se dispara cuando se registra un movimiento de stock (entrada, salida, transferencia).Payload:
{
  "id": "evt_abc890",
  "event": "stock/movement_created",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    "movement_id": 5678,
    "type": "transfer",
    "product_id": 100,
    "sku": "PROD-001",
    "product_name": "Widget A",
    "quantity": 20,
    "from_location": "A-01-01",
    "to_location": "B-02-03",
    "warehouse_id": 1,
    "created_by": "[email protected]",
    "notes": "Reubicación por espacio",
    "created_at": "2024-01-15T10:30:00Z"
  }
}
CampoTipoDescripción
typestringTipo: receipt, dispatch, transfer, adjustment
from_locationstringUbicación origen (null si es entrada)
to_locationstringUbicación destino (null si es salida)

Flujos de Picking (Flows)

Se dispara cuando un operador inicia un flujo de picking.Payload:
{
  "id": "evt_def123",
  "event": "flow/started",
  "created_at": "2024-01-15T13:00:00Z",
  "data": {
    "flow_id": 789,
    "order_id": 12345,
    "order_tenant_id": "ORD-001",
    "warehouse_id": 1,
    "operator_id": 5,
    "operator_email": "[email protected]",
    "items_to_pick": 5,
    "total_quantity": 12,
    "started_at": "2024-01-15T13:00:00Z"
  }
}
Se dispara cuando un flujo de picking se completa exitosamente.Payload:
{
  "id": "evt_ghi456",
  "event": "flow/completed",
  "created_at": "2024-01-15T14:00:00Z",
  "data": {
    "flow_id": 789,
    "order_id": 12345,
    "order_tenant_id": "ORD-001",
    "warehouse_id": 1,
    "operator_id": 5,
    "operator_email": "[email protected]",
    "items_picked": 5,
    "total_quantity_picked": 12,
    "started_at": "2024-01-15T13:00:00Z",
    "completed_at": "2024-01-15T14:00:00Z",
    "duration_minutes": 60
  }
}
Se dispara cuando un flujo de picking es cancelado.Payload:
{
  "id": "evt_jkl789",
  "event": "flow/cancelled",
  "created_at": "2024-01-15T13:30:00Z",
  "data": {
    "flow_id": 789,
    "order_id": 12345,
    "order_tenant_id": "ORD-001",
    "warehouse_id": 1,
    "operator_id": 5,
    "operator_email": "[email protected]",
    "cancellation_reason": "Producto no disponible",
    "items_picked_before_cancel": 2,
    "started_at": "2024-01-15T13:00:00Z",
    "cancelled_at": "2024-01-15T13:30:00Z"
  }
}

Estructura del Payload

Todos los payloads siguen esta estructura base:
{
  "id": "evt_abc123",
  "event": "order/created",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    // Datos específicos del evento
  }
}
CampoTipoDescripción
idstringID único del evento (usar para idempotencia)
eventstringTopic del evento
created_atstringTimestamp ISO 8601
dataobjectPayload específico del evento

Verificando Webhooks

Siempre verifica las firmas de los webhooks para asegurar que las solicitudes provienen de ZenFlow.

Formato de la Firma

ZenFlow incluye una firma en el header X-Webhook-Signature:
t=1705312200,v1=5d4b8c7a...
  • t: Timestamp Unix cuando se envió el webhook
  • v1: Firma HMAC-SHA256

Proceso de Verificación

const crypto = require("crypto");

function verifyWebhookSignature(payload, signature, secret) {
  const parts = signature.split(",");
  const timestamp = parts.find((p) => p.startsWith("t=")).substring(2);
  const expectedSig = parts.find((p) => p.startsWith("v1=")).substring(3);

  // Verifica que el timestamp esté dentro de 5 minutos
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) {
    return false; // Ataque de replay
  }

  // Calcula la firma
  const signedPayload = `${timestamp}.${payload}`;
  const computedSig = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");

  // Comparación de tiempo constante
  return crypto.timingSafeEqual(
    Buffer.from(expectedSig),
    Buffer.from(computedSig)
  );
}

// Ejemplo con Express.js
app.post(
  "/webhooks/zenflow",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.headers["x-webhook-signature"];
    const payload = req.body.toString();

    if (
      !verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET)
    ) {
      return res.status(401).send("Firma inválida");
    }

    const event = JSON.parse(payload);
    // Procesar evento...

    res.status(200).send("OK");
  }
);

Política de Reintentos

Si la entrega del webhook falla, ZenFlow reintenta con backoff exponencial:
IntentoDelay
1Inmediato
21 segundo
32 segundos
4 (final)4 segundos
Una entrega se considera fallida si:
  • Tu servidor retorna un código de estado no-2xx
  • La conexión expira (30 segundos por defecto)
  • Ocurren errores SSL/TLS

Mejores Prácticas

Responde Rápido

Retorna 200 inmediatamente, procesa async

Maneja Duplicados

Usa el ID del evento para idempotencia

Verifica Firmas

Siempre valida las firmas de los webhooks

Usa HTTPS

Solo usa URLs de webhook con HTTPS

Procesamiento Asíncrono

app.post("/webhooks/zenflow", (req, res) => {
  // Responde inmediatamente
  res.status(200).send("OK");

  // Procesa en segundo plano
  processWebhookAsync(req.body).catch(console.error);
});

async function processWebhookAsync(event) {
  // Verifica duplicado
  if (await isProcessed(event.id)) {
    return;
  }

  // Procesa evento
  switch (event.event) {
    case "order.created":
      await handleOrderCreated(event.data);
      break;
    // ... otros eventos
  }

  // Marca como procesado
  await markProcessed(event.id);
}

Gestionando Webhooks

Ver Historial de Entregas

GET /api/v1/webhooks/:id/deliveries

Probar tu Webhook

POST /api/v1/webhooks/:id/test
Esto envía un evento de prueba para verificar que tu endpoint está funcionando.

Rotar Secret

Si el secret de tu webhook está comprometido:
POST /api/v1/webhooks/:id/rotate-secret
Actualiza tu servidor con el nuevo secret antes de que el anterior expire.