---
title: "Weryfikowanie podpisów webhooków"
description: "Każdy webhook i żądanie narzędzia wysyłane przez ThunderPhone jest podpisane znacznikiem czasu. Zweryfikuj je za pomocą przepisu lub pomocnika SDK dostępnego tutaj i odrzucaj ponownie odtwarzane żądania starsze niż pięć minut."
---

Każde żądanie, które wysyłamy na Twój serwer (dostawy webhooków, starszy
webhook z jednym adresem URL oraz wywołania punktów końcowych narzędzi), zawiera dwa nagłówki
podpisu. Oba używają HMAC-SHA256 z tym samym sekretem.

| Nagłówek | Podpisuje | Ochrona przed ponownym odtworzeniem |
|--------|-------|-------------------|
| `X-ThunderPhone-Signature-V2` (zalecany) | Sygnatura czasowa, metoda, ścieżka, zapytanie i treść | Odrzuć żądania starsze niż 5 minut |
| `X-ThunderPhone-Signature` | Tylko treść | Brak: przechwycone żądanie można weryfikować bezterminowo |

W nowych integracjach weryfikuj `X-ThunderPhone-Signature-V2` i przenieś
do niego istniejące integracje. `X-ThunderPhone-Signature` jest nadal wysyłany z każdym
żądaniem, bez zmian, więc weryfikatory oparte na nim nadal działają. Ogłosimy datę, zanim przestaniemy go wysyłać.

## Podpis V2

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

- `t` to czas Unix w sekundach, w którym wysłaliśmy żądanie. Ponowiona
  dostawa jest podpisywana ponownie podczas wysyłania, z własnym `t`.
- `v1` to szesnastkowy HMAC-SHA256 podpisywanego ciągu poniżej, utworzony za pomocą Twojego
  sekretu.

Wartość jest listą par `key=value` rozdzielonych przecinkami. Ignoruj klucze,
których nie rozpoznajesz, i zaakceptuj żądanie, jeśli pasuje dowolne `v1`: możemy dodać
drugi podpis podczas przyszłej zmiany.

### Podpisywany ciąg

Cztery pola, po każdym jeden znak nowej linii (`\n`, bajt `0x0A`) z wyjątkiem
ostatniego:

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

| Pole | Wartość |
|-------|-------|
| `t` | Wartość `t` z nagłówka |
| `METHOD` | Metoda HTTP zapisana wielkimi literami: `POST`, `GET`, `PUT`, `PATCH` lub `DELETE` |
| path and query | Cel żądania z wiersza żądania HTTP: ścieżka, następnie `?` i ciąg zapytania, jeśli występuje. Bez schematu, hosta ani fragmentu. Użyj go dokładnie w otrzymanej postaci, bez dekodowania ani zmiany kolejności parametrów |
| `body` | Surowe ciało żądania. Wywołania narzędzi `GET` i `DELETE` nie mają ciała, więc ich podpisywany ciąg kończy się znakiem nowej linii po ścieżce |

Aby zweryfikować:

1. Przed jakimkolwiek parsowaniem odczytaj nagłówek, metodę, surową ścieżkę i
   zapytanie oraz surowe ciało.
2. Pobierz `t` oraz każde `v1` z nagłówka.
3. Odrzuć żądanie, jeśli `t` wyprzedza lub opóźnia się względem
   Twojego zegara o więcej niż **300 sekund**.
4. Oblicz szesnastkowy HMAC-SHA256 podpisywanego ciągu za pomocą swojego sekretu.
5. Porównaj go z `v1` w czasie stałym. Odpowiedz `401`, jeśli nic nie pasuje.

### Przykład

Sekret `whsec_example_4c1f2a9b`. Wysyłamy to żądanie o
`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"}
```

Podpisywany ciąg to te cztery wiersze, bez znaku nowej linii po ciele:

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

Jego HMAC-SHA256 to
`03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e`.
Sprawdź względem niego swoją implementację albo odtwórz go za pomocą `openssl`:

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

Wywołanie narzędzia `GET` do `/tools/lookup?customer_id=cust_123` w tej samej
sekundzie ma puste ciało. Jego podpisywany ciąg to
`1767225600\nGET\n/tools/lookup?customer_id=cust_123\n`, a jego podpis
to `3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280`.

### Z użyciem SDK

[SDK dla Pythona i TypeScript](/api-reference/sdks) zawierają funkcję pomocniczą
(`thunderphone` 0.2.0 lub nowszy, `@thunderphone/sdk` 0.2.0 lub nowszy). Sprawdza ona
5-minutowe okno, porównuje w czasie stałym i akceptuje listę
sekretów, dzięki czemu możesz je rotować bez przestoju.

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

### Integracja z frameworkiem

