---
title: "Verifiera webhook-signaturer"
description: "Varje webhook- och verktygsbegäran som ThunderPhone skickar signeras med en tidsstämpel. Verifiera den med receptet eller SDK-hjälpfunktionen här och avvisa upprepade begäranden som är äldre än fem minuter."
---

Varje begäran vi skickar till din server (webhook-leveranser, den äldre
webhooken med en enda URL och anrop till verktygsändpunkter) innehåller två
signaturhuvuden. Båda använder HMAC-SHA256 med samma hemlighet.

| Header | Signerar | Skydd mot återuppspelning |
|--------|-------|-------------------|
| `X-ThunderPhone-Signature-V2` (rekommenderas) | Tidsstämpel, metod, sökväg, frågesträng och brödtext | Avvisa begäranden som är äldre än 5 minuter |
| `X-ThunderPhone-Signature` | Endast brödtext | Inget: en fångad begäran verifieras för alltid |

Verifiera `X-ThunderPhone-Signature-V2` i nya integrationer och flytta
befintliga till den. `X-ThunderPhone-Signature` skickas fortfarande med varje
begäran, oförändrad, så verifierare som bygger på den fortsätter att fungera. Vi
meddelar ett datum innan vi slutar skicka den.

## Signatur V2

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

- `t` är Unix-tiden, i sekunder, när vi skickade begäran. En återförsökt
  leverans signeras igen när den skickas, med ett eget `t`.
- `v1` är den hexadecimala HMAC-SHA256-summan för den signerade strängen nedan,
  beräknad med din hemlighet som nyckel.

Värdet är en kommaseparerad lista med `key=value`-par. Ignorera nycklar som du
inte känner igen och godkänn begäran om någon `v1` matchar: vi kan lägga till
en andra signatur vid en framtida ändring.

### Den signerade strängen

Fyra fält, vart och ett följt av en nyrad (`\n`, byte `0x0A`) utom det
sista:

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

| Fält | Värde |
|-------|-------|
| `t` | `t`-värdet från rubriken |
| `METHOD` | HTTP-metoden med versaler: `POST`, `GET`, `PUT`, `PATCH` eller `DELETE` |
| sökväg och fråga | Begärandemålet från HTTP-begäranderaden: sökvägen, följt av `?` och frågesträngen om en finns. Inget schema, värdnamn eller fragment. Använd det exakt som det kom, utan att avkoda det eller ändra ordningen på parametrarna |
| `body` | Den råa begärandetexten. Verktygsanrop med `GET` och `DELETE` har ingen brödtext, så deras signerade sträng avslutas med nyraden efter sökvägen |

Så här verifierar du:

1. Läs rubriken, metoden, den råa sökvägen och frågan samt den råa
   begärandetexten innan någon tolkning sker.
2. Hämta `t` och varje `v1` från rubriken.
3. Avvisa begäran om `t` är mer än **300 sekunder** före eller efter
   din klocka.
4. Beräkna den hexadecimala HMAC-SHA256-summan för den signerade strängen med din hemlighet.
5. Jämför den med `v1` i konstant tid. Svara med `401` om inget matchar.

### Genomarbetat exempel

Hemlighet `whsec_example_4c1f2a9b`. Vi skickar denna begäran vid
`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"}
```

Den signerade strängen är dessa fyra rader, utan nyrad efter brödtexten:

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

Dess HMAC-SHA256-summa är
`03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e`.
Kontrollera din implementering mot den, eller återskapa den med `openssl`:

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

Ett `GET`-verktygsanrop till `/tools/lookup?customer_id=cust_123` vid samma
sekund har en tom brödtext. Dess signerade sträng är
`1767225600\nGET\n/tools/lookup?customer_id=cust_123\n` och dess signatur
är `3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280`.

### Med SDK:erna

[Python- och TypeScript-SDK:erna](/api-reference/sdks) innehåller en hjälpfunktion
(`thunderphone` 0.2.0 eller senare, `@thunderphone/sdk` 0.2.0 eller senare). Den
kontrollerar 5-minutersfönstret, jämför i konstant tid och accepterar en lista
med hemligheter så att du kan rotera utan driftstopp.

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

### Utan ett 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>

### Integrering i ramverk

Varje exempel läser den råa sökvägen och frågan samt den råa brödtexten och anropar sedan SDK-hjälpfunktionen.

