Open in
Verifica las firmas de los webhooks
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
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4etes la hora Unix, en segundos, en la que enviamos la solicitud. Una entrega reintentada se firma de nuevo cuando se envía, con su propiot.v1es 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:
<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:
- Antes de analizar cualquier dato, lee el encabezado, el método, la ruta y la consulta sin procesar, y el cuerpo sin procesar.
- Obtén
ty cadav1del encabezado. - Rechaza la solicitud si
testá más de 300 segundos antes o después de tu reloj. - Calcula el HMAC-SHA256 hexadecimal de la cadena firmada con tu secreto.
- Compáralo con
v1en tiempo constante. Responde401si no coincide ninguno.
Ejemplo completo
Secreto whsec_example_4c1f2a9b. Enviamos esta solicitud a las
t = 1767225600 (2026-01-01 00:00:00 UTC):
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:
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:
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 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.
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
)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
});Sin un SDK
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)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)
);
});
}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.
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}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);
},
);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})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 (/v1/developer/webhook-endpoints) | secret por endpoint (48 caracteres hexadecimales) que se devuelve una sola vez al crearlo |
| Webhook heredado de URL única | secret por organización devuelto en GET /v1/webhook |
Invocación de endpoint de herramienta (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 (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:
- Las herramientas
GET/DELETEno 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. - 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-IDy 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 medianteendpoint.headers.
@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 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.
- Lee el cuerpo de la solicitud sin procesar: los bytes exactos que te enviamos.
- Calcula
hmac_sha256(secret, body).hexdigest(). - 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:
# 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.
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 "")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),
);
}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))
}require "openssl"
def verify(body, signature, secret)
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
Rack::Utils.secure_compare(expected, signature.to_s)
endErrores comunes
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.
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.
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.
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".
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.
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.
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á.
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.
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.
Próximos pasos
Semántica de entrega, reintentos e IP de origen.
Administra varias URL y rota secretos.
Las dos rutas de invocación de herramientas y las estructuras de sus solicitudes.
Crea de principio a fin una integración completa respaldada por herramientas.