# 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.

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**

1. **Cuerpo en texto** (rawBody): El JSON del aviso, tal como viaja en la petición.
2. **Marca de tiempo** (X-Utilia-Timestamp): Instante del intento en milisegundos Unix, por ejemplo `1749816000000`.
3. **Cadena a firmar** (timestamp.rawBody): La marca de tiempo, un punto literal y el cuerpo: `timestamp.rawBody`.
4. **HMAC-SHA256** (X-Utilia-Signature): Con la clave de firma de la aplicación; en hexadecimal y con el prefijo `sha256=`.

_Tu servidor repite el mismo cálculo con su copia de la clave de firma y compara el resultado._

El algoritmo equivale exactamente a esta expresión:

```text
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](#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.

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `X-Utilia-Signature` | `string` | Sí | Una sola firma HMAC-SHA256 sobre `${timestamp}.${rawBody}`, con el prefijo `sha256=`. 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-Signatures` | `string` | Sí | 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-Timestamp` | `string` | Sí | 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-Event` | `string` | Sí | Nombre del evento entregado (por ejemplo, TICKET_CREATED, BUDGET_SIGNED). |
| `X-Utilia-Delivery-Id` | `string` | Sí | Identificador de la entrega, igual en todos sus reintentos. Úsalo para no procesar dos veces el mismo aviso. |
| `X-Utilia-Schema-Version` | `string` | Sí | Versión de la forma del contenido. Permite detectar un cambio de estructura sin leer el cuerpo. |

> **Importante: 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**

1. **Lee el cuerpo crudo**: El `rawBody` tal como llega, antes de que tu framework interprete el JSON.
2. **¿Es reciente?** (X-Utilia-Timestamp): A menos de cinco minutos de tu reloj, hacia atrás o hacia delante. Si no: responde 401 y no lo proceses.
3. **¿La firma coincide?** (X-Utilia-Signature): Recalcula el HMAC-SHA256 y compara en tiempo constante, nunca con `==`. Si no: responde 401 y no lo proceses.
4. **¿Es una entrega nueva?** (X-Utilia-Delivery-Id): Los reintentos repiten el mismo identificador. Si no: responde 2xx sin volver a procesarla.

_Responder 401 cierra la entrega sin reintentos: corrige la comprobación antes de reintentarla a mano._

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)

```javascript
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)

```python
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", 200
```

La 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.

### Node.js

```javascript
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');
// }
```

### Python

```python
import hashlib
import hmac
import os

# Durante el cambio, las dos claves; después, solo la nueva.
CLAVES = [
    clave
    for clave in (
        os.environ.get("UTILIA_WEBHOOK_SECRET"),
        os.environ.get("UTILIA_WEBHOOK_SECRET_ANTERIOR"),
    )
    if clave
]


def firma_valida(raw_body: bytes, timestamp: str, cabecera_lista: str) -> bool:
    cadena_firmada = f"{timestamp}.".encode("utf-8") + raw_body
    recibidas = [f.strip() for f in (cabecera_lista or "").split(",") if f.strip()]
    for clave in CLAVES:
        esperada = "sha256=" + hmac.new(
            clave.encode("utf-8"), cadena_firmada, hashlib.sha256
        ).hexdigest()
        if any(
            hmac.compare_digest(firma.encode("utf-8"), esperada.encode("utf-8"))
            for firma in recibidas
        ):
            return True
    return False


# En la ruta del ejemplo de Flask, en lugar de comparar X-Utilia-Signature:
# if not firma_valida(raw_body, timestamp, request.headers.get("X-Utilia-Webhook-Signatures", "")):
#     abort(401, "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](https://os.utilia.ai/docs/developers/sdk/versions)). A la anterior le puedes poner fecha de caducidad (`validUntil`), y pasada esa fecha deja de aceptarse aunque siga en tu configuración:

### JavaScript

```typescript
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' },
]);
```

### Python

```python
import os

from utilia_sdk import WebhookSecret

evento = sdk.webhooks.parse(
    request.get_data(as_text=True),  # el cuerpo crudo, en Flask
    request.headers,
    [
        os.environ["UTILIA_WEBHOOK_SECRET"],
        WebhookSecret(os.environ["UTILIA_WEBHOOK_SECRET_ANTERIOR"], valid_until="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:

```json
{
  "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. Usa `crypto.timingSafeEqual` (Node.js) o `hmac.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](https://os.utilia.ai/docs/developers/webhooks/delivery-and-retries).
- **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 eventos](https://os.utilia.ai/docs/developers/webhooks/events): La forma del cuerpo de cada evento, con ejemplos.
- [Entregas y reintentos](https://os.utilia.ai/docs/developers/webhooks/delivery-and-retries): Cuántas veces se reintenta un aviso, cuándo se da por perdido y cómo reintentarlo a mano.
- [Avisos a tu servidor: visión general](https://os.utilia.ai/docs/developers/webhooks/overview): Configuración, cabeceras y eventos disponibles.
- [Panel de aplicaciones externas](https://os.utilia.ai/docs/developers/api/apps-panel): Dónde se configura la dirección de avisos y se renueva la clave de firma.