<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) samt ASGI:s `raw_path` och `query_string`
(FastAPI) är målet exakt så som det togs emot. Djangos
`get_full_path()` och Flasks `request.path` avkodar sökvägen först, vilket
ger samma sträng för vanliga sökvägar. Om sökvägen till din slutpunkt innehåller
procentkodade eller reserverade tecken som `%2F`, `;` eller `=`, läser du i stället
det råa målet som servern tillhandahåller (till exempel `request.META["RAW_URI"]` i
Gunicorn).

## Vilken hemlighet?

Samma hemlighet signerar båda rubrikerna.

| Källa | Hemlighet |
|--------|--------|
| [Webhook-slutpunkt](/sv/webhooks/endpoints) (`/v1/developer/webhook-endpoints`) | `secret` per slutpunkt (48 hexadecimala tecken) returneras en gång vid skapande |
| [Äldre webhook med en enda URL](/api-reference/organizations#legacy-single-url-webhook) | `secret` per organisation returneras vid `GET /v1/webhook` |
| [Anrop till verktygsslutpunkt](/sv/tools/overview) (direktanrop till din `endpoint.url`) | **Webhook-hemligheten på organisationsnivå** (samma som för den äldre webhooken med en enda URL) — inte en hemlighet per slutpunkt |

Spara hemligheten i din hemlighetshanterare eller miljövariabel — comitta den aldrig.

## Verifiera verktygsanrop

När röstagenten anropar ett av dina
[funktionsverktyg](/sv/tools/overview) direkt (verktyget har en
`endpoint`) innehåller begäran följande ThunderPhone-rubriker utöver
din konfigurerade `endpoint.headers`:

- `X-ThunderPhone-Call-ID` — det numeriska id:t för det pågående samtalet.
- `X-ThunderPhone-Signature-V2` — den tidsstämplade signaturen ovan, signerad
  med din **webhookhemlighet på organisationsnivå**.
- `X-ThunderPhone-Signature` — den äldre signaturen som endast omfattar begärandetexten, med samma hemlighet.

Två saker skiljer sig från webhooks:

1. **`GET`- och `DELETE`-verktyg har ingen begärandetext.** Argument skickas som frågeparametrar. V2-signaturen omfattar dem via sökvägen och frågan, och fältet för begärandetext är tomt. Verifiera med den råa frågesträngen: att tolka parametrarna och bygga en ny frågesträng kan ändra deras ordning eller kodning. Den äldre rubriken signerar den tomma bytesträngen.
2. **Organisationer utan en konfigurerad äldre webhook har ingen organisationshemlighet.** I så fall innehåller verktygsanrop endast `X-ThunderPhone-Call-ID` och inga signaturrubriker. Konfigurera den äldre webhooken
   (`PUT /v1/webhook`) för att få en signeringshemlighet, eller autentisera verktygsanrop med din egen rubrik 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)
    ...
