ThunderPhone 2.0 ya está disponible.Empieza por tu cuenta desde 2¢/min.Lee el anuncio

Operations

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.

EncabezadoFirmaProtección contra repeticiones
X-ThunderPhone-Signature-V2 (recomendado)Marca de tiempo, método, ruta, consulta y cuerpoRechaza solicitudes con más de 5 minutos de antigüedad
X-ThunderPhone-SignatureSolo el cuerpoNinguna: 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=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:

<t>\n<METHOD>\n<path and query>\n<body>
CampoValor
tEl valor de t del encabezado
METHODEl método HTTP en mayúsculas: POST, GET, PUT, PATCH o DELETE
path and queryEl 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
bodyEl 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):

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.

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
)
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
});

Sin un SDK

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)
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)
    );
  });
}

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.

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}
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);
  },
);
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})

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.

OrigenSecreto
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 únicasecret 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:

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

  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:

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

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 "")
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
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
require "openssl"
 
def verify(body, signature, secret)
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
  Rack::Utils.secure_compare(expected, signature.to_s)
end

Errores 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