Guía rápida de verificación para integradores que reciben webhooks de iNotus. Cubre headers, firma HMAC y un payload de ejemplo.
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. |
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 ==).
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'), ); }
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).
| 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.
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..."
}
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.