Verificar assinaturas de webhook
Todo webhook e solicitação de ferramenta enviados pelo ThunderPhone são assinados. Verifique a assinatura uma vez com a receita abaixo e reutilize a mesma verificação em todos os endpoints que você executar.
Todas as solicitações que enviamos ao seu servidor — entregas de webhook e
invocações de endpoint de ferramenta — incluem uma assinatura HMAC-SHA256 no
cabeçalho X-ThunderPhone-Signature. Faça a verificação corretamente uma vez e
use o mesmo auxiliar em todos os manipuladores.
O algoritmo
- Leia o corpo bruto da solicitação — os bytes exatos que enviamos por POST para você.
- Calcule
hmac_sha256(secret, body).hexdigest(). - Compare em tempo constante com
X-ThunderPhone-Signature. (Uma comparação ingênua de strings vaza informações de tempo.)
Assinamos exatamente os bytes que transmitimos, portanto verificar o corpo bruto
sempre funciona. 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. Isso oferece uma segunda receita, totalmente
equivalente, quando seu framework expõe apenas JSON analisado:
serialize novamente de forma canônica e aplique HMAC a isso.
# 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 evita particularidades de conversão de ida e volta de números JSON em algumas linguagens.
Qual segredo?
| 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.
Implementações de referência
Todas as quatro verificam o corpo bruto da solicitação:
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)
endIntegração específica do 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})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 dois cabeçalhos do ThunderPhone além
dos seus endpoint.headers configurados:
X-ThunderPhone-Call-ID— o ID numérico da chamada ativa.X-ThunderPhone-Signature— HMAC-SHA256, com chave definida pelo seu segredo de webhook no nível da organização, sobre os bytes exatos do corpo da solicitação.
O mesmo auxiliar verify() funciona sem alterações, com duas particularidades:
- As ferramentas
GET/DELETEnão têm corpo. Os argumentos são enviados como parâmetros de consulta, e a assinatura é calculada sobre a string de bytes vazia — ou seja,verify(b"", sig, secret)(Python) ouverify(Buffer.alloc(0), sig, secret)(Node). Não faça hash da string de consulta. - Organizações sem um webhook legado configurado não têm um segredo da 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.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)
...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.
Armadilhas comuns
Reserializar 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 reserializá-lo, 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 a mesma função verify(),
mas certifique-se de fornecer a ela o segredo da organização nas rotas de ferramentas.
Gerar hash da string de consulta em ferramentas GET/DELETE
Para métodos de ferramentas sem corpo, a assinatura abrange a string de bytes vazia, mantendo uma única receita universal: aplique HMAC ao corpo bruto da solicitação, seja ele qual for. Gerar hash da URL ou da string de consulta nunca corresponderá.
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.