---
title: "Ověření podpisů webhooků"
description: "Každý webhook a požadavek nástroje, který ThunderPhone odesílá, je podepsán časovým razítkem. Ověřte jej pomocí zde uvedeného postupu nebo pomocné funkce SDK a odmítněte opakované požadavky starší než pět minut."
---

Každý požadavek, který odesíláme na váš server (doručení webhooků, starší
webhook s jedinou adresou URL a volání koncových bodů nástrojů), obsahuje dvě hlavičky
podpisu. Obě používají HMAC-SHA256 se stejným tajným klíčem.

| Hlavička | Podepisuje | Ochrana proti opakování |
|--------|-------|-------------------|
| `X-ThunderPhone-Signature-V2` (doporučeno) | Časové razítko, metoda, cesta, dotaz a tělo | Odmítněte požadavky starší než 5 minut |
| `X-ThunderPhone-Signature` | Pouze tělo | Žádná: zachycený požadavek se ověří navždy |

V nových integracích ověřujte `X-ThunderPhone-Signature-V2` a stávající
integrace na něj převeďte. `X-ThunderPhone-Signature` se stále odesílá s každým
požadavkem beze změny, takže ověřovací mechanismy založené na něm nadále fungují. Datum, kdy jej přestaneme odesílat, oznámíme předem.

## Podpis V2

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

- `t` je čas Unix v sekundách, kdy jsme požadavek odeslali. Opakované
  doručení se při odeslání podepíše znovu s vlastním `t`.
- `v1` je hexadecimální HMAC-SHA256 podepsaného řetězce níže, vytvořený
  pomocí vašeho tajného klíče.

Hodnota je seznam párů `key=value` oddělených čárkami. Ignorujte klíče,
které neznáte, a přijměte požadavek, pokud se shoduje jakékoli `v1`: při
budoucí změně můžeme přidat druhý podpis.

### Podepsaný řetězec

Čtyři pole, po každém následuje jeden nový řádek (`\n`, bajt `0x0A`), kromě
posledního:

```text
<t>\n<METHOD>\n<path and query>\n<body>
```

| Pole | Hodnota |
|-------|-------|
| `t` | Hodnota `t` z hlavičky |
| `METHOD` | Metoda HTTP velkými písmeny: `POST`, `GET`, `PUT`, `PATCH` nebo `DELETE` |
| path and query | Cíl požadavku z řádku požadavku HTTP: cesta, poté `?` a řetězec dotazu, pokud existuje. Bez schématu, hostitele nebo fragmentu. Použijte jej přesně tak, jak dorazil, bez dekódování nebo změny pořadí parametrů |
| `body` | Nezpracované tělo požadavku. Volání nástrojů `GET` a `DELETE` nemají tělo, takže jejich podepsaný řetězec končí novým řádkem za cestou |

Ověření:

1. Před jakýmkoli parsováním načtěte hlavičku, metodu, nezpracovanou cestu
   a dotaz i nezpracované tělo.
2. Z hlavičky vezměte `t` a všechna `v1`.
3. Odmítněte požadavek, pokud se `t` liší od vašeho času o více než
   **300 sekund**.
4. Vypočítejte hexadecimální HMAC-SHA256 podepsaného řetězce pomocí vašeho tajného klíče.
5. Porovnejte jej s `v1` v konstantním čase. Pokud se nic neshoduje, odpovězte `401`.

### Praktický příklad

Tajný klíč `whsec_example_4c1f2a9b`. Tento požadavek odesíláme v čase
`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"}
```

Podepsaný řetězec tvoří tyto čtyři řádky bez nového řádku za tělem:

```text
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}
```

Jeho HMAC-SHA256 je
`03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e`.
Ověřte podle něj svou implementaci nebo jej reprodukujte pomocí `openssl`:

```bash
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
  | openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'
```

Volání nástroje `GET` na `/tools/lookup?customer_id=cust_123` ve stejné
sekundě má prázdné tělo. Jeho podepsaný řetězec je
`1767225600\nGET\n/tools/lookup?customer_id=cust_123\n` a jeho podpis je
`3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280`.

### Se sadami SDK

[Sady SDK pro Python a TypeScript](/api-reference/sdks) obsahují pomocnou funkci
(`thunderphone` 0.2.0 nebo novější, `@thunderphone/sdk` 0.2.0 nebo novější). Ta
kontroluje pětiminutové okno, porovnává v konstantním čase a přijímá seznam
tajných klíčů, takže je můžete střídat bez výpadku.

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

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

### Zapojení frameworku

