Firma HMAC de los avisos
Cómo firma UTILIA OS cada aviso con HMAC-SHA256 y cómo verificar la firma en Node.js y Python, también al renovar la clave.
Actualizado el . 8 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 cada intento de entrega, 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 del intento 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 renuevas desde la ficha de la aplicación, eliges cuánto tiempo sigue valiendo la anterior, y mientras tanto cada aviso lleva las dos firmas: lo explicamos en Cuando renuevas la clave de firma.
Cabeceras que acompañan cada entrega
UTILIA OS añade estas cabeceras a cada aviso. Para comprobar la firma importan las tres primeras.
- X-Utilia-Signaturestringobligatorio
- Una sola firma HMAC-SHA256 sobre
${timestamp}.${rawBody}, con el prefijosha256=. Va con la clave vigente, salvo durante una renovación, en la que sigue con la anterior hasta que tu servidor usa la nueva. Si la clave no se puede recuperar, la entrega no sale. - X-Utilia-Webhook-Signaturesstringobligatorio
- Lista de firmas separadas por comas, cada una con su prefijo
sha256=, la de la clave vigente primero. Fuera de una renovación lleva una sola, igual que X-Utilia-Signature; durante el periodo de transición, también la de la clave anterior. - X-Utilia-Timestampstringobligatorio
- Instante en que sale ese intento, en milisegundos Unix. Se genera de nuevo en cada reintento, así que un reintento legítimo pasa la ventana de cinco minutos; el timestamp del cuerpo, en cambio, es el de cuando se generó el aviso. 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, en bytes: con
# dos str, una cabecera con caracteres no ASCII lanzaría TypeError.
if not hmac.compare_digest(signature.encode("utf-8"), esperada.encode("utf-8")):
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.
Cuando renuevas la clave de firma
Al renovar la clave de firma desde la ficha de la aplicación eliges cuánto tiempo sigue valiendo la actual: 1 día, 7 días (lo recomendado), 30 días o «Deja de valer en el acto». Mientras la anterior sigue valiendo, UTILIA OS firma cada aviso con las dos claves:
| Momento | X-Utilia-Signature | X-Utilia-Webhook-Signatures |
|---|---|---|
| Sin renovación en curso | Clave vigente | Clave vigente |
| Recién renovada | Clave anterior | Nueva, anterior |
| Tu servidor ya usa la nueva | Clave nueva | Nueva, anterior |
| Fin del periodo de transición | Clave nueva | Nueva |
La firma de X-Utilia-Signature pasa a la clave nueva en cuanto se cumple una de estas tres cosas: alguien pulsa Mi servidor ya usa la nueva en la ficha, termina el periodo de transición o tu servidor acepta la nueva por su cuenta. Esto último ocurre así: si durante la transición tu servidor rechaza un aviso con 400, 401 o 403, UTILIA OS lo reenvía una vez al momento, con el mismo cuerpo y la misma marca de tiempo, firmado con la otra clave en X-Utilia-Signature; si lo acepta, desde entonces firma con la nueva. Así, el código de los ejemplos de arriba, que solo mira X-Utilia-Signature, sigue funcionando tanto si tu servidor tiene la clave anterior como si ya tiene la nueva.
Si quieres cambiar de clave sin depender de ese reenvío, comprueba la lista: acepta el aviso si alguna de sus firmas coincide con alguna de las claves que tenga tu servidor. Así puedes tener las dos claves a la vez mientras despliegas la nueva.
const crypto = require('crypto');
// Durante el cambio, las dos claves; después, solo la nueva.
const CLAVES = [process.env.UTILIA_WEBHOOK_SECRET, process.env.UTILIA_WEBHOOK_SECRET_ANTERIOR]
.filter(Boolean);
function firmaValida(rawBody, timestamp, cabeceraLista) {
const cadenaFirmada = `${timestamp}.${rawBody.toString('utf8')}`;
const recibidas = (cabeceraLista || '').split(',').map((f) => f.trim()).filter(Boolean);
return CLAVES.some((clave) => {
const esperada = Buffer.from(
'sha256=' + crypto.createHmac('sha256', clave).update(cadenaFirmada).digest('hex'),
);
return recibidas.some((firma) => {
const recibida = Buffer.from(firma);
return recibida.length === esperada.length && crypto.timingSafeEqual(recibida, esperada);
});
});
}
// En la ruta del ejemplo de Express, en lugar de comparar X-Utilia-Signature:
// if (!firmaValida(req.body, timestamp, req.header('X-Utilia-Webhook-Signatures'))) {
// return res.status(401).send('Firma inválida');
// }La comprobación de la marca de tiempo no cambia: la lista se firma sobre la misma cadena ${timestamp}.${rawBody}.
Si usas los SDK oficiales, desde la 4.2.0 sdk.webhooks.verify y sdk.webhooks.parse leen las dos cabeceras y aceptan una clave o varias (qué versión está publicada lo dice Versiones y compatibilidad). A la anterior le puedes poner fecha de caducidad (validUntil), y pasada esa fecha deja de aceptarse aunque siga en tu configuración:
const evento = sdk.webhooks.parse(req.body.toString('utf8'), req.headers, [
process.env.UTILIA_WEBHOOK_SECRET!,
{ secret: process.env.UTILIA_WEBHOOK_SECRET_ANTERIOR!, validUntil: '2026-10-19T09:00:00Z' },
]);Si todas las claves que pasas han caducado, el SDK lanza un error con errorCode WEBHOOK_SECRET_EXPIRED; si validUntil no es una fecha, WEBHOOK_SECRET_INVALID.
Cuando tu servidor ya tenga la clave nueva, quita la anterior de su configuración. Retirarla en la ficha (Retirar la anterior) hace que UTILIA OS deje de firmar con ella, pero mientras tu servidor la siga aceptando, quien la tenga podría fabricar avisos que tu servidor tomaría por buenos.
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 renovarla. Durante el periodo de transición cada aviso lleva también la firma de la anterior; cuando termine, actualízala en tu servidor y quita la antigua.
- 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. Renueva la clave de firma desde la ficha de la aplicación.
- Dar por buena la integración solo con la prueba. Probar la conexión firma igual que una entrega real (misma cadena, mismas claves, mismas cabeceras), así que si pasa, la firma cuadra; pero solo demuestra algo si tu servidor comprueba la firma, y la prueba no queda en el historial ni se reintenta. Ver Entregas y reintentos de los avisos.
- No saber si tu servidor comprueba la firma. Durante una renovación, Probar la clave nueva envía primero un aviso de prueba con una firma falsa y después otro firmado solo con la clave vigente. Si tu servidor acepta el de la firma falsa, el resultado lo dice: tu servidor no está comprobando la firma.
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 renueva la clave de firma.