---
title: "Verifica las firmas de los webhooks"
description: "Cada solicitud de webhook y herramienta que envía ThunderPhone se firma con una marca de tiempo. Verifícala con la receta o el asistente del SDK de esta página y rechaza las solicitudes repetidas de más de cinco minutos."
---

Cada solicitud que enviamos a tu servidor (entregas de webhooks, el webhook heredado de una sola URL y llamadas a endpoints de herramientas) incluye dos encabezados de firma. Ambos usan HMAC-SHA256 con el mismo secreto.

| Encabezado | Firma | Protección contra repeticiones |
|--------|-------|-------------------|
| `X-ThunderPhone-Signature-V2` (recomendado) | Marca de tiempo, método, ruta, consulta y cuerpo | Rechaza solicitudes con más de 5 minutos de antigüedad |
| `X-ThunderPhone-Signature` | Solo el cuerpo | Ninguna: una solicitud capturada se verifica para siempre |

Verifica `X-ThunderPhone-Signature-V2` en las nuevas integraciones y migra las existentes a este. `X-ThunderPhone-Signature` se sigue enviando en cada solicitud, sin cambios, por lo que los verificadores basados en él seguirán funcionando. Anunciaremos una fecha antes de dejar de enviarlo.

## Firma V2

```http
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
```

- `t` es la hora Unix, en segundos, en la que enviamos la solicitud. Una entrega
  reintentada se firma de nuevo cuando se envía, con su propio `t`.
- `v1` es el HMAC-SHA256 hexadecimal de la cadena firmada que aparece a continuación, con tu
  secreto como clave.

El valor es una lista de pares `key=value` separados por comas. Ignora las claves que
no reconozcas y acepta la solicitud si algún `v1` coincide: podríamos agregar
una segunda firma durante un cambio futuro.

### La cadena firmada

Cuatro campos, cada uno seguido por un salto de línea (`\n`, byte `0x0A`) excepto el
último:

```text
<t>\n<METHOD>\n<path and query>\n<body>
```

| Campo | Valor |
|-------|-------|
| `t` | El valor de `t` del encabezado |
| `METHOD` | El método HTTP en mayúsculas: `POST`, `GET`, `PUT`, `PATCH` o `DELETE` |
| path and query | El destino de la solicitud de la línea de solicitud HTTP: la ruta y luego `?` y la cadena de consulta cuando exista una. Sin esquema, host ni fragmento. Úsalo exactamente como llegó, sin decodificarlo ni reordenar parámetros |
| `body` | El cuerpo sin procesar de la solicitud. Las llamadas a herramientas `GET` y `DELETE` no tienen cuerpo, por lo que su cadena firmada termina con el salto de línea después de la ruta |

Para verificar:

1. Antes de analizar cualquier dato, lee el encabezado, el método, la ruta y la
   consulta sin procesar, y el cuerpo sin procesar.
2. Obtén `t` y cada `v1` del encabezado.
3. Rechaza la solicitud si `t` está más de **300 segundos** antes o después de
   tu reloj.
4. Calcula el HMAC-SHA256 hexadecimal de la cadena firmada con tu secreto.
5. Compáralo con `v1` en tiempo constante. Responde `401` si no coincide ninguno.

### Ejemplo completo

Secreto `whsec_example_4c1f2a9b`. Enviamos esta solicitud a las
`t = 1767225600` (2026-01-01 00:00:00 UTC):

```http
POST /tools/lookup?region=us HTTP/1.1
Host: api.example.com
Content-Type: application/json
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e

{"customer_id":"cust_123"}
```

La cadena firmada son estas cuatro líneas, sin salto de línea después del cuerpo:

```text
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}
```

Su HMAC-SHA256 es
`03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e`.
Comprueba tu implementación con este valor o reprodúcelo con `openssl`:

```bash
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
  | openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'
```

Una llamada a herramienta `GET` a `/tools/lookup?customer_id=cust_123` en el mismo
segundo tiene un cuerpo vacío. Su cadena firmada es
`1767225600\nGET\n/tools/lookup?customer_id=cust_123\n` y su firma
es `3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280`.

### Con los SDK

Los [SDK de Python y TypeScript](/api-reference/sdks) incluyen un asistente
(`thunderphone` 0.2.0 o posterior, `@thunderphone/sdk` 0.2.0 o posterior). Este
comprueba la ventana de 5 minutos, compara en tiempo constante y acepta una lista
de secretos para que puedas rotarlos sin tiempo de inactividad.

<CodeGroup>
```python Python
from thunderphone import verify_signature

ok = verify_signature(
    header,            # the X-ThunderPhone-Signature-V2 value
    secret=SECRET,     # or [NEW_SECRET, OLD_SECRET] while rotating
    method=method,     # "POST"
    path=path,         # "/tools/lookup?region=us", exactly as received
    body=raw_body,     # bytes, before any JSON parsing
    tolerance=300,     # optional; seconds
)
```

