---
title: "Vahvista webhook-allekirjoitukset"
description: "Jokainen ThunderPhonen lähettämä webhook- ja työkalupyyntö allekirjoitetaan aikaleimalla. Vahvista se tässä olevalla esimerkillä tai SDK-aputoiminnolla ja hylkää yli viisi minuuttia vanhat uudelleenlähetetyt pyynnöt."
---

Jokaisessa palvelimellesi lähettämässämme pyynnössä (webhook-toimitukset, vanha
yhden URL-osoitteen webhook sekä työkalupäätepisteen kutsut) on kaksi allekirjoitus-
otsaketta. Molemmat ovat HMAC-SHA256-allekirjoituksia samalla salaisuudella.

| Otsake | Allekirjoittaa | Uudelleenlähetyssuojaus |
|--------|-------|-------------------|
| `X-ThunderPhone-Signature-V2` (suositeltu) | Aikaleiman, menetelmän, polun, kyselyn ja pyynnön rungon | Hylkää yli 5 minuuttia vanhat pyynnöt |
| `X-ThunderPhone-Signature` | Vain pyynnön rungon | Ei mitään: kaapattu pyyntö voidaan vahvistaa ikuisesti |

Vahvista `X-ThunderPhone-Signature-V2` uusissa integraatioissa ja siirrä
olemassa olevat integraatiot siihen. `X-ThunderPhone-Signature` lähetetään edelleen
jokaisessa pyynnössä muuttumattomana, joten siihen perustuvat vahvistajat toimivat yhä. Ilmoitamme päivämäärän ennen kuin lopetamme sen lähettämisen.

## Allekirjoitus V2

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

- `t` on Unix-aika sekunteina, jolloin lähetimme pyynnön. Uudelleen yritetty
  toimitus allekirjoitetaan uudelleen lähetyshetkellä omalla `t`-arvollaan.
- `v1` on alla olevan allekirjoitetun merkkijonon heksamuotoinen HMAC-SHA256,
  joka käyttää salaista avaintasi.

Arvo on pilkuilla eroteltu luettelo `key=value`-pareja. Ohita avaimet, joita et
tunnista, ja hyväksy pyyntö, jos jokin `v1` täsmää: voimme lisätä toisen
allekirjoituksen tulevan muutoksen yhteydessä.

### Allekirjoitettu merkkijono

Neljä kenttää, joista kutakin viimeistä lukuun ottamatta seuraa yksi
rivinvaihto (`\n`, tavu `0x0A`):

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

| Kenttä | Arvo |
|-------|-------|
| `t` | Otsakkeen `t`-arvo |
| `METHOD` | HTTP-metodi isoilla kirjaimilla: `POST`, `GET`, `PUT`, `PATCH` tai `DELETE` |
| path and query | HTTP-pyyntörivillä oleva pyynnön kohde: ensin polku, sitten `?` ja kyselymerkkijono, jos sellainen on. Ei skeemaa, isäntää tai fragmenttia. Käytä sitä täsmälleen siinä muodossa kuin se saapui, purkamatta sitä tai järjestämättä parametreja uudelleen |
| `body` | Raaka pyyntörunko. `GET`- ja `DELETE`-työkalukutsuilla ei ole runkoa, joten niiden allekirjoitettu merkkijono päättyy polun jälkeiseen rivinvaihtoon |

Vahvista näin:

1. Lue ennen jäsentämistä otsake, metodi, raaka polku ja kysely sekä raaka
   pyyntörunko.
2. Ota otsakkeesta `t` ja kaikki `v1`-arvot.
3. Hylkää pyyntö, jos `t` on yli **300 sekuntia** ennen tai jälkeen
   kellosi aikaa.
4. Laske allekirjoitetun merkkijonon heksamuotoinen HMAC-SHA256 salaisella avaimellasi.
5. Vertaa sitä `v1`-arvoon vakioajassa. Vastaa `401`, jos mikään ei täsmää.

### Esimerkki