Każdy przykład odczytuje surową ścieżkę i zapytanie oraz surowe ciało żądania, a następnie wywołuje funkcję pomocniczą 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) oraz ASGI `raw_path` i `query_string`
(FastAPI) stanowią docelowy adres dokładnie w otrzymanej postaci. Metody Django
`get_full_path()` i Flask `request.path` najpierw dekodują ścieżkę, co
dla zwykłych ścieżek daje ten sam ciąg znaków. Jeśli ścieżka endpointu zawiera
znaki zakodowane procentowo lub zastrzeżone, takie jak `%2F`, `;` lub `=`, odczytaj
zamiast tego surowy adres docelowy udostępniany przez serwer (na przykład `request.META["RAW_URI"]` w
Gunicorn).

## Który sekret?

Ten sam sekret podpisuje oba nagłówki.

| Źródło | Sekret |
|--------|--------|
| [Endpoint webhooka](/pl/webhooks/endpoints) (`/v1/developer/webhook-endpoints`) | Sekret `secret` dla danego endpointu (48 znaków szesnastkowych), zwracany jednorazowo podczas tworzenia |
| [Starszy webhook z pojedynczym adresem URL](/api-reference/organizations#legacy-single-url-webhook) | Sekret `secret` dla organizacji zwracany przez `GET /v1/webhook` |
| [Wywołanie endpointu narzędzia](/pl/tools/overview) (bezpośrednie wywołanie Twojego `endpoint.url`) | **Sekret webhooka na poziomie organizacji** (ten sam co dla starszego webhooka z pojedynczym adresem URL) — nie sekret dla pojedynczego endpointu |

Przechowuj sekret w menedżerze sekretów lub zmiennej środowiskowej — nigdy nie zapisuj go w repozytorium.

## Weryfikowanie wywołań narzędzi

Gdy agent bezpośrednio wywołuje jedno z Twoich
[narzędzi funkcyjnych](/pl/tools/overview) (narzędzie ma
`endpoint`), żądanie zawiera następujące nagłówki ThunderPhone wraz
ze skonfigurowanym przez Ciebie `endpoint.headers`:

- `X-ThunderPhone-Call-ID` — numeryczny identyfikator aktywnego połączenia.
- `X-ThunderPhone-Signature-V2` — oparty na znaczniku czasu podpis powyżej, powiązany
  z Twoim **sekretem webhooka na poziomie organizacji**.
- `X-ThunderPhone-Signature` — starszy podpis obejmujący tylko treść żądania, z tym samym kluczem.

Dwie rzeczy różnią się od webhooków:

1. **Narzędzia `GET` / `DELETE` nie mają treści żądania.** Argumenty są przekazywane jako parametry
   zapytania. Podpis V2 obejmuje je za pośrednictwem ścieżki i zapytania,
   a pole treści jest puste. Weryfikuj przy użyciu nieprzetworzonego ciągu zapytania: analizowanie
   parametrów i tworzenie nowego ciągu zapytania może zmienić ich kolejność
   lub kodowanie. Starszy nagłówek podpisuje pusty ciąg bajtów.
2. **Organizacje bez skonfigurowanego starszego webhooka nie mają sekretu organizacji.** W
   takim przypadku wywołania narzędzi zawierają tylko `X-ThunderPhone-Call-ID` i nie zawierają
   nagłówków podpisu. Skonfiguruj starszy webhook
   (`PUT /v1/webhook`), aby uzyskać sekret do podpisywania, lub uwierzytelniaj wywołania narzędzi
   własnym nagłówkiem za pośrednictwem `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)
    ...
```

W Express parser treści żądania pozostawia `req.body` jako pusty obiekt przy żądaniu `GET`,
dlatego przekaż `Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0)`.

Dystrybucja wywołań narzędzi w **trybie** webhooka (narzędzia bez `endpoint`, dostarczane
do webhooka organizacji jako `telephony.tool` / `web.tool`) to zwykły
podpisany webhook — obowiązuje standardowa procedura opisana powyżej. Zobacz
[Narzędzia funkcyjne](/pl/tools/overview), aby poznać oba formaty żądań.

## Starszy podpis: `X-ThunderPhone-Signature`

Oryginalny nagłówek to szesnastkowy HMAC-SHA256 samego surowego treści żądania, z
tym samym sekretem. Nie zawiera znacznika czasu, więc każdy, kto przechwyci jedno
podpisane żądanie, może je ponownie odtworzyć. Jest nadal wysyłany przy każdym żądaniu, gdy
integracje przechodzą na V2.

1. Odczytaj **surową** treść żądania — dokładne bajty, które Ci wysłaliśmy.
2. Oblicz `hmac_sha256(secret, body).hexdigest()`.
3. Porównaj w **czasie stałym** z `X-ThunderPhone-Signature`.

Podpisujemy dokładnie te bajty, które przesyłamy. Te bajty są również
**kanoniczną serializacją JSON** ładunku — klucze posortowane
alfabetycznie, zwarte separatory (`,` i `:` bez spacji), UTF-8.
Jeśli Twój framework udostępnia tylko sparsowany JSON, ponownie go zserializuj kanonicznie i
oblicz dla niego HMAC:

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

Preferuj surową treść — to o jeden krok mniej i zapewnia odporność na problemy z ponowną konwersją
liczb JSON w niektórych językach. To samo dotyczy pola treści w 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>

## Typowe pułapki

<AccordionGroup>
  <Accordion title="Rozbieżność zegara">
    5-minutowe okno jest mierzone względem zegara Twojego serwera. Utrzymuj go
    zsynchronizowanego z NTP; zegar opóźniony lub przyspieszony o ponad 5 minut odrzuca każde
    żądanie.
  </Accordion>

  <Accordion title="Serwer proxy zmienia ścieżkę">
    Podpisana ścieżka to ta, o którą poprosiliśmy: ścieżka adresu URL
    skonfigurowanego w ThunderPhone. Jeśli odwrotny serwer proxy lub brama API usuwa
    albo dodaje prefiks, zanim aplikacja zobaczy żądanie, odtwórz
    ścieżkę, którą wysłaliśmy (na przykład przywróć prefiks), przed weryfikacją.
  </Accordion>

  <Accordion title="Ponowna serializacja z domyślnym formatowaniem">
    Przetworzenie treści i ponowne zapisanie jej przy użyciu domyślnych ustawień
    biblioteki JSON (spacje po `,` / `:`, klucze w kolejności wstawiania) generuje
    inne bajty i unieważnia HMAC. Zweryfikuj surową treść — lub jeśli
    musisz ponownie ją serializować, dokładnie dopasuj naszą formę kanoniczną: posortowane
    klucze, zwarte separatory, UTF-8.
  </Accordion>

  <Accordion title="Framework automatycznie przetwarza JSON">
    Oprogramowanie pośredniczące Express `express.json()` zużywa strumień treści
    i tracisz surowe bajty. Użyj `express.raw()` konkretnie dla trasy webhooka
    albo buforuj surową treść w oprogramowaniu pośredniczącym poprzedzającym parser.
    Tak samo jest w NestJS / Koa — sprawdź ich dokumentację dotyczącą surowego ciała żądania.
  </Accordion>

  <Accordion title="Porównanie niebezpieczne czasowo">
    `expected === signature` w JS lub `expected == signature` w
    Pythonie to porównania zależne od czasu wykonania. Użyj odpowiednio `crypto.timingSafeEqual`
    lub `hmac.compare_digest`. Różnica w wydajności
    jest zerowa.
  </Accordion>

  <Accordion title="Nieprawidłowy sekret dla punktów końcowych narzędzi">
    Bezpośrednie wywołania punktów końcowych narzędzi są podpisywane za pomocą **sekretu webhooka
    na poziomie organizacji** (`GET /v1/webhook`) — nie za pomocą sekretu konkretnego punktu końcowego
    z `/v1/developer/webhook-endpoints`. Użyj ponownie tego samego pomocnika,
    ale upewnij się, że przekazujesz mu sekret organizacji na trasach narzędzi.
  </Accordion>

  <Accordion title="Ciągi zapytania w narzędziach GET/DELETE">
    V2 podpisuje ciąg zapytania dokładnie w wysłanej postaci. Starszy nagłówek podpisuje
    pustą treść, a nigdy ciąg zapytania, więc obliczanie skrótu URL dla
    starszego nagłówka nigdy nie da zgodnego wyniku.
  </Accordion>

  <Accordion title="Duplikaty w oknie czasowym">
    Znacznik czasu blokuje powtórzenia starsze niż 5 minut, a nie drugą kopię
    w tym oknie. Dostarczenia do punktów końcowych odbywają się co najmniej raz, więc może również
    nadejść ponowna próba tego samego zdarzenia; usuwaj duplikaty na podstawie
    `event_id` koperty.
  </Accordion>

  <Accordion title="Brak zwracania 401 przy niezgodności">
    Zwracanie 200 po nieudanej weryfikacji sprawia, że procedura obsługi staje się celem ataków typu replay.
    Zawsze odpowiadaj kodem innym niż 2xx, jeśli weryfikacja się nie powiedzie.
  </Accordion>
</AccordionGroup>

---

## Kolejne kroki

<CardGroup cols={2}>
  <Card title="Przegląd webhooków" icon="bolt" href="/pl/webhooks/overview">
    Semantyka dostarczania, ponowne próby, źródłowe adresy IP.
  </Card>
  <Card title="Endpointy webhooków" icon="plug" href="/pl/webhooks/endpoints">
    Zarządzaj wieloma adresami URL, rotuj sekrety.
  </Card>
  <Card title="Narzędzia funkcji" icon="screwdriver-wrench" href="/pl/tools/overview">
    Dwie ścieżki wywoływania narzędzi i formaty ich żądań.
  </Card>
  <Card title="Integracje narzędzi" icon="wrench" href="/pl/guides/build-tool-integration">
    Zbuduj kompletną integrację opartą na narzędziach od początku do końca.
  </Card>
</CardGroup>
