---
title: "Verificar assinaturas de webhook"
description: "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

```http
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
```

- `t` é o horário Unix, em segundos, em que enviamos a solicitação. Uma entrega
  repetida é assinada novamente quando é enviada, com seu próprio `t`.
- `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:

```text
<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:

1. Antes de qualquer análise, leia o cabeçalho, o método, o caminho e a
   query brutos e o corpo bruto.
2. Obtenha `t` e cada `v1` do cabeçalho.
3. Rejeite a solicitação se `t` estiver mais de **300 segundos** antes ou depois
   do seu relógio.
4. Calcule o HMAC-SHA256 hexadecimal da string assinada usando seu segredo.
5. Compare-o com `v1` em tempo constante. Responda `401` se nada corresponder.

### Exemplo completo

Segredo `whsec_example_4c1f2a9b`. Enviamos esta solicitação em
`t = 1767225600` (2026-01-01 00:00:00 UTC):

```http
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:

```text
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`:

```bash
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](/api-reference/sdks) 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.

<CodeGroup>
```python 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
)
```

```javascript 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
});
```
</CodeGroup>

### Sem um SDK

<CodeGroup>
```python 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)
```

```javascript 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)
    );
  });
}
```
</CodeGroup>

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

<CodeGroup>
```python 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}
```

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

```python 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})
```
</CodeGroup>

`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](/pt/webhooks/endpoints) (`/v1/developer/webhook-endpoints`) | `secret` por endpoint (48 caracteres hexadecimais), retornado uma única vez na criação |
| [Webhook legado de URL única](/api-reference/organizations#legacy-single-url-webhook) | `secret` por organização retornado em `GET /v1/webhook` |
| [Invocação de endpoint de ferramenta](/pt/tools/overview) (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](/pt/tools/overview) (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:

1. **As ferramentas `GET` / `DELETE` nã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.
2. **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-ID` e 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 via `endpoint.headers`.

```python
@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](/pt/tools/overview) 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.

1. Leia o corpo **bruto** da solicitação — os bytes exatos que enviamos a você.
2. Calcule `hmac_sha256(secret, body).hexdigest()`.
3. 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:

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

<CodeGroup>
```python 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 "")
```

```javascript 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 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 Ruby
require "openssl"

def verify(body, signature, secret)
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
  Rack::Utils.secure_compare(expected, signature.to_s)
end
```
</CodeGroup>

## Armadilhas comuns

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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".
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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á.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

---

## Próximas etapas

<CardGroup cols={2}>
  <Card title="Visão geral dos webhooks" icon="bolt" href="/pt/webhooks/overview">
    Semântica de entrega, novas tentativas, IPs de origem.
  </Card>
  <Card title="Endpoints de webhook" icon="plug" href="/pt/webhooks/endpoints">
    Gerencie várias URLs, alterne segredos.
  </Card>
  <Card title="Ferramentas de função" icon="screwdriver-wrench" href="/pt/tools/overview">
    Os dois caminhos de invocação de ferramentas e os formatos das solicitações.
  </Card>
  <Card title="Integrações de ferramentas" icon="wrench" href="/pt/guides/build-tool-integration">
    Crie uma integração completa com suporte de ferramentas de ponta a ponta.
  </Card>
</CardGroup>