Salainen avain `whsec_example_4c1f2a9b`. Lähetämme tämän pyynnön hetkellä
`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"}
```

Allekirjoitettu merkkijono koostuu näistä neljästä rivistä ilman rivinvaihtoa
rungon jälkeen:

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

Sen HMAC-SHA256 on
`03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e`.
Tarkista toteutuksesi sitä vasten tai toista laskenta komennolla `openssl`:

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

Saman sekunnin aikana tehtävällä `GET`-työkalukutsulla osoitteeseen
`/tools/lookup?customer_id=cust_123` on tyhjä runko. Sen allekirjoitettu merkkijono on
`1767225600\nGET\n/tools/lookup?customer_id=cust_123\n` ja sen allekirjoitus
on `3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280`.

### SDK:iden kanssa

[Python- ja TypeScript-SDK:t](/api-reference/sdks) sisältävät apufunktion
(`thunderphone` 0.2.0 tai uudempi, `@thunderphone/sdk` 0.2.0 tai uudempi). Se
tarkistaa 5 minuutin aikarajan, vertaa vakioajassa ja hyväksyy luettelon
salaisia avaimia, joten voit kierrättää ne ilman käyttökatkoa.

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

### Ilman SDK:ta

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

### Kehysintegraatio

Jokainen esimerkki lukee raa'an polun ja kyselymerkkijonon sekä raa'an rungon ja kutsuu sitten SDK-apufunktiota.

<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) sekä ASGI:n `raw_path` ja `query_string`
(FastAPI) ovat allekirjoituksen kohde täsmälleen vastaanotetussa muodossa. Djangon
`get_full_path()` ja Flaskin `request.path` purkavat polun ensin, mikä
tuottaa tavallisille poluille saman merkkijonon. Jos päätepistepolkusi sisältää
prosenttikoodattuja tai varattuja merkkejä, kuten `%2F`, `;` tai `=`, lue sen
sijaan palvelimesi tarjoama raaka kohde (esimerkiksi `request.META["RAW_URI"]`
Gunicornissa).

## Mikä salaisuus?

Sama salaisuus allekirjoittaa molemmat otsakkeet.

