Open in
Verificar assinaturas de webhook
Todo webhook e toda solicitação de ferramenta enviados pelo ThunderPhone são assinados com um carimbo de data e hora. Verifique-os com a receita ou o auxiliar do SDK disponível aqui e rejeite solicitações repetidas com mais de cinco minutos.
Cada solicitação que enviamos ao seu servidor (entregas de webhook, o webhook legado de URL única e chamadas de endpoint de ferramenta) inclui dois cabeçalhos de assinatura. Ambos usam HMAC-SHA256 com o mesmo segredo.
| Cabeçalho | Assina | Proteção contra repetição |
|---|---|---|
X-ThunderPhone-Signature-V2 (recomendado) | Carimbo de data e hora, método, caminho, consulta e corpo | Rejeite solicitações com mais de 5 minutos |
X-ThunderPhone-Signature | Apenas o corpo | Nenhuma: uma solicitação capturada é verificada para sempre |
Verifique X-ThunderPhone-Signature-V2 em novas integrações e migre as existentes para ele. X-ThunderPhone-Signature ainda é enviado em todas as solicitações, sem alterações, portanto os verificadores baseados nele continuam funcionando. Anunciaremos uma data antes de parar de enviá-lo.
Assinatura V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4eté o horário Unix, em segundos, em que enviamos a solicitação. Uma entrega repetida é assinada novamente quando é enviada, com seu própriot.v1é o HMAC-SHA256 hexadecimal da string assinada abaixo, usando seu segredo como chave.
O valor é uma lista separada por vírgulas de pares key=value. Ignore chaves que
você não reconhece e aceite a solicitação se algum v1 corresponder: podemos adicionar
uma segunda assinatura em uma alteração futura.
A string assinada
Quatro campos, cada um seguido por uma quebra de linha (\n, byte 0x0A), exceto o
último:
<t>\n<METHOD>\n<path and query>\n<body>| Campo | Valor |
|---|---|
t | O valor de t do cabeçalho |
METHOD | O método HTTP em maiúsculas: POST, GET, PUT, PATCH ou DELETE |
| caminho e query | O destino da solicitação na linha de solicitação HTTP: o caminho, seguido de ? e da string de query quando houver uma. Sem esquema, host ou fragmento. Use-o exatamente como foi recebido, sem decodificá-lo nem reordenar parâmetros |
body | O corpo bruto da solicitação. Chamadas de ferramenta GET e DELETE não têm corpo, portanto sua string assinada termina com a quebra de linha após o caminho |
Para verificar:
- Antes de qualquer análise, leia o cabeçalho, o método, o caminho e a query brutos e o corpo bruto.
- Obtenha
te cadav1do cabeçalho. - Rejeite a solicitação se
testiver mais de 300 segundos antes ou depois do seu relógio. - Calcule o HMAC-SHA256 hexadecimal da string assinada usando seu segredo.
- Compare-o com
v1em tempo constante. Responda401se nada corresponder.
Exemplo completo
Segredo whsec_example_4c1f2a9b. Enviamos esta solicitação em
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"}A string assinada são estas quatro linhas, sem quebra de linha após o corpo:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}Seu HMAC-SHA256 é
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
Verifique sua implementação com ele ou reproduza-o com openssl:
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'Uma chamada de ferramenta GET para /tools/lookup?customer_id=cust_123 no mesmo
segundo tem um corpo vazio. Sua string assinada é
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n e sua assinatura
é 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.
Com os SDKs
Os SDKs de Python e TypeScript incluem um auxiliar
(thunderphone 0.2.0 ou posterior, @thunderphone/sdk 0.2.0 ou posterior). Ele
verifica a janela de 5 minutos, compara em tempo constante e aceita uma lista
de segredos para que você possa alterná-los sem indisponibilidade.
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
});Sem um 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)
);
});
}Integração com frameworks
Cada exemplo lê o caminho e a consulta brutos, além do corpo bruto, e então chama o auxiliar do 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) e raw_path e query_string do ASGI
(FastAPI) correspondem ao destino exatamente como foi recebido. O
get_full_path() do Django e request.path do Flask decodificam o caminho primeiro, o que
gera a mesma string para caminhos comuns. Se o caminho do seu endpoint contiver
caracteres codificados em percentual ou reservados, como %2F, ; ou =, leia o
destino bruto fornecido pelo seu servidor (por exemplo, request.META["RAW_URI"] no
Gunicorn).
Qual segredo?
O mesmo segredo assina ambos os cabeçalhos.
| Origem | Segredo |
|---|---|
Endpoint de webhook (/v1/developer/webhook-endpoints) | secret por endpoint (48 caracteres hexadecimais), retornado uma única vez na criação |
| Webhook legado de URL única | secret por organização retornado em GET /v1/webhook |
Invocação de endpoint de ferramenta (chamada direta para seu endpoint.url) | O segredo de webhook no nível da organização (o mesmo do webhook legado de URL única) — não um segredo por endpoint |
Armazene o segredo no seu gerenciador de segredos ou em uma variável de ambiente — nunca o confirme em um commit.
Verificação de chamadas de ferramentas
Quando o agente invoca diretamente uma das suas
ferramentas de função (a ferramenta tem um
endpoint), a solicitação inclui estes cabeçalhos do ThunderPhone junto com
os endpoint.headers configurados:
X-ThunderPhone-Call-ID— o ID numérico da chamada ao vivo.X-ThunderPhone-Signature-V2— a assinatura com carimbo de data e hora acima, associada ao seu segredo de webhook no nível da organização.X-ThunderPhone-Signature— a assinatura legada apenas do corpo, com a mesma chave.
Duas coisas diferem dos webhooks:
- As ferramentas
GET/DELETEnão têm corpo. Os argumentos são enviados como parâmetros de consulta. A assinatura V2 os abrange pelo caminho e pela consulta, e o campo do corpo fica vazio. Verifique usando a string de consulta bruta: analisar os parâmetros e criar uma nova string de consulta pode alterar a ordem ou a codificação deles. O cabeçalho legado assina a string de bytes vazia. - Organizações sem um webhook legado configurado não têm segredo de organização. Nesse
caso, as chamadas de ferramentas incluem apenas
X-ThunderPhone-Call-IDe nenhum cabeçalho de assinatura. Configure o webhook legado (PUT /v1/webhook) para obter um segredo de assinatura ou autentique as chamadas de ferramentas com seu próprio cabeçalho viaendpoint.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)
...No Express, um analisador de corpo deixa req.body como um objeto vazio em um GET,
portanto passe Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).
O despacho de ferramentas no modo webhook (ferramentas sem um endpoint,
entregues ao webhook da sua organização como telephony.tool / web.tool) é
um webhook assinado comum — a receita padrão acima se aplica. Consulte
Ferramentas de função para conhecer os dois formatos de solicitação.
Assinatura legada: X-ThunderPhone-Signature
O cabeçalho original é o HMAC-SHA256 em hexadecimal apenas do corpo bruto, com o mesmo segredo. Ele não contém carimbo de data e hora, portanto qualquer pessoa que capture uma solicitação assinada poderá reproduzi-la. Ele ainda é enviado em todas as solicitações enquanto as integrações migram para a V2.
- Leia o corpo bruto da solicitação — os bytes exatos que enviamos a você.
- Calcule
hmac_sha256(secret, body).hexdigest(). - Compare em tempo constante com
X-ThunderPhone-Signature.
Assinamos exatamente os bytes que transmitimos. Esses bytes também são a
serialização JSON canônica da carga — chaves ordenadas
alfabeticamente, separadores compactos (, e : sem espaços), UTF-8.
Se seu framework expõe apenas JSON analisado, serialize novamente de forma canônica e
calcule o HMAC:
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")Prefira o corpo bruto — é uma etapa a menos e é imune a particularidades de conversão de números JSON em algumas linguagens. O mesmo se aplica ao campo de corpo da 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)
endArmadilhas comuns
Desvio de relógio
A janela de 5 minutos é medida em relação ao relógio do seu servidor. Mantenha-o sincronizado com NTP; um relógio com desvio de mais de 5 minutos rejeita todas as solicitações.
Um proxy reescreve o caminho
O caminho assinado é o que solicitamos: o caminho da URL que você configurou no ThunderPhone. Se um proxy reverso ou gateway de API remove ou adiciona um prefixo antes que sua aplicação veja a solicitação, reconstrua o caminho que enviamos (por exemplo, adicione o prefixo novamente) antes de verificar.
Serialização novamente com formatação padrão
Analisar o corpo e serializá-lo novamente com as configurações
padrão da sua biblioteca JSON (espaços após , / :, chaves na ordem de inserção) produz
bytes diferentes e invalida o HMAC. Verifique o corpo bruto — ou, se
precisar serializar novamente, corresponda exatamente à nossa forma canônica: chaves
ordenadas, separadores compactos, UTF-8.
O framework analisa JSON automaticamente
O middleware express.json() do Express consome o fluxo do corpo
e você perde os bytes brutos. Use express.raw() especificamente na rota
de webhook ou armazene o corpo bruto em buffer em um pré-middleware.
O mesmo ocorre com NestJS / Koa — consulte a documentação sobre "corpo bruto".
Comparação insegura em relação ao tempo
expected === signature em JS ou expected == signature em
Python são comparações com tempo variável. Use crypto.timingSafeEqual
ou hmac.compare_digest, respectivamente. A diferença de desempenho
é nula.
Segredo incorreto para endpoints de ferramentas
Chamadas diretas a endpoints de ferramentas são assinadas com o segredo de webhook
no nível da organização (GET /v1/webhook) — não com qualquer segredo por endpoint
de /v1/developer/webhook-endpoints. Reutilize o mesmo auxiliar,
mas certifique-se de fornecer a ele o segredo da organização nas rotas de ferramentas.
Parâmetros de consulta em ferramentas GET/DELETE
A V2 assina a string de consulta exatamente como enviada. O cabeçalho legado assina o corpo vazio e nunca a string de consulta, portanto calcular o hash da URL para o cabeçalho legado nunca corresponderá.
Duplicatas dentro da janela
O carimbo de data e hora impede repetições com mais de 5 minutos, não uma segunda cópia
dentro dessa janela. As entregas de endpoint ocorrem pelo menos uma vez, portanto uma
nova tentativa do mesmo evento também pode chegar; elimine duplicatas usando o
event_id do envelope.
Não retornar 401 em caso de divergência
Retornar 200 quando a verificação falha torna o manipulador um alvo de repetição. Sempre responda com um status diferente de 2xx se a verificação falhar.
Próximas etapas
Semântica de entrega, novas tentativas, IPs de origem.
Gerencie várias URLs, alterne segredos.
Os dois caminhos de invocação de ferramentas e os formatos das solicitações.
Crie uma integração completa com suporte de ferramentas de ponta a ponta.