Firma HMAC de los avisos
Cómo firma UTILIA OS cada aviso con HMAC-SHA256 sobre la marca de tiempo y el cuerpo, y cómo verificar la firma en Node.js y Python.
Actualizado el . 5 min de lectura
Incluido en todos los planes. Ver precios
Cada aviso que UTILIA OS entrega a tu servidor viaja firmado con HMAC-SHA256. La firma garantiza dos cosas: que el aviso viene de verdad de UTILIA OS y que nadie ha tocado el cuerpo por el camino. Comprobarla es la única forma fiable de distinguir un aviso legítimo de una petición falsificada.
La firma se calcula sobre la marca de tiempo y el cuerpo, unidos por un punto: ${timestamp}.${rawBody}. Si tu servidor firma solo el cuerpo (como se hacía antes del 27 de mayo de 2026), la comparación fallará siempre.
Cómo se construye la firma
UTILIA OS calcula la firma justo antes de entregar el aviso, en cuatro pasos:
Cómo se firma un aviso
rawBody
Cuerpo en texto
El JSON del aviso, tal como viaja en la petición.X-Utilia-Timestamp
Marca de tiempo
Instante de emisión en milisegundos Unix, por ejemplo1749816000000.timestamp.rawBody
Cadena a firmar
La marca de tiempo, un punto literal y el cuerpo:timestamp.rawBody.X-Utilia-Signature
HMAC-SHA256
Con la clave de firma de la aplicación; en hexadecimal y con el prefijosha256=.
El algoritmo equivale exactamente a esta expresión:
X-Utilia-Signature = "sha256=" + HMAC_SHA256( webhookSecret, `${timestamp}.${rawBody}` ).hex()El webhookSecret es la clave de firma que te enseña la pantalla «Aplicación creada» al dar de alta la aplicación. Nunca viaja en las peticiones: UTILIA OS la usa para firmar y tu servidor, para comprobar. Si la regeneras desde la ficha de la aplicación, las entregas siguientes se firman con la nueva y tienes que actualizarla en tu servidor.
Cabeceras que acompañan cada entrega
UTILIA OS añade estas cabeceras a cada aviso. Para comprobar la firma importan las dos primeras.
- X-Utilia-Signaturestringobligatorio
- Firma HMAC-SHA256 sobre
${timestamp}.${rawBody}con la clave de firma de la aplicación. Incluye el prefijosha256=. Si la clave no se puede recuperar, la entrega no sale. - X-Utilia-Timestampstringobligatorio
- Instante de emisión en milisegundos Unix. Forma parte de la cadena firmada y permite rechazar avisos reenviados tiempo después.
- X-Utilia-Eventstringobligatorio
- Nombre del evento entregado (por ejemplo, TICKET_CREATED, BUDGET_SIGNED).
- X-Utilia-Delivery-Idstringobligatorio
- Identificador de la entrega, igual en todos sus reintentos. Úsalo para no procesar dos veces el mismo aviso.
- X-Utilia-Schema-Versionstringobligatorio
- Versión de la forma del contenido. Permite detectar un cambio de estructura sin leer el cuerpo.
Firma sobre el cuerpo tal como llegó
Calcula la firma sobre el cuerpo exactamente como llegó (rawBody), nunca sobre el JSON vuelto a convertir en texto. Si lo interpretas como objeto y lo conviertes otra vez, el orden de las claves o los espacios pueden cambiar y la firma no coincidirá aunque el contenido sea el mismo. Lee el cuerpo crudo antes de que tu framework lo interprete.
Cómo verificar la firma
Para aceptar un aviso, tu servidor hace cuatro comprobaciones, en orden:
Cómo comprueba tu servidor un aviso
Lee el cuerpo crudo
ElrawBodytal como llega, antes de que tu framework interprete el JSON.X-Utilia-Timestamp
¿Es reciente?
A menos de cinco minutos de tu reloj, hacia atrás o hacia delante.Si no: responde 401 y no lo proceses.
X-Utilia-Signature
¿La firma coincide?
Recalcula el HMAC-SHA256 y compara en tiempo constante, nunca con==.Si no: responde 401 y no lo proceses.
X-Utilia-Delivery-Id
¿Es una entrega nueva?
Los reintentos repiten el mismo identificador.Si no: responde 2xx sin volver a procesarla.
En la mayoría de frameworks, leer el cuerpo crudo exige desactivar la interpretación automática del JSON en esa ruta o leer directamente los bytes de la petición. La comparación en tiempo constante evita que el tiempo de respuesta dé pistas a un atacante.
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
const WEBHOOK_SECRET = process.env.UTILIA_WEBHOOK_SECRET;
const TOLERANCIA_MS = 5 * 60 * 1000; // ventana anti-replay de 5 minutos
// Importante: capturamos el cuerpo CRUDO. Si usas express.json() global,
// la firma fallará porque el cuerpo llega ya reserializado.
app.post(
'/webhooks/utilia',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body; // Buffer con el cuerpo crudo
const signature = req.header('X-Utilia-Signature') || '';
const timestamp = req.header('X-Utilia-Timestamp') || '';
// 1. Defensa anti-replay: el timestamp no debe ser demasiado antiguo
// (ni venir del futuro).
const edadMs = Math.abs(Date.now() - Number(timestamp));
if (!timestamp || Number.isNaN(Number(timestamp)) || edadMs > TOLERANCIA_MS) {
return res.status(401).send('Timestamp fuera de la ventana permitida');
}
// 2. Recalculamos la firma sobre `${timestamp}.${rawBody}`.
const cadenaFirmada = `${timestamp}.${rawBody.toString('utf8')}`;
const esperada =
'sha256=' +
crypto.createHmac('sha256', WEBHOOK_SECRET).update(cadenaFirmada).digest('hex');
// 3. Comparación en tiempo constante. timingSafeEqual exige buffers
// de la misma longitud, de ahí la comprobación previa.
const a = Buffer.from(signature);
const b = Buffer.from(esperada);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('Firma inválida');
}
// 4. A partir de aquí el webhook es de confianza. Parseamos y
// procesamos de forma idempotente usando X-Utilia-Delivery-Id.
const payload = JSON.parse(rawBody.toString('utf8'));
const deliveryId = req.header('X-Utilia-Delivery-Id');
// ... descarta duplicados por deliveryId y procesa payload.event ...
return res.status(200).send('ok');
},
);Python (Flask)
import hashlib
import hmac
import time
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "tu_webhook_secret"
TOLERANCIA_SEGUNDOS = 5 * 60 # ventana anti-replay de 5 minutos
@app.post("/webhooks/utilia")
def recibir_webhook():
# Importante: request.get_data() devuelve el cuerpo CRUDO (bytes),
# sin parsear. No uses request.json antes de verificar la firma.
raw_body = request.get_data()
signature = request.headers.get("X-Utilia-Signature", "")
timestamp = request.headers.get("X-Utilia-Timestamp", "")
# 1. Defensa anti-replay. El timestamp viene en milisegundos Unix.
try:
ts_segundos = int(timestamp) / 1000
except ValueError:
abort(401, "Timestamp inválido")
if abs(time.time() - ts_segundos) > TOLERANCIA_SEGUNDOS:
abort(401, "Timestamp fuera de la ventana permitida")
# 2. Recalculamos la firma sobre `${timestamp}.${rawBody}`.
cadena_firmada = f"{timestamp}.".encode("utf-8") + raw_body
esperada = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode("utf-8"),
cadena_firmada,
hashlib.sha256,
).hexdigest()
# 3. Comparación en tiempo constante con compare_digest.
if not hmac.compare_digest(signature, esperada):
abort(401, "Firma inválida")
# 4. Webhook de confianza: parsea y procesa de forma idempotente.
payload = request.get_json()
delivery_id = request.headers.get("X-Utilia-Delivery-Id")
# ... descarta duplicados por delivery_id y procesa payload["event"] ...
return "ok", 200La cabecera X-Utilia-Signature lleva siempre el prefijo sha256=. Los ejemplos comparan la cabecera completa con el valor esperado, al que también anteponen sha256=. Si prefieres comparar solo la parte hexadecimal, quita el prefijo de los dos lados.
Forma del cuerpo firmado
La cadena que se firma es el cuerpo JSON completo precedido por la marca de tiempo. El cuerpo tiene esta forma:
{
"event": "TICKET_CREATED",
"timestamp": "2026-06-13T10:00:00.000Z",
"app": {
"id": "app_xxxxxxxx",
"slug": "mi-integracion"
},
"data": {
"id": "tck_xxxxxxxx"
},
"meta": {
"schemaVersion": "2026-05"
}
}No confundas el campo timestamp del cuerpo (una fecha ISO 8601, solo informativa) con la cabecera X-Utilia-Timestamp (milisegundos Unix). La firma y el control de antigüedad usan la cabecera.
Errores comunes
- Firmar el JSON vuelto a convertir en texto en lugar del cuerpo crudo. Es el fallo más frecuente: el contenido es el mismo, pero cambian el orden de las claves o los espacios y la firma no coincide.
- Firmar solo el
rawBody, sin la marca de tiempo. La firma se calcula sobre${timestamp}.${rawBody}; sin la marca de tiempo delante, nunca coincide. - Comparar con
==en lugar de en tiempo constante. El tiempo de respuesta da pistas que un atacante puede aprovechar. Usacrypto.timingSafeEqual(Node.js) ohmac.compare_digest(Python). - No comprobar la antigüedad de la marca de tiempo. Sin ese control, alguien puede reenviarte un aviso legítimo capturado tiempo atrás.
- Clave de firma antigua tras regenerarla. Si regeneras la clave de firma de la aplicación, actualízala también en tu servidor.
- Esperar avisos sin firma. Si la clave de firma de la aplicación no se puede recuperar, UTILIA OS no envía el aviso sin firmar: lo marca como fallo definitivo con el motivo. Lo que verás es que no llega nada. Regenera la clave de firma desde la ficha de la aplicación.
- Dar por buena la integración solo con la prueba. El botón Probar firma igual que una entrega real (misma cadena, misma clave, mismas cabeceras), así que si pasa, la firma cuadra; pero la prueba no queda en el historial ni se reintenta. Ver Entregas y reintentos de los avisos.
Siguiente paso
Con la firma comprobada, toca procesar cada tipo de evento y entender cómo se reintentan las entregas.
- Contenido y catálogo de eventosLa forma del cuerpo de cada evento, con ejemplos.
- Entregas y reintentosCuántas veces se reintenta un aviso, cuándo se da por perdido y cómo reintentarlo a mano.
- Avisos a tu servidor: visión generalConfiguración, cabeceras y eventos disponibles.
- Panel de aplicaciones externasDónde se configura la dirección de avisos y se regenera la clave de firma.