| Lähde | Salaisuus |
|--------|--------|
| [Webhook-päätepiste](/fi/webhooks/endpoints) (`/v1/developer/webhook-endpoints`) | Päätepistekohtainen `secret` (48 heksamerkkiä), joka palautetaan kerran luotaessa |
| [Vanha yhden URL-osoitteen webhook](/api-reference/organizations#legacy-single-url-webhook) | Organisaatiokohtainen `secret`, joka palautetaan pyynnössä `GET /v1/webhook` |
| [Työkalupäätepisteen kutsu](/fi/tools/overview) (suora kutsu osoitteeseen `endpoint.url`) | **Organisaatiotason webhook-salaisuus** (sama kuin vanhassa yhden URL-osoitteen webhookissa) — ei päätepistekohtainen salaisuus |

Tallenna salaisuus salaisuuksien hallintaan tai ympäristömuuttujaan — älä koskaan commitoi sitä.

## Työkalukutsujen varmistaminen

Kun agentti kutsuu yhtä
[funktiotyökaluistasi](/fi/tools/overview) suoraan (työkalulla on
`endpoint`), pyyntö sisältää seuraavat ThunderPhone-otsakkeet
määrittämiesi `endpoint.headers`-asetusten lisäksi:

- `X-ThunderPhone-Call-ID` — käynnissä olevan puhelun numeerinen tunniste.
- `X-ThunderPhone-Signature-V2` — yllä oleva aikaleimattu allekirjoitus, joka on muodostettu
  organisaatiotason **webhook-salaisuudellasi**.
- `X-ThunderPhone-Signature` — vanha vain rungon kattava allekirjoitus samalla avaimella.

Kaksi asiaa poikkeaa webhookeista:

1. **`GET`- / `DELETE`-työkaluilla ei ole runkoa.** Argumentit välitetään kyselyparametreina.
   V2-allekirjoitus kattaa ne polun ja kyselyn kautta, ja runkokenttä on tyhjä. Vahvista
   käyttäen raakaa kyselymerkkijonoa: parametrien jäsentäminen ja uuden kyselymerkkijonon
   muodostaminen voi muuttaa niiden järjestystä tai koodausta. Vanha otsake allekirjoittaa tyhjän tavujonon.
2. **Organisaatioilla, joille ei ole määritetty vanhaa webhookia, ei ole organisaatiosalaisuutta.** Tässä
   tapauksessa työkalukutsut sisältävät vain `X-ThunderPhone-Call-ID`-otsakkeen eivätkä
   allekirjoitusotsakkeita. Määritä vanha webhook
   (`PUT /v1/webhook`) saadaksesi allekirjoitussalaisuuden tai todenna työkalukutsut
   omalla otsakkeellasi `endpoint.headers`-asetusten kautta.

```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)
    ...
```

Expressissä rungon jäsennin jättää `req.body`-arvon tyhjäksi objektiksi `GET`-pyynnössä,
joten välitä `Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0)`.

Webhook-**tilan** työkalujen välitys (työkalut ilman `endpoint`-asetusta,
jotka toimitetaan organisaatiosi webhookiin tyyppeinä `telephony.tool` /
`web.tool`) on tavallinen allekirjoitettu webhook — yllä oleva vakiomenettely
pätee. Katso molemmat pyyntömuodot kohdasta
[Funktiotyökalut](/fi/tools/overview).

## Vanha allekirjoitus: `X-ThunderPhone-Signature`

Alkuperäinen otsake on pelkän raa'an rungon heksamuotoinen HMAC-SHA256, jossa
käytetään samaa salaisuutta. Siinä ei ole aikaleimaa, joten kuka tahansa yhden
allekirjoitetun pyynnön kaapannut voi toistaa sen. Se lähetetään edelleen jokaisen pyynnön mukana, kun integraatiot siirtyvät V2:een.

1. Lue **raaka** pyynnön runko — täsmälleen ne tavut, jotka lähetimme sinulle.
2. Laske `hmac_sha256(secret, body).hexdigest()`.
3. Vertaa sitä **vakioaikaisesti** arvoon `X-ThunderPhone-Signature`.

Allekirjoitamme täsmälleen ne tavut, jotka lähetämme. Nämä tavut ovat myös
kuorman **kanoninen JSON-sarjoitus** — avaimet aakkosjärjestyksessä,
tiiviit erottimet (`,` ja `:` ilman välilyöntejä), UTF-8.
Jos kehys tarjoaa vain jäsennetyn JSONin, sarjoita se uudelleen kanonisesti ja
laske sille HMAC:

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

Suosi raakaa runkoa — siinä on yksi vaihe vähemmän, eikä se ole altis joissakin kielissä esiintyville JSON-lukujen edestakaisen muunnoksen erityispiirteille. Sama pätee V2:n runkokenttään.

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

## Yleiset sudenkuopat

<AccordionGroup>
  <Accordion title="Kellon ajan poikkeama">
    Viiden minuutin ikkuna mitataan palvelimesi kellon mukaan. Pidä se
    synkronoituna NTP:n avulla; yli viisi minuuttia väärässä oleva kello hylkää jokaisen
    pyynnön.
  </Accordion>

  <Accordion title="Välityspalvelin muuttaa polun">
    Allekirjoitettu polku on pyytämämme polku: ThunderPhoneen
    määrittämäsi URL-osoitteen polku. Jos käänteinen välityspalvelin tai API-yhdyskäytävä poistaa
    etuliitteen tai lisää sellaisen ennen kuin sovelluksesi näkee pyynnön, muodosta
    lähettämämme polku uudelleen (esimerkiksi lisää etuliite takaisin) ennen vahvistamista.
  </Accordion>

  <Accordion title="Uudelleensarjoittaminen oletusmuotoilulla">
    Rungon jäsentäminen ja uudelleensarjoittaminen JSON-kirjastosi
    oletusasetuksilla (välilyönnit merkkien `,` / `:` jälkeen, lisäysjärjestyksessä olevat avaimet) tuottaa
    erilaiset tavut ja rikkoo HMACin. Tarkista raaka runko — tai jos
    sinun on sarjoitava se uudelleen, vastaa kanonista muotoamme tarkalleen: lajitellut
    avaimet, tiiviit erottimet, UTF-8.
  </Accordion>

  <Accordion title="Kehys jäsentää JSONin automaattisesti">
    Expressin `express.json()`-middleware kuluttaa rungon tietovirran
    ja menetät raakatavut. Käytä `express.raw()`-middlewarea nimenomaan webhook-reitillä
    tai puskuroi raakarunko esiväliohjelmassa.
    Sama koskee NestJS:ää / Koaa — tarkista niiden "raaka runko" -dokumentaatio.
  </Accordion>

  <Accordion title="Ajoitusturvaton vertailu">
    `expected === signature` JS:ssä tai `expected == signature`
    Pythonissa ovat ajoituksesta riippuvia vertailuja. Käytä vastaavasti `crypto.timingSafeEqual`-
    tai `hmac.compare_digest`-funktiota. Suorituskykyero
    on olematon.
  </Accordion>

  <Accordion title="Väärä salaisuus työkalupäätepisteille">
    Suorat työkalupäätepistekutsut allekirjoitetaan **organisaatiotason webhook-salaisuudella**
    (`GET /v1/webhook`) — ei millään päätepistekohtaisella salaisuudella,
    joka on peräisin polusta `/v1/developer/webhook-endpoints`. Käytä samaa apufunktiota uudelleen,
    mutta varmista, että välität sille organisaation salaisuuden työkalureiteillä.
  </Accordion>

  <Accordion title="Kyselymerkkijonot GET/DELETE-työkaluissa">
    V2 allekirjoittaa kyselymerkkijonon täsmälleen lähetetyssä muodossa. Vanha otsake allekirjoittaa
    tyhjän rungon, ei koskaan kyselymerkkijonoa, joten URL-osoitteen tiivistäminen
    vanhaa otsaketta varten ei koskaan täsmää.
  </Accordion>

  <Accordion title="Kaksoiskappaleet ikkunan sisällä">
    Aikaleima estää yli viisi minuuttia vanhat toistot, ei toista kopiota
    tämän ikkunan sisällä. Päätepistetoimitukset tapahtuvat vähintään kerran, joten myös
    saman tapahtuman uudelleenyritys voi saapua; poista kaksoiskappaleet
    kuoren `event_id`:n perusteella.
  </Accordion>

  <Accordion title="401-vastauksen palauttamatta jättäminen, kun allekirjoitus ei täsmää">
    200-vastauksen palauttaminen epäonnistuneen vahvistuksen jälkeen tekee
    käsittelijästä uudelleenlähetyshyökkäyksen kohteen. Palauta aina muu kuin 2xx-vastaus,
    jos vahvistus epäonnistuu.
  </Accordion>
</AccordionGroup>

---

## Seuraavat vaiheet

<CardGroup cols={2}>
  <Card title="Webhookien yleiskatsaus" icon="bolt" href="/fi/webhooks/overview">
    Toimitussemantiikka, uudelleenyritykset, lähde-IP-osoitteet.
  </Card>
  <Card title="Webhook-päätepisteet" icon="plug" href="/fi/webhooks/endpoints">
    Hallitse useita URL-osoitteita, kierrätä salaisuuksia.
  </Card>
  <Card title="Funktiotyökalut" icon="screwdriver-wrench" href="/fi/tools/overview">
    Kaksi työkalukutsun polkua ja niiden pyyntömuodot.
  </Card>
  <Card title="Työkalintegraatiot" icon="wrench" href="/fi/guides/build-tool-integration">
    Rakenna täydellinen työkalutuettu integraatio alusta loppuun.
  </Card>
</CardGroup>
