---
title: "Verifiser webhook-signaturer"
description: "Hver webhook- og verktøyforespørsel ThunderPhone sender, signeres med et tidsstempel. Verifiser den med oppskriften eller SDK-hjelpefunksjonen her, og avvis gjentatte forespørsler som er eldre enn fem minutter."
---

Hver forespørsel vi sender til serveren din (webhook-leveringer, den eldre
webhooken med én URL og kall til verktøyendepunkter) har to
signaturheadere. Begge er HMAC-SHA256 med samme hemmelighet.

| Header | Signerer | Beskyttelse mot replay-angrep |
|--------|-------|-------------------|
| `X-ThunderPhone-Signature-V2` (anbefalt) | Tidsstempel, metode, sti, spørring og brødtekst | Avvis forespørsler som er mer enn 5 minutter gamle |
| `X-ThunderPhone-Signature` | Kun brødtekst | Ingen: en oppfanget forespørsel valideres for alltid |

Verifiser `X-ThunderPhone-Signature-V2` i nye integrasjoner, og flytt
eksisterende integrasjoner til den. `X-ThunderPhone-Signature` sendes fortsatt med hver
forespørsel, uendret, slik at verifiseringsmekanismer som er bygget på den, fortsatt fungerer. Vi vil
kunngjøre en dato før vi slutter å sende den.

## Signatur V2

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

- `t` er Unix-tiden, i sekunder, da vi sendte forespørselen. En levering som
  prøves på nytt, signeres på nytt når den sendes, med sin egen `t`.
- `v1` er den heksadesimale HMAC-SHA256-en av den signerte strengen nedenfor,
  med hemmeligheten din som nøkkel.

Verdien er en kommaseparert liste over `key=value`-par. Ignorer nøkler du
ikke kjenner igjen, og godta forespørselen hvis en av `v1`-verdiene samsvarer:
vi kan legge til en andre signatur i en fremtidig endring.

### Den signerte strengen

Fire felt, hvert etterfulgt av én linjeskifttegn (`\n`, byte `0x0A`) unntatt
det siste:

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

| Felt | Verdi |
|-------|-------|
| `t` | `t`-verdien fra overskriften |
| `METHOD` | HTTP-metoden med store bokstaver: `POST`, `GET`, `PUT`, `PATCH` eller `DELETE` |
| sti og spørring | Forespørselsmålet fra HTTP-forespørselslinjen: stien, deretter `?` og spørringsstrengen når den finnes. Ingen skjema, vert eller fragment. Bruk den nøyaktig slik den kom, uten å dekode den eller endre rekkefølgen på parametere |
| `body` | Den rå forespørselskroppen. `GET`- og `DELETE`-verktøykall har ingen kropp, så den signerte strengen deres slutter med linjeskiftet etter stien |

Slik verifiserer du:

1. Før all parsing leser du overskriften, metoden, den rå stien og
   spørringen, og den rå kroppen.
2. Hent `t` og alle `v1`-verdier fra overskriften.
3. Avvis forespørselen hvis `t` er mer enn **300 sekunder** før eller etter
   klokken din.
4. Beregn den heksadesimale HMAC-SHA256-en av den signerte strengen med
   hemmeligheten din.
5. Sammenlign den med `v1` i konstant tid. Svar med `401` hvis ingenting samsvarer.

### Gjennomgått eksempel

