Referencia técnica

iNotus Webhooks

Guía rápida de verificación para integradores que reciben webhooks de iNotus. Cubre headers, firma HMAC y un payload de ejemplo.

Headers enviados

Cada request es POST application/json con estos headers:

Header Ejemplo Descripción
Content-Type application/json Siempre JSON.
User-Agent iNotus-Webhook/1.0 Identificador del emisor.
X-iNotus-Event report.confirmed Tipo de evento.
X-iNotus-Signature 9f86d0... (hex 64 chars) HMAC-SHA256 del body.
X-iNotus-Timestamp 1747612800 Unix timestamp en segundos.

Firma

signature = HMAC-SHA256(
  key:  webhook_secret,
  data: "${timestamp}.${raw_body}"
)
  • webhook_secret: el secret de tu workspace (lo genera/rota iNotus, empieza por inotus_).
  • timestamp: el valor literal del header X-iNotus-Timestamp.
  • raw_body: el cuerpo del request tal cual lo recibes, sin reparsear ni reformatear. Si haces JSON.parse y vuelves a stringify, la firma no coincidirá.

Compara la firma de forma timing-safe (no ==).

Node.js

const crypto = require('crypto');

function verifySignature(rawBody, headers, secret) {
  const signature = headers['x-inotus-signature'];
  const timestamp  = headers['x-inotus-timestamp'];

  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

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

Python

import hmac, hashlib, time

def verify_signature(raw_body: bytes, headers: dict, secret: str) -> bool:
  signature = headers.get('X-iNotus-Signature', '')
  timestamp  = headers.get('X-iNotus-Timestamp', '')

  if abs(time.time() - int(timestamp)) > 300:
    return False

  expected = hmac.new(
    secret.encode(),
    f"{timestamp}.{raw_body.decode()}".encode(),
    hashlib.sha256,
  ).hexdigest()

  return hmac.compare_digest(signature, expected)

Se recomienda rechazar requests cuyo timestamp difiera más de 5 minutos del reloj actual (replay protection).

Respuesta esperada

Status Comportamiento de iNotus
2xx Recibido. Sin reintentos.
3xx Tratado como ok (sin seguir redirect).
4xx / 5xx Registrado como fallido.
Timeout (>10s) Registrado como error: "timeout".

No se requiere body de respuesta. Solo el status code importa.

Payload de ejemplo event: "report.confirmed"

{
  "event": "report.confirmed",
  "timestamp": "2026-05-19T10:14:32.000Z",
  "report": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "created_at": "2026-05-19T10:08:11.412Z",
    "status": "confirmed",
    "salesperson": {
      "id": "f8a9b0c1-d2e3-4567-89ab-cdef01234567",
      "name": "Juan Pérez",
      "phone": "+34123123123"
    }
  },
  "client": "ID_123",
  "place_visited": "Sucursal Centro",
  "template_fields": {
    "objetivo_visita": "Presentar nueva línea premium",
    "contacto": "María González - Gerente de compras",
    "resultado_visita": "Solicitan cotización formal"
  },
  "smart_sections": {
    "novedades": ["Abrieron nueva sucursal en zona norte"],
    "ventas_realizadas": ["50 unidades de SKU-1234 a 15€ unidad"],
    "stock_disponibilidad": ["Falta stock de SKU-9012"],
    "objeciones": ["Precio elevado vs competencia"],
    "proximos_pasos": ["Enviar cotización antes del viernes"],
    "sugerencias": ["Ofrecer descuento por volumen"]
  },
  "transcript": "Buenas tardes, vengo de visitar la sucursal centro..."
}

Notas sobre los campos

  • client: null si no hay match, un ID en caso de que exista un cliente.
  • template_fields: claves dinámicas según el template del manager. Valores string o null.
  • smart_sections: las 6 claves siempre presentes; cada una es un string[] (puede ser []).
  • place_visited: string o null.