```javascript Node.js
import { verifySignature } from "@thunderphone/sdk";

const ok = verifySignature({
  header,               // the X-ThunderPhone-Signature-V2 value
  secret: SECRET,       // or [NEW_SECRET, OLD_SECRET] while rotating
  method,               // "POST"
  path,                 // "/tools/lookup?region=us", exactly as received
  body: rawBody,        // Buffer or string, before any JSON parsing
  toleranceSeconds: 300, // optional
});
```
</CodeGroup>

### Sin un SDK

<CodeGroup>
```python Python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify_v2(header: str, secret: str, method: str, path: str, body: bytes) -> bool:
    """path is the path plus ?query exactly as received; body is the raw body."""
    timestamp, signatures = None, []
    for item in (header or "").split(","):
        key, _, value = item.strip().partition("=")
        if key == "t" and value.isdigit() and len(value) <= 15:
            timestamp = int(value)
        elif key == "v1":
            signatures.append(value)
    if timestamp is None or abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False
    signed = f"{timestamp}\n{method.upper()}\n{path}\n".encode() + body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected.encode(), s.encode()) for s in signatures)
```

```javascript Node.js
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300;

// path is the path plus ?query exactly as received; body is the raw Buffer.
export function verifyV2(header, secret, method, path, body) {
  let timestamp;
  const signatures = [];
  for (const item of (header || "").split(",")) {
    const [key, value = ""] = item.trim().split("=", 2);
    if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
    else if (key === "v1") signatures.push(value);
  }
  if (timestamp === undefined) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
  const expected = Buffer.from(
    crypto
      .createHmac("sha256", secret)
      .update(`${timestamp}\n${method.toUpperCase()}\n${path}\n`)
      .update(body)
      .digest("hex"),
  );
  return signatures.some((sig) => {
    const candidate = Buffer.from(sig);
    return (
      candidate.length === expected.length &&
      crypto.timingSafeEqual(candidate, expected)
    );
  });
}
```
</CodeGroup>

### Integración con frameworks

Cada ejemplo lee la ruta y la consulta sin procesar, así como el cuerpo sin procesar, y luego llama
al asistente del SDK.

<CodeGroup>
```python FastAPI
import json

from fastapi import FastAPI, HTTPException, Request
from thunderphone import verify_signature

app = FastAPI()


def raw_path_and_query(request: Request) -> str:
    path = (request.scope.get("raw_path") or request.url.path.encode()).decode("latin-1")
    query = request.scope.get("query_string", b"").decode("latin-1")
    return f"{path}?{query}" if query else path


@app.post("/thunderphone-webhook")
async def hook(request: Request):
    body = await request.body()  # raw bytes, NOT request.json()
    if not verify_signature(
        request.headers.get("X-ThunderPhone-Signature-V2"),
        secret=SECRET,
        method=request.method,
        path=raw_path_and_query(request),
        body=body,
    ):
        raise HTTPException(status_code=401)
    event = json.loads(body)
    # … dispatch on event["type"] …
    return {"ok": True}
```

```javascript Express
import express from "express";
import { verifySignature } from "@thunderphone/sdk";

const app = express();

app.post(
  "/thunderphone-webhook",
  // IMPORTANT: parse as raw; do NOT use express.json() here.
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ok = verifySignature({
      header: req.header("X-ThunderPhone-Signature-V2"),
      secret: process.env.WEBHOOK_SECRET,
      method: req.method,
      path: req.originalUrl, // path and query as received
      body: req.body,
    });
    if (!ok) return res.sendStatus(401);
    const event = JSON.parse(req.body.toString("utf8"));
    // … dispatch on event.type …
    res.sendStatus(204);
  },
);
```

```python Django
import json

from django.http import HttpResponseForbidden, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from thunderphone import verify_signature


@csrf_exempt
@require_POST
def hook(request):
    if not verify_signature(
        request.headers.get("X-ThunderPhone-Signature-V2"),
        secret=SECRET,
        method=request.method,
        path=request.get_full_path(),
        body=request.body,  # raw bytes
    ):
        return HttpResponseForbidden("invalid signature")
    event = json.loads(request.body)
    # … dispatch on event["type"] …
    return JsonResponse({"ok": True})
```
</CodeGroup>

`req.originalUrl` (Express) y `raw_path` y `query_string` de ASGI
(FastAPI) corresponden exactamente al destino tal como se recibió. `get_full_path()`
de Django y `request.path` de Flask decodifican primero la ruta, lo que
produce la misma cadena para rutas comunes. Si la ruta de tu endpoint contiene
caracteres codificados en porcentaje o reservados, como `%2F`, `;` o `=`, lee en su lugar el
destino sin procesar que proporciona tu servidor (por ejemplo, `request.META["RAW_URI"]` en
Gunicorn).