```

I Express lämnar en body-parser `req.body` som ett tomt objekt vid en `GET`,
så skicka `Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0)`.

Webhook-**läge** för verktygsdistribution (verktyg utan en `endpoint`, som
levereras till din organisationswebhook som `telephony.tool` / `web.tool`) är
en vanlig signerad webhook — standardreceptet ovan gäller. Se
[Funktionsverktyg](/sv/tools/overview) för båda begärandeformaten.

## Äldre signatur: `X-ThunderPhone-Signature`

Det ursprungliga huvudet är hexkodad HMAC-SHA256 av enbart den råa begärandetexten, med
samma hemlighet. Det innehåller ingen tidsstämpel, så vem som helst som fångar en
signerad begäran kan spela upp den igen. Det skickas fortfarande med varje begäran medan
integrationer flyttas till V2.

1. Läs den **råa** begärandetexten — de exakta byte vi skickade till dig.
2. Beräkna `hmac_sha256(secret, body).hexdigest()`.
3. Jämför i **konstant tid** med `X-ThunderPhone-Signature`.

Vi signerar exakt de byte vi överför. Dessa byte är också payloadens
**kanoniska JSON-serialisering** — nycklar sorterade alfabetiskt, kompakta avgränsare
(`,` och `:` utan mellanslag), UTF-8. Om ditt ramverk bara exponerar parsad JSON ska du
serialisera om den kanoniskt och beräkna HMAC på den:

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

Föredra den råa begärandetexten — det är ett steg mindre och skyddat mot egenheter vid
tur-och-retur-konvertering av JSON-tal i vissa språk. Detsamma gäller textfältet i 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>

## Vanliga fallgropar

<AccordionGroup>
  <Accordion title="Klockavvikelse">
    Fönstret på 5 minuter mäts mot serverns klocka. Håll den
    synkroniserad med NTP; en klocka som avviker med mer än 5 minuter avvisar alla
    begäranden.
  </Accordion>

  <Accordion title="En proxy skriver om sökvägen">
    Den signerade sökvägen är den vi begärde: sökvägen i den URL som du
    konfigurerade i ThunderPhone. Om en omvänd proxy eller API-gateway tar bort
    eller lägger till ett prefix innan din applikation ser begäran, återskapa
    sökvägen vi skickade (lägg till exempel tillbaka prefixet) före verifieringen.
  </Accordion>

  <Accordion title="Omserialisering med standardformatering">
    Att parsa innehållet och sedan serialisera det igen med JSON-bibliotekets
    standardinställningar (mellanslag efter `,` / `:`, nycklar i insättningsordning) ger
    andra byte och bryter HMAC. Verifiera det råa innehållet — eller, om
    du måste omserialisera, matcha vår kanoniska form exakt: sorterade
    nycklar, kompakta avgränsare, UTF-8.
  </Accordion>

  <Accordion title="Ramverket parsar JSON automatiskt">
    Express-mellanprogrammet `express.json()` förbrukar brödtextströmmen
    och du förlorar de råa byten. Använd `express.raw()` specifikt på webhook-routen,
    eller buffra den råa brödtexten i ett för-mellanprogram.
    Samma sak gäller NestJS / Koa — läs deras dokumentation om "raw body".
  </Accordion>

  <Accordion title="Tidsosäker jämförelse">
    `expected === signature` i JS eller `expected == signature` i
    Python är jämförelser med varierande körtid. Använd `crypto.timingSafeEqual`
    respektive `hmac.compare_digest`. Prestandaskillnaden
    är obefintlig.
  </Accordion>

  <Accordion title="Fel hemlighet för verktygsslutpunkter">
    Direkta anrop till verktygsslutpunkter signeras med den **organisationsövergripande webhook-
    hemligheten** (`GET /v1/webhook`) — inte med någon hemlighet per slutpunkt
    från `/v1/developer/webhook-endpoints`. Återanvänd samma hjälpfunktion,
    men se till att du skickar in organisationens hemlighet på verktygsrutter.
  </Accordion>

  <Accordion title="Frågesträngar för GET/DELETE-verktyg">
    V2 signerar frågesträngen exakt som den skickades. Den äldre HTTP-rubriken signerar
    det tomma innehållet och aldrig frågesträngen, så att hasha URL:en för
    den äldre HTTP-rubriken kommer aldrig att matcha.
  </Accordion>

  <Accordion title="Dubbletter inom fönstret">
    Tidsstämpeln stoppar upprepade begäranden som är äldre än 5 minuter, inte en andra kopia
    inom det fönstret. Leveranser till slutpunkter sker minst en gång, så ett
    nytt försök för samma händelse kan också komma; ta bort dubbletter baserat på
    kuvertets `event_id`.
  </Accordion>

  <Accordion title="Returnerar inte 401 vid avvikelse">
    Att returnera 200 vid misslyckad verifiering gör hanteraren till ett mål
    för replay-attacker. Svara alltid med en statuskod utanför 2xx om verifieringen misslyckas.
  </Accordion>
</AccordionGroup>

---

## Nästa steg

<CardGroup cols={2}>
  <Card title="Översikt över webhooks" icon="bolt" href="/sv/webhooks/overview">
    Leveranssemantik, återförsök, käll-IP-adresser.
  </Card>
  <Card title="Webhook-slutpunkter" icon="plug" href="/sv/webhooks/endpoints">
    Hantera flera URL:er, rotera hemligheter.
  </Card>
  <Card title="Funktionsverktyg" icon="screwdriver-wrench" href="/sv/tools/overview">
    De två vägarna för verktygsanrop och deras begärandeformat.
  </Card>
  <Card title="Verktygsintegrationer" icon="wrench" href="/sv/guides/build-tool-integration">
    Bygg en komplett verktygsbaserad integration från början till slut.
  </Card>
</CardGroup>
