Verificar firmas de webhooks
Cada solicitud de webhook y herramienta que envía ThunderPhone está firmada. Verifica la firma una vez con la receta de esta página y luego reutiliza la misma verificación en cada endpoint que ejecutes.
Cada solicitud que enviamos a tu servidor — entregas de webhooks e
invocaciones de endpoints de herramientas — incluye una firma HMAC-SHA256 en el
encabezado X-ThunderPhone-Signature. Configura la verificación correctamente una vez y
conecta el mismo asistente en cada controlador.
El algoritmo
- Lee el cuerpo de la solicitud sin procesar — los bytes exactos que te enviamos mediante POST.
- Calcula
hmac_sha256(secret, body).hexdigest(). - Compara en tiempo constante con
X-ThunderPhone-Signature. (Una comparación de cadenas ingenua filtra información de tiempo.)
Firmamos exactamente los bytes que transmitimos, por lo que verificar el cuerpo sin procesar
siempre funciona. 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. Esto te ofrece una segunda receta completamente
equivalente cuando tu framework solo expone JSON analizado:
vuelve a serializar 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 evita las particularidades del recorrido de ida y vuelta de los números JSON en algunos lenguajes.
¿Qué secreto?
| 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.
Implementaciones de referencia
Las cuatro verifican el cuerpo de la solicitud sin procesar:
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)
endConfiguración específica del framework
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
@app.post("/thunderphone-webhook")
async def hook(request: Request):
body = await request.body() # raw bytes, NOT request.json()
sig = request.headers.get("X-ThunderPhone-Signature", "")
if not verify(body, sig, SECRET):
raise HTTPException(status_code=401)
import json
event = json.loads(body)
# … dispatch on event["type"] …
return {"ok": True}import express from "express";
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 sig = req.header("X-ThunderPhone-Signature") || "";
if (!verify(req.body, sig, process.env.WEBHOOK_SECRET)) {
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 JsonResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt
@require_POST
def hook(request):
body = request.body # raw bytes
sig = request.headers.get("X-ThunderPhone-Signature", "")
if not verify(body, sig, SECRET):
return HttpResponseForbidden("invalid signature")
event = json.loads(body)
# … dispatch on event["type"] …
return JsonResponse({"ok": True})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 dos 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: HMAC-SHA256, con tu secreto de webhook de nivel de organización como clave, sobre los bytes exactos del cuerpo de la solicitud.
El mismo asistente verify() funciona sin cambios, con dos detalles:
- Las herramientas
GET/DELETEno tienen cuerpo. Los argumentos se envían como parámetros de consulta y la firma se calcula sobre la cadena de bytes vacía; por lo tanto, usaverify(b"", sig, secret)(Python) overify(Buffer.alloc(0), sig, secret)(Node). No calcules el hash de la cadena de consulta. - 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 no incluyen un 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.post("/tools/search-appointments")
async def tool(request: Request):
body = await request.body() # b"" for GET/DELETE tools
sig = request.headers.get("X-ThunderPhone-Signature", "")
call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
if not verify(body, sig, ORG_WEBHOOK_SECRET):
raise HTTPException(status_code=401)
args = json.loads(body)
...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.
Errores comunes
Reserializar con el formato predeterminado
Analizar el cuerpo y volver a serializarlo con los valores
predeterminados de tu biblioteca JSON (espacios después de , / :, claves
ordenadas por inserción) produce bytes diferentes e invalida el HMAC. Verifica el cuerpo sin procesar; o, si debes volver a serializarlo, reproduce 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 la misma función verify(),
pero asegúrate de proporcionarle el secreto de la organización en las rutas de herramientas.
Aplicar hash a la cadena de consulta en herramientas GET/DELETE
Para métodos de herramientas sin cuerpo, la firma cubre la cadena de bytes vacía, manteniendo una única receta universal: aplica HMAC al cuerpo sin procesar de la solicitud, sea cual sea. Aplicar hash a la URL o a la cadena de consulta nunca coincidirá.
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.