Každý příklad načte nezpracovanou cestu a dotaz i nezpracované tělo a poté zavolá
pomocnou funkci 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) a ASGI `raw_path` a `query_string`
(FastAPI) představují cíl přesně v podobě, v jaké byl přijat. Metoda Django
`get_full_path()` a Flask `request.path` nejprve dekódují cestu, což
u běžných cest poskytne stejný řetězec. Pokud cesta vašeho endpointu obsahuje
procentuálně kódované nebo vyhrazené znaky, například `%2F`, `;` nebo `=`, načtěte
místo toho nezpracovaný cíl, který váš server poskytuje (například `request.META["RAW_URI"]` na
Gunicornu).

## Který tajný klíč?

Obě hlavičky jsou podepsány stejným tajným klíčem.

| Zdroj | Tajný klíč |
|--------|--------|
| [Koncový bod webhooku](/cs/webhooks/endpoints) (`/v1/developer/webhook-endpoints`) | Tajný klíč `secret` pro každý koncový bod (48 hexadecimálních znaků), vrácený jednou při vytvoření |
| [Starší webhook s jedinou adresou URL](/api-reference/organizations#legacy-single-url-webhook) | Tajný klíč `secret` pro organizaci vrácený při `GET /v1/webhook` |
| [Volání koncového bodu nástroje](/cs/tools/overview) (přímé volání vašeho `endpoint.url`) | **Tajný klíč webhooku na úrovni organizace** (stejný jako pro starší webhook s jedinou adresou URL) — ne tajný klíč pro jednotlivý koncový bod |

Tajný klíč uložte do správce tajných klíčů nebo proměnné prostředí — nikdy jej neukládejte do repozitáře.

## Ověřování volání nástrojů

Když hlasový agent přímo vyvolá jeden z vašich
[funkčních nástrojů](/cs/tools/overview) (nástroj má
`endpoint`), požadavek kromě vámi nakonfigurovaných `endpoint.headers`
obsahuje tyto hlavičky ThunderPhone:

- `X-ThunderPhone-Call-ID` — číselné ID probíhajícího hovoru.
- `X-ThunderPhone-Signature-V2` — výše uvedený časově označený podpis, podepsaný
  vaším **tajemstvím webhooku na úrovni organizace**.
- `X-ThunderPhone-Signature` — starší podpis pouze těla se stejným tajemstvím.

Oproti webhookům se liší dvě věci:

1. **Nástroje `GET` / `DELETE` nemají tělo.** Argumenty se předávají jako parametry
   dotazu. Podpis V2 je zahrnuje prostřednictvím cesty a dotazu
   a pole těla je prázdné. Ověřujte pomocí nezpracovaného řetězce dotazu: parsování
   parametrů a vytvoření nového řetězce dotazu může změnit jejich pořadí
   nebo kódování. Starší hlavička podepisuje prázdný bajtový řetězec.
2. **Organizace bez nakonfigurovaného staršího webhooku nemají tajemství organizace.** V
   takovém případě volání nástrojů obsahují pouze `X-ThunderPhone-Call-ID` a žádné
   hlavičky podpisu. Nakonfigurujte starší webhook
   (`PUT /v1/webhook`) pro získání tajemství k podepisování nebo ověřujte volání
   nástrojů vlastní hlavičkou prostřednictvím `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)
    ...
```

V Expressu parser těla ponechá `req.body` u požadavku `GET` jako prázdný objekt,
proto předejte `Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0)`.

Odesílání nástrojů v **režimu** webhooku (nástroje bez `endpoint`, doručované
na webhook vaší organizace jako `telephony.tool` / `web.tool`) je běžný
podepsaný webhook — platí pro něj standardní postup uvedený výše. Oba tvary požadavků najdete v části
[Funkční nástroje](/cs/tools/overview).

## Starší podpis: `X-ThunderPhone-Signature`

Původní hlavička obsahuje hexadecimální HMAC-SHA256 pouze nezpracovaného těla se stejným tajemstvím. Neobsahuje časové razítko, takže kdokoli, kdo zachytí jeden podepsaný požadavek, jej může znovu přehrát. Během přechodu integrací na V2 se stále odesílá s každým požadavkem.

1. Přečtěte **nezpracované** tělo požadavku — přesné bajty, které jsme vám odeslali.
2. Vypočítejte `hmac_sha256(secret, body).hexdigest()`.
3. Porovnejte v **konstantním čase** s `X-ThunderPhone-Signature`.

Podepisujeme přesně ty bajty, které přenášíme. Tyto bajty jsou také **kanonickou serializací JSON** datové části — klíče seřazené abecedně, kompaktní oddělovače (`,` a `:` bez mezer), UTF-8. Pokud váš framework zpřístupňuje pouze parsovaný JSON, znovu jej kanonicky serializujte a vypočítejte HMAC z něj:

```python
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
```

Upřednostněte nezpracované tělo — je to o krok méně a vyhnete se zvláštnostem při opakované konverzi čísel JSON v některých jazycích. Totéž platí pro pole těla ve 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>

## Běžné chyby

<AccordionGroup>
  <Accordion title="Posun hodin">
    Pětiminutové okno se měří podle hodin vašeho serveru. Udržujte je
    synchronizované pomocí NTP; hodiny posunuté o více než 5 minut odmítnou každý
    požadavek.
  </Accordion>

  <Accordion title="Proxy přepisuje cestu">
    Podepsaná cesta je ta, kterou jsme požadovali: cesta adresy URL, kterou jste
    nakonfigurovali v ThunderPhone. Pokud reverzní proxy nebo brána API předtím,
    než vaše aplikace požadavek uvidí, odebere nebo přidá předponu, před ověřením
    znovu sestavte cestu, kterou jsme odeslali (například předponu vraťte zpět).
  </Accordion>

  <Accordion title="Opětovná serializace s výchozím formátováním">
    Parsování těla a jeho opětovný výpis s výchozím nastavením vaší knihovny JSON
    (mezery po `,` / `:`, klíče v pořadí vložení) vytváří jiné bajty a poruší
    HMAC. Ověřte nezpracované tělo — nebo pokud jej musíte znovu serializovat,
    přesně dodržte náš kanonický formát: seřazené klíče, kompaktní oddělovače,
    UTF-8.
  </Accordion>

  <Accordion title="Framework automaticky parsuje JSON">
    Middleware `express.json()` v Expressu spotřebuje datový proud těla
    a přijdete o nezpracované bajty. Použijte `express.raw()` přímo pro cestu webhooku
    nebo ukládejte nezpracované tělo do vyrovnávací paměti v předchozím middleware.
    Totéž platí pro NestJS / Koa — podívejte se do jejich dokumentace k „raw body“.
  </Accordion>

  <Accordion title="Porovnání nebezpečné z hlediska časování">
    `expected === signature` v JS nebo `expected == signature` v
    Pythonu jsou porovnání závislá na časování. Použijte příslušně `crypto.timingSafeEqual`
    nebo `hmac.compare_digest`. Rozdíl ve výkonu
    je nulový.
  </Accordion>

  <Accordion title="Nesprávný tajný klíč pro koncové body nástrojů">
    Přímá volání koncových bodů nástrojů jsou podepsána **tajným klíčem webhooku
    na úrovni organizace** (`GET /v1/webhook`) — nikoli tajným klíčem pro konkrétní
    koncový bod z `/v1/developer/webhook-endpoints`. Znovu použijte stejnou pomocnou
    funkci, ale ujistěte se, že jí pro cesty nástrojů předáváte tajný klíč organizace.
  </Accordion>

  <Accordion title="Řetězce dotazu u nástrojů GET/DELETE">
    V2 podepisuje řetězec dotazu přesně v odeslané podobě. Starší hlavička podepisuje
    prázdné tělo a nikdy ne řetězec dotazu, takže hashování adresy URL pro starší
    hlavičku nikdy nebude odpovídat.
  </Accordion>

  <Accordion title="Duplikáty v rámci okna">
    Časové razítko zastaví opakování starší než 5 minut, nikoli druhou kopii v rámci
    tohoto okna. Doručování na koncový bod probíhá alespoň jednou, takže může dorazit
    i opakovaný pokus o stejnou událost; odstraňujte duplicity podle `event_id`
    obálky.
  </Accordion>

  <Accordion title="Nevracení 401 při neshodě">
    Vrácení 200 při neúspěšném ověření dělá z handleru cíl pro opakované útoky.
    Pokud ověření selže, vždy odpovězte stavem mimo 2xx.
  </Accordion>
</AccordionGroup>

---

## Další kroky

<CardGroup cols={2}>
  <Card title="Přehled webhooků" icon="bolt" href="/cs/webhooks/overview">
    Sémantika doručování, opakování, zdrojové IP adresy.
  </Card>
  <Card title="Koncové body webhooků" icon="plug" href="/cs/webhooks/endpoints">
    Spravujte více adres URL, obměňujte tajné klíče.
  </Card>
  <Card title="Funkční nástroje" icon="screwdriver-wrench" href="/cs/tools/overview">
    Dvě cesty volání nástrojů a tvary jejich požadavků.
  </Card>
  <Card title="Integrace nástrojů" icon="wrench" href="/cs/guides/build-tool-integration">
    Vytvořte kompletní integraci využívající nástroje od začátku do konce.
  </Card>
</CardGroup>