## ¿Qué secreto?

El mismo secreto firma ambos encabezados.

| Origen | Secreto |
|--------|--------|
| [Endpoint de webhook](/es/webhooks/endpoints) (`/v1/developer/webhook-endpoints`) | `secret` por endpoint (48 caracteres hexadecimales) que se devuelve una sola vez al crearlo |
| [Webhook heredado de URL única](/api-reference/organizations#legacy-single-url-webhook) | `secret` por organización devuelto en `GET /v1/webhook` |
| [Invocación de endpoint de herramienta](/es/tools/overview) (llamada directa a tu `endpoint.url`) | El **secreto de webhook a nivel de organización** (el mismo que el del webhook heredado de URL única) — no un secreto por endpoint |

Guarda el secreto en tu gestor de secretos o variable de entorno — nunca lo incluyas en un commit.

## Verificación de llamadas a herramientas

Cuando el agente invoca directamente una de tus
[herramientas de función](/es/tools/overview) (la herramienta tiene un
`endpoint`), la solicitud incluye estos encabezados de ThunderPhone junto con
tus `endpoint.headers` configurados:

- `X-ThunderPhone-Call-ID` — el ID numérico de la llamada en curso.
- `X-ThunderPhone-Signature-V2` — la firma con marca de tiempo anterior, asociada
  con tu **secreto de webhook a nivel de organización**.
- `X-ThunderPhone-Signature` — la firma heredada solo del cuerpo, con la misma clave.

Hay dos diferencias con respecto a los webhooks:

1. **Las herramientas `GET` / `DELETE` no tienen cuerpo.** Los argumentos se envían como
   parámetros de consulta. La firma V2 los cubre mediante la ruta y la consulta,
   y el campo del cuerpo está vacío. Verifica con la cadena de consulta sin procesar: analizar
   los parámetros y crear una nueva cadena de consulta puede cambiar su orden
   o codificación. El encabezado heredado firma la cadena de bytes vacía.
2. **Las organizaciones sin un webhook heredado configurado no tienen un secreto de organización.** En
   ese caso, las llamadas a herramientas solo incluyen `X-ThunderPhone-Call-ID` y ningún
   encabezado de firma. Configura el webhook heredado
   (`PUT /v1/webhook`) para obtener un secreto de firma, o autentica las llamadas a
   herramientas con tu propio encabezado mediante `endpoint.headers`.

```python
@app.api_route("/tools/search-appointments", methods=["GET", "POST"])
async def tool(request: Request):
    body = await request.body()  # b"" for GET/DELETE tools
    if not verify_signature(
        request.headers.get("X-ThunderPhone-Signature-V2"),
        secret=ORG_WEBHOOK_SECRET,
        method=request.method,
        path=raw_path_and_query(request),
        body=body,
    ):
        raise HTTPException(status_code=401)
    call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
    args = json.loads(body) if body else dict(request.query_params)
    ...
```

En Express, un analizador de cuerpo deja `req.body` como un objeto vacío en una solicitud `GET`,
así que pasa `Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0)`.

La distribución de herramientas en **modo webhook** (herramientas sin un `endpoint`, enviadas
a tu webhook de organización como `telephony.tool` / `web.tool`) es un webhook firmado
normal: aplica la receta estándar anterior. Consulta
[Herramientas de función](/es/tools/overview) para ver ambas formas de solicitud.

## Firma heredada: `X-ThunderPhone-Signature`

El encabezado original es el HMAC-SHA256 hexadecimal solo del cuerpo sin procesar, con
el mismo secreto. No incluye una marca de tiempo, por lo que cualquiera que capture una
solicitud firmada puede reproducirla. Se sigue enviando en cada solicitud mientras las
integraciones migran a V2.

1. Lee el cuerpo de la solicitud **sin procesar**: los bytes exactos que te enviamos.
2. Calcula `hmac_sha256(secret, body).hexdigest()`.
3. Compara en **tiempo constante** con `X-ThunderPhone-Signature`.

Firmamos exactamente los bytes que transmitimos. Esos bytes también son la
**serialización JSON canónica** de la carga útil: claves ordenadas
alfabéticamente, separadores compactos (`,` y `:` sin espacios), UTF-8.
Si tu plataforma solo expone JSON analizado, vuelve a serializarlo de forma canónica y
calcula el HMAC de eso:

```python
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
```

Prefiere el cuerpo sin procesar: es un paso menos y es inmune a las peculiaridades de
conversión de ida y vuelta de números JSON en algunos lenguajes. Lo mismo aplica al campo
del cuerpo de V2.

<CodeGroup>
```python Python
import hashlib
import hmac


def verify(body: bytes, signature: str, secret: str) -> bool:
    """Constant-time HMAC-SHA256 verification."""
    expected = hmac.new(
        secret.encode("utf-8"),
        body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature or "")
```

```javascript Node.js
import crypto from "node:crypto";

export function verify(body, signature, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("hex");
  if (!signature || expected.length !== signature.length) return false;
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature),
  );
}
```

```go Go
package webhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
)

func Verify(body []byte, signature, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(signature))
}
```

```ruby Ruby
require "openssl"

def verify(body, signature, secret)
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
  Rack::Utils.secure_compare(expected, signature.to_s)
end
```
</CodeGroup>

## Errores comunes

<AccordionGroup>
  <Accordion title="Desfase del reloj">
    La ventana de 5 minutos se mide según el reloj de tu servidor. Mantenlo
    sincronizado con NTP; un reloj desfasado por más de 5 minutos rechaza cada
    solicitud.
  </Accordion>

  <Accordion title="Un proxy reescribe la ruta">
    La ruta firmada es la que solicitamos: la ruta de la URL que
    configuraste en ThunderPhone. Si un proxy inverso o una puerta de enlace de API elimina
    o agrega un prefijo antes de que tu aplicación vea la solicitud, reconstruye
    la ruta que enviamos (por ejemplo, vuelve a agregar el prefijo) antes de verificar.
  </Accordion>

  <Accordion title="Volver a serializar con formato predeterminado">
    Analizar el cuerpo y volver a serializarlo con la configuración
    predeterminada de tu biblioteca JSON (espacios después de `,` / `:`, claves en orden de inserción) produce
    bytes diferentes e invalida el HMAC. Verifica el cuerpo sin procesar, o si
    debes volver a serializarlo, iguala exactamente nuestra forma canónica: claves
    ordenadas, separadores compactos, UTF-8.
  </Accordion>

  <Accordion title="El framework analiza JSON automáticamente">
    El middleware `express.json()` de Express consume el flujo del cuerpo
    y pierdes los bytes sin procesar. Usa `express.raw()` específicamente en la ruta
    del webhook o almacena el cuerpo sin procesar en un middleware previo.
    Lo mismo ocurre con NestJS / Koa: consulta su documentación sobre "cuerpo sin procesar".
  </Accordion>

  <Accordion title="Comparación no segura frente a ataques de temporización">
    `expected === signature` en JS o `expected == signature` en
    Python son comparaciones cuyo tiempo de ejecución varía. Usa `crypto.timingSafeEqual`
    o `hmac.compare_digest`, respectivamente. La diferencia de rendimiento
    es nula.
  </Accordion>

  <Accordion title="Secreto incorrecto para endpoints de herramientas">
    Las llamadas directas a endpoints de herramientas se firman con el **secreto de webhook
    a nivel de organización** (`GET /v1/webhook`) — no con ningún secreto por endpoint
    de `/v1/developer/webhook-endpoints`. Reutiliza el mismo auxiliar,
    pero asegúrate de proporcionarle el secreto de la organización en las rutas de herramientas.
  </Accordion>

  <Accordion title="Cadenas de consulta en herramientas GET/DELETE">
    V2 firma la cadena de consulta exactamente como se envía. El encabezado heredado firma
    el cuerpo vacío y nunca la cadena de consulta, por lo que hashear la URL para
    el encabezado heredado nunca coincidirá.
  </Accordion>

  <Accordion title="Duplicados dentro de la ventana">
    La marca de tiempo detiene repeticiones con más de 5 minutos de antigüedad, no una segunda copia
    dentro de esa ventana. Las entregas de endpoints se realizan al menos una vez, por lo que
    también puede llegar un reintento del mismo evento; elimina duplicados mediante el
    `event_id` del sobre.
  </Accordion>

  <Accordion title="No devolver 401 cuando no hay coincidencia">
    Devolver 200 cuando falla la verificación convierte el controlador en un objetivo
    de repetición. Responde siempre con un código distinto de 2xx si la verificación falla.
  </Accordion>
</AccordionGroup>

---

## Próximos pasos

<CardGroup cols={2}>
  <Card title="Descripción general de webhooks" icon="bolt" href="/es/webhooks/overview">
    Semántica de entrega, reintentos e IP de origen.
  </Card>
  <Card title="Endpoints de webhook" icon="plug" href="/es/webhooks/endpoints">
    Administra varias URL y rota secretos.
  </Card>
  <Card title="Herramientas de función" icon="screwdriver-wrench" href="/es/tools/overview">
    Las dos rutas de invocación de herramientas y las estructuras de sus solicitudes.
  </Card>
  <Card title="Integraciones de herramientas" icon="wrench" href="/es/guides/build-tool-integration">
    Crea de principio a fin una integración completa respaldada por herramientas.
  </Card>
</CardGroup>
