# Verificar la firma

> Comprueba que cada webhook viene de MINDO y que nadie lo modificó.

Tu URL de webhook es pública, así que cualquiera podría mandarle un `POST`. Para que sepas que el aviso es de MINDO, cada uno viene firmado con el **secreto de firma** (`whsec_…`) que te mostramos al crear el webhook.

## Cómo es la firma

El encabezado `X-Mindo-Signature` tiene dos partes separadas por coma:

```
X-Mindo-Signature: t=1790271068,v1=5f2b0c6e8a1d4b7e9c3f6a2d8b5e1c4f7a0d3b6e9c2f5a8d1b4e7c0f3a6d9b2e
```

- `t`: el momento de la firma, en segundos Unix.
- `v1`: el HMAC-SHA256, en hexadecimal, de este texto: el valor de `t`, un punto y el **cuerpo crudo** del pedido.

```
v1 = hex( HMAC_SHA256( secreto, t + "." + cuerpo ) )
```

## Cómo verificarla

<Steps>
  <Step title="Lee el cuerpo crudo">
    Usa los bytes tal como llegaron, **antes** de convertirlos a JSON. Si parseas y vuelves a serializar, cambian los espacios o el orden y la firma no coincide.
  </Step>
  <Step title="Separa t y v1">
    Divide el encabezado por `,` y cada parte por el primer `=`.
  </Step>
  <Step title="Calcula tu propia firma">
    HMAC-SHA256 con tu secreto sobre `t + "." + cuerpo`, en hexadecimal.
  </Step>
  <Step title="Compara en tiempo constante">
    Usa `timingSafeEqual`, `hmac.compare_digest` o `hash_equals`, no `==`. Así nadie puede adivinar la firma midiendo cuánto tarda tu servidor.
  </Step>
  <Step title="Revisa que sea reciente">
    Rechaza firmas con `t` de hace más de 5 minutos. Así, si alguien captura un aviso, no puede reenviártelo más tarde.
  </Step>
</Steps>

## Ejemplos

<CodeGroup>

```javascript Node
import crypto from "node:crypto"

const TOLERANCIA_SEGUNDOS = 5 * 60

export function verificarFirma(cuerpoCrudo, encabezado, secreto) {
  const partes = Object.fromEntries(
    encabezado.split(",").map((p) => {
      const i = p.indexOf("=")
      return [p.slice(0, i), p.slice(i + 1)]
    })
  )
  const t = Number(partes.t)
  if (!t || !partes.v1) return false
  if (Math.abs(Date.now() / 1000 - t) > TOLERANCIA_SEGUNDOS) return false

  const esperada = crypto.createHmac("sha256", secreto).update(`${partes.t}.${cuerpoCrudo}`).digest("hex")
  const a = Buffer.from(partes.v1, "hex")
  const b = Buffer.from(esperada, "hex")
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

// Con Express: app.post("/webhooks/mindo", express.raw({ type: "application/json" }), …)
// y verificarFirma(req.body.toString("utf8"), req.get("x-mindo-signature"), secreto)
```

```python Python
import hashlib
import hmac
import time

TOLERANCIA_SEGUNDOS = 5 * 60

def verificar_firma(cuerpo_crudo: bytes, encabezado: str, secreto: str) -> bool:
    partes = dict(p.split("=", 1) for p in encabezado.split(",") if "=" in p)
    t, v1 = partes.get("t"), partes.get("v1")
    if not t or not v1:
        return False
    if abs(time.time() - int(t)) > TOLERANCIA_SEGUNDOS:
        return False
    esperada = hmac.new(secreto.encode(), f"{t}.".encode() + cuerpo_crudo, hashlib.sha256).hexdigest()
    return hmac.compare_digest(esperada, v1)

# Con Flask: verificar_firma(request.get_data(), request.headers["X-Mindo-Signature"], secreto)
# Con Django: verificar_firma(request.body, request.headers["X-Mindo-Signature"], secreto)
```

```php PHP
<?php
const TOLERANCIA_SEGUNDOS = 300;

function verificar_firma(string $cuerpoCrudo, string $encabezado, string $secreto): bool {
  $partes = [];
  foreach (explode(",", $encabezado) as $p) {
    [$k, $v] = array_pad(explode("=", $p, 2), 2, "");
    $partes[$k] = $v;
  }
  $t = $partes["t"] ?? "";
  $v1 = $partes["v1"] ?? "";
  if ($t === "" || $v1 === "") return false;
  if (abs(time() - (int) $t) > TOLERANCIA_SEGUNDOS) return false;

  $esperada = hash_hmac("sha256", $t . "." . $cuerpoCrudo, $secreto);
  return hash_equals($esperada, $v1);
}

// $ok = verificar_firma(file_get_contents("php://input"), $_SERVER["HTTP_X_MINDO_SIGNATURE"] ?? "", getenv("MINDO_WEBHOOK_SECRET"));
```

</CodeGroup>

## Prueba tu código

Con estos datos, tu función tiene que devolver `true` (desactiva por un momento el control de los 5 minutos, porque la fecha es fija):

| | |
| --- | --- |
| Secreto | `whsec_prueba_mindo_123` |
| Encabezado | `t=1790271068,v1=b8aedf45f0f2732e4c2f5ea1ecea74da93db08b495610a363107dbc098ddfd3a` |
| Cuerpo | `{"id":"d1","event":"message.received","test":true}` |

También puedes calcular la firma desde la terminal:

```bash
printf '%s' '1790271068.{"id":"d1","event":"message.received","test":true}' \
  | openssl dgst -sha256 -hmac 'whsec_prueba_mindo_123' | awk '{print $NF}'
# b8aedf45f0f2732e4c2f5ea1ecea74da93db08b495610a363107dbc098ddfd3a
```

Y la forma más fácil de probar con un aviso real es el botón **Probar** de **Ajustes › API y webhooks**.

## Si la firma no coincide

<AccordionGroup>
  <Accordion title="Estás usando el cuerpo ya parseado">
    Es la causa más común. Frameworks como Express (`express.json()`), NestJS o Laravel parsean el JSON antes de que lo veas. Configura la ruta del webhook para recibir el cuerpo crudo.
  </Accordion>
  <Accordion title="El secreto es de otro webhook">
    Cada webhook tiene su propio secreto. Si tienes varios, usa el que corresponde a esa URL.
  </Accordion>
  <Accordion title="Perdiste el secreto">
    No se puede volver a ver. Crea un webhook nuevo con la misma URL y los mismos eventos, actualiza el secreto en tu servidor y elimina el anterior.
  </Accordion>
  <Accordion title="Falla solo el control de tiempo">
    Revisa que el reloj de tu servidor esté sincronizado (NTP). Un reloj atrasado unos minutos hace que todas las firmas parezcan viejas.
  </Accordion>
</AccordionGroup>