Hemmelighet `whsec_example_4c1f2a9b`. Vi sender denne forespørselen på
`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 signerte strengen består av disse fire linjene, uten linjeskift etter
kroppen:

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

HMAC-SHA256-en er
`03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e`.
Kontroller implementeringen din mot den, eller gjenskap den med `openssl`:

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

Et `GET`-verktøykall til `/tools/lookup?customer_id=cust_123` i samme
sekund har en tom kropp. Den signerte strengen er
`1767225600\nGET\n/tools/lookup?customer_id=cust_123\n`, og signaturen er
`3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280`.

### Med SDK-ene

[Python- og TypeScript-SDK-ene](/api-reference/sdks) inkluderer en hjelpefunksjon
(`thunderphone` 0.2.0 eller nyere, `@thunderphone/sdk` 0.2.0 eller nyere). Den
kontrollerer 5-minuttersvinduet, sammenligner i konstant tid og godtar en liste
over hemmeligheter, slik at du kan rotere dem uten nedetid.

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

### Uten et 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>

### Rammeverksintegrasjon

Hvert eksempel leser den rå stien og spørringen og den rå brødteksten, og kaller deretter
SDK-hjelperen.

<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) og ASGI `raw_path` og `query_string`
(FastAPI) er målverdien nøyaktig slik den ble mottatt. Djangos
`get_full_path()` og Flasks `request.path` dekoder stien først, noe som
gir samme streng for vanlige stier. Hvis endepunktstien din inneholder
prosentkodede eller reserverte tegn som `%2F`, `;` eller `=`, må du lese
det rå målet serveren din tilbyr i stedet (for eksempel `request.META["RAW_URI"]` på
Gunicorn).

## Hvilken hemmelighet?

Det samme hemmelige nøkkelen signerer begge overskriftene.

| Kilde | Hemmelighet |
|--------|--------|
| [Webhook-endepunkt](/nb/webhooks/endpoints) (`/v1/developer/webhook-endpoints`) | `secret` per endepunkt (48 heksadesimale tegn) som returneres én gang ved opprettelse |
| [Eldre webhook med én URL](/api-reference/organizations#legacy-single-url-webhook) | `secret` per organisasjon som returneres ved `GET /v1/webhook` |
| [Kall til verktøyendepunkt](/nb/tools/overview) (direkte kall til din `endpoint.url`) | **Webhook-hemmeligheten på organisasjonsnivå** (den samme som for den eldre webhooken med én URL) — ikke en hemmelighet per endepunkt |

Lagre hemmeligheten i hemmelighetshåndtereren din eller i en miljøvariabel — aldri legg den inn i commit.

## Verifisering av verktøykall

Når agenten påkaller ett av dine
[funksjonsverktøy](/nb/tools/overview) direkte (verktøyet har et
`endpoint`), inneholder forespørselen disse ThunderPhone-headerne sammen med
dine konfigurerte `endpoint.headers`:

- `X-ThunderPhone-Call-ID` — den numeriske ID-en til den aktive samtalen.
- `X-ThunderPhone-Signature-V2` — den tidsstemplede signaturen ovenfor, med
  din **webhook-hemmelighet på organisasjonsnivå** som nøkkel.
- `X-ThunderPhone-Signature` — den eldre signaturen som kun dekker forespørselskroppen, med samme nøkkel.

To ting skiller seg fra webhooks:

1. **`GET`- / `DELETE`-verktøy har ingen forespørselskropp.** Argumenter sendes som
   spørringsparametere. V2-signaturen dekker dem via stien og spørringen,
   og kroppsfeltet er tomt. Verifiser med den rå spørringsstrengen: å tolke
   parameterne og bygge en ny spørringsstreng kan endre rekkefølgen
   eller kodingen. Den eldre headeren signerer den tomme bytestrengen.
2. **Organisasjoner uten en konfigurert eldre webhook har ingen organisasjonshemmelighet.** I
   så fall inneholder verktøykall bare `X-ThunderPhone-Call-ID` og ingen
   signaturheadere. Konfigurer den eldre webhooken
   (`PUT /v1/webhook`) for å få en signeringshemmelighet, eller autentiser verktøy-
   kall med din egen header 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 lar en parser for forespørselskroppen `req.body` være et tomt objekt ved en `GET`,
så send `Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0)`.

Verktøydistribusjon i webhook-**modus** (verktøy uten et `endpoint`, levert
til organisasjonens webhook som `telephony.tool` / `web.tool`) er en vanlig
signert webhook — standardoppskriften ovenfor gjelder. Se
[Funksjonsverktøy](/nb/tools/overview) for begge forespørselsformatene.

## Eldre signatur: `X-ThunderPhone-Signature`

Den opprinnelige headeren er hex-kodet HMAC-SHA256 av kun den rå kroppen, med
samme hemmelighet. Den inneholder ikke noe tidsstempel, så alle som fanger opp én
signert forespørsel, kan spille den av på nytt. Den sendes fortsatt med hver forespørsel mens
integrasjoner flyttes til V2.

1. Les den **rå** forespørselskroppen — de nøyaktige bytene vi sendte deg.
2. Beregn `hmac_sha256(secret, body).hexdigest()`.
3. Sammenlign i **konstant tid** med `X-ThunderPhone-Signature`.

Vi signerer nøyaktig de bytene vi overfører. Disse bytene er også
payloadens **kanoniske JSON-serialisering** — nøkler sortert
alfabetisk, kompakte skilletegn (`,` og `:` uten mellomrom), UTF-8.
Hvis rammeverket ditt bare eksponerer tolket JSON, serialiser på nytt kanonisk og
beregn HMAC av den:

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

Foretrekk den rå kroppen — det er ett trinn mindre og unngår særheter ved
tur-retur-konvertering av JSON-tall i enkelte språk. Det samme gjelder kroppsfeltet 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>

## Vanlige fallgruver

<AccordionGroup>
  <Accordion title="Klokkeavvik">
    5-minuttersvinduet måles mot serverens klokke. Hold den
    synkronisert med NTP; en klokke som avviker med mer enn 5 minutter,
    avviser alle forespørsler.
  </Accordion>

  <Accordion title="En proxy skriver om stien">
    Den signerte stien er den vi ba om: stien til URL-en du
    konfigurerte i ThunderPhone. Hvis en omvendt proxy eller API-gateway fjerner
    eller legger til et prefiks før applikasjonen din ser forespørselen, må du rekonstruere
    stien vi sendte (for eksempel ved å legge tilbake prefikset) før du verifiserer.
  </Accordion>

  <Accordion title="Re-serialisering med standardformatering">
    Å parse brødteksten og serialisere den på nytt med standardinnstillingene i JSON-biblioteket ditt
    (mellomrom etter `,` / `:`, nøkler i innsettingsrekkefølge) gir
    andre byte og ødelegger HMAC-en. Verifiser den rå brødteksten — eller hvis
    du må re-serialisere, match vår kanoniske form nøyaktig: sorterte
    nøkler, kompakte skilletegn, UTF-8.
  </Accordion>

  <Accordion title="Rammeverket tolker JSON automatisk">
    Express-mellomvaren `express.json()` bruker opp kroppstrømmen,
    og du mister de rå bytene. Bruk `express.raw()` spesifikt på
    webhook-ruten, eller bufre den rå kroppen i en forhåndsmellomvare.
    Det samme gjelder NestJS / Koa — sjekk dokumentasjonen deres for "rå kropp".
  </Accordion>

  <Accordion title="Sammenligning som ikke er timingsikker">
    `expected === signature` i JS eller `expected == signature` i
    Python er sammenligninger med variabel timing. Bruk henholdsvis `crypto.timingSafeEqual`
    eller `hmac.compare_digest`. Ytelsesforskjellen
    er lik null.
  </Accordion>

  <Accordion title="Feil hemmelighet for verktøyendepunkter">
    Direkte kall til verktøyendepunkter signeres med **webhookhemmeligheten på
    organisasjonsnivå** (`GET /v1/webhook`) — ikke med en hemmelighet per endepunkt
    fra `/v1/developer/webhook-endpoints`. Gjenbruk den samme hjelperen,
    men sørg for at du sender inn organisasjonshemmeligheten på verktøyruter.
  </Accordion>

  <Accordion title="Spørringsstrenger på GET/DELETE-verktøy">
    V2 signerer spørringsstrengen nøyaktig slik den ble sendt. Den gamle headeren signerer
    den tomme brødteksten og aldri spørringsstrengen, så hashing av URL-en for
    den gamle headeren vil aldri samsvare.
  </Accordion>

  <Accordion title="Duplikater innenfor tidsvinduet">
    Tidsstempelet stopper gjentakelser eldre enn 5 minutter, ikke en ny kopi
    innenfor det vinduet. Leveringer til endepunkter skjer minst én gang, så et
    nytt forsøk på samme hendelse kan også komme; dedupliser på
    konvoluttens `event_id`.
  </Accordion>

  <Accordion title="Returnerer ikke 401 ved manglende samsvar">
    Å returnere 200 ved mislykket verifisering gjør behandleren til et mål
    for replay-angrep. Svar alltid med ikke-2xx hvis verifiseringen mislykkes.
  </Accordion>
</AccordionGroup>

---

## Neste steg

<CardGroup cols={2}>
  <Card title="Oversikt over webhooks" icon="bolt" href="/nb/webhooks/overview">
    Leveringssemantikk, nye forsøk, kilde-IP-er.
  </Card>
  <Card title="Webhook-endepunkter" icon="plug" href="/nb/webhooks/endpoints">
    Administrer flere URL-er, roter hemmeligheter.
  </Card>
  <Card title="Funksjonsverktøy" icon="screwdriver-wrench" href="/nb/tools/overview">
    De to banene for verktøykall og forespørselsformatene deres.
  </Card>
  <Card title="Verktøyintegrasjoner" icon="wrench" href="/nb/guides/build-tool-integration">
    Bygg en komplett verktøystøttet integrasjon fra start til slutt.
  </Card>
</CardGroup>
