---
title: "Verificer webhook-signaturer"
description: "Hver webhook- og værktøjsanmodning, ThunderPhone sender, er signeret med et tidsstempel. Verificer den med opskriften eller SDK-hjælpefunktionen her, og afvis gentagne anmodninger, der er ældre end fem minutter."
---

Hver anmodning, vi sender til din server (webhook-leveringer, det ældre
webhook med én URL og kald til værktøjsendepunkter), indeholder to
signaturheadere. Begge bruger HMAC-SHA256 med den samme hemmelighed.

| Header | Signerer | Beskyttelse mod gentagelsesangreb |
|--------|-------|-------------------|
| `X-ThunderPhone-Signature-V2` (anbefalet) | Tidsstempel, metode, sti, forespørgsel og brødtekst | Afvis anmodninger, der er mere end 5 minutter gamle |
| `X-ThunderPhone-Signature` | Kun brødtekst | Ingen: en opsnappet anmodning verificeres for altid |

Verificer `X-ThunderPhone-Signature-V2` i nye integrationer, og flyt
eksisterende integrationer til den. `X-ThunderPhone-Signature` sendes stadig
med hver anmodning, uændret, så verifikatorer, der er bygget på den, fortsat
virker. Vi annoncerer en dato, før vi holder op med at sende den.

## Signatur V2

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

- `t` er Unix-tidspunktet i sekunder, da vi sendte anmodningen. En genforsøgt
  levering signeres igen, når den sendes, med sit eget `t`.
- `v1` er den hexkodede HMAC-SHA256 for den signerede streng nedenfor, med din
  hemmelighed som nøgle.

Værdien er en kommasepareret liste over `key=value`-par. Ignorer nøgler, du
ikke genkender, og accepter anmodningen, hvis et hvilket som helst `v1` matcher:
Vi kan tilføje en anden signatur ved en fremtidig ændring.

### Den signerede streng

Fire felter, hver efterfulgt af én linjeskiftstegn (`\n`, byte `0x0A`) undtagen
det sidste:

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

| Felt | Værdi |
|-------|-------|
| `t` | `t`-værdien fra headeren |
| `METHOD` | HTTP-metoden med store bogstaver: `POST`, `GET`, `PUT`, `PATCH` eller `DELETE` |
| sti og forespørgsel | Anmodningsmålet fra HTTP-anmodningslinjen: stien, efterfulgt af `?` og forespørgselsstrengen, når der er en. Intet skema, værtsnavn eller fragment. Brug det præcis, som det ankom, uden at afkode det eller ændre rækkefølgen af parametre |
| `body` | Den rå anmodningskrop. `GET`- og `DELETE`-værktøjskald har ingen krop, så deres signerede streng slutter med linjeskiftet efter stien |

Sådan verificerer du:

1. Før enhver parsing skal du læse headeren, metoden, den rå sti og
   forespørgsel samt den rå krop.
2. Hent `t` og alle `v1` fra headeren.
3. Afvis anmodningen, hvis `t` er mere end **300 sekunder** før eller efter
   dit ur.
4. Beregn den hexkodede HMAC-SHA256 for den signerede streng med din hemmelighed.
5. Sammenlign den med `v1` i konstant tid. Svar `401`, hvis intet matcher.

### Gennemarbejdet eksempel

Hemmelighed `whsec_example_4c1f2a9b`. Vi sender denne anmodning 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 signerede streng er disse fire linjer uden linjeskift efter kroppen:

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

Dens HMAC-SHA256 er
`03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e`.
Kontrollér din implementering mod den, eller genskab 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`-værktøjskald til `/tools/lookup?customer_id=cust_123` i samme
sekund har en tom krop. Dens signerede streng er
`1767225600\nGET\n/tools/lookup?customer_id=cust_123\n`, og dens signatur
er `3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280`.

### Med SDK'erne

[Python- og TypeScript-SDK'erne](/api-reference/sdks) indeholder en hjælper
(`thunderphone` 0.2.0 eller nyere, `@thunderphone/sdk` 0.2.0 eller nyere). Den
kontrollerer 5-minuttersvinduet, sammenligner i konstant tid og accepterer en
liste over hemmeligheder, så du kan rotere uden 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>

### Uden en 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>

### Framework-integration

Hvert eksempel læser den rå sti og forespørgselsstreng samt den rå brødtekst og kalder derefter
SDK-hjælpefunktionen.

<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's `raw_path` og `query_string`
(FastAPI) er målet præcis som modtaget. Djangos
`get_full_path()` og Flasks `request.path` afkoder først stien, hvilket
giver den samme streng for almindelige stier. Hvis din endpointsti indeholder
procentkodede eller reserverede tegn som `%2F`, `;` eller `=`, skal du i stedet læse det
rå mål, som din server leverer (for eksempel `request.META["RAW_URI"]` på
Gunicorn).

## Hvilken hemmelighed?

Den samme hemmelighed signerer begge headere.

| Kilde | Hemmelighed |
|--------|--------|
| [Webhook-endepunkt](/da/webhooks/endpoints) (`/v1/developer/webhook-endpoints`) | `secret` pr. endepunkt (48 hextegn), som returneres én gang ved oprettelse |
| [Ældre webhook med enkelt URL](/api-reference/organizations#legacy-single-url-webhook) | `secret` pr. organisation, returneret ved `GET /v1/webhook` |
| [Kald af værktøjsendepunkt](/da/tools/overview) (direkte kald til din `endpoint.url`) | **Webhook-hemmeligheden på organisationsniveau** (den samme som for den ældre webhook med enkelt URL) — ikke en hemmelighed pr. endepunkt |

Gem hemmeligheden i din secret manager eller miljøvariabel — commit den aldrig.

## Verificering af værktøjskald

Når agenten kalder et af dine
[funktionsværktøjer](/da/tools/overview) direkte (værktøjet har et
`endpoint`), indeholder anmodningen disse ThunderPhone-headere sammen med
dine konfigurerede `endpoint.headers`:

- `X-ThunderPhone-Call-ID` — det numeriske id for det aktive opkald.
- `X-ThunderPhone-Signature-V2` — den tidsstemplete signatur ovenfor, signeret
  med din **webhook-hemmelighed på organisationsniveau**.
- `X-ThunderPhone-Signature` — den ældre signatur, der kun dækker brødteksten, med samme nøgle.

To ting adskiller sig fra webhooks:

1. **`GET`- / `DELETE`-værktøjer har ingen brødtekst.** Argumenter sendes som
   forespørgselsparametre. V2-signaturen dækker dem via stien og forespørgslen,
   og brødtekstfeltet er tomt. Bekræft med den rå forespørgselsstreng: Fortolkning
   af parametrene og opbygning af en ny forespørgselsstreng kan ændre deres rækkefølge
   eller kodning. Den ældre header signerer den tomme bytestreng.
2. **Organisationer uden en konfigureret ældre webhook har ingen organisationshemmelighed.** I
   det tilfælde indeholder værktøjskald kun `X-ThunderPhone-Call-ID` og ingen
   signatur-headere. Konfigurer den ældre webhook
   (`PUT /v1/webhook`) for at få en signeringshemmelighed, eller godkend
   værktøjskald 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 efterlader en body-parser `req.body` som et tomt objekt ved en `GET`,
så send `Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0)`.

Værktøjsafsendelse i webhook-**tilstand** (værktøjer uden et `endpoint`, leveret
til din organisationswebhook som `telephony.tool` / `web.tool`) er en almindelig
signeret webhook — standardopskriften ovenfor gælder. Se
[Funktionsværktøjer](/da/tools/overview) for begge anmodningsformer.

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

Den oprindelige header er den hexadecimale HMAC-SHA256 af alene den rå body med
den samme hemmelighed. Den indeholder intet tidsstempel, så enhver, der opfanger én
signeret anmodning, kan genafspille den. Den sendes stadig med hver anmodning, mens
integrationer flytter til V2.

1. Læs den **rå** body for anmodningen — de nøjagtige bytes, vi sendte til dig.
2. Beregn `hmac_sha256(secret, body).hexdigest()`.
3. Sammenlign i **konstant tid** med `X-ThunderPhone-Signature`.

Vi signerer præcis de bytes, vi overfører. Disse bytes er også
payloadets **kanoniske JSON-serialisering** — nøgler sorteret
alfabetisk, kompakte separatorer (`,` og `:` uden mellemrum), UTF-8.
Hvis dit framework kun eksponerer parset JSON, skal du serialisere kanonisk igen og
beregne HMAC over det:

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

Foretræk den rå body — det er ét trin mindre og undgår særegenheder ved
afrunding af JSON-tal ved tur-retur-konvertering i nogle sprog. Det samme gælder body-feltet 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>

## Almindelige faldgruber

<AccordionGroup>
  <Accordion title="Kurforskydning">
    Vinduet på 5 minutter måles i forhold til dit servers ur. Hold det
    synkroniseret med NTP; et ur, der afviger med mere end 5 minutter, afviser alle
    anmodninger.
  </Accordion>

  <Accordion title="En proxy omskriver stien">
    Den signerede sti er den, vi anmodede om: stien i den URL, du
    konfigurerede i ThunderPhone. Hvis en reverse proxy eller API-gateway fjerner
    eller tilføjer et præfiks, før din applikation ser anmodningen, skal du genskabe
    den sti, vi sendte (for eksempel ved at tilføje præfikset igen), før du verificerer.
  </Accordion>

  <Accordion title="Genserialisering med standardformatering">
    Parsing af brødteksten og genudskrivning med dit JSON-biblioteks
    standardindstillinger (mellemrum efter `,` / `:`, nøgler i indsættelsesrækkefølge) giver
    forskellige bytes og ødelægger HMAC'en. Verificer den rå brødtekst — eller hvis
    du skal genserialisere, skal du matche vores kanoniske form nøjagtigt: sorterede
    nøgler, kompakte separatorer, UTF-8.
  </Accordion>

  <Accordion title="Framework parser automatisk JSON">
    Express' `express.json()`-middleware forbruger brødtekststrømmen,
    så du mister de rå bytes. Brug specifikt `express.raw()` på webhook-ruten,
    eller buffer den rå brødtekst i en pre-middleware.
    Det samme gælder NestJS / Koa — se deres dokumentation om "raw body".
  </Accordion>

  <Accordion title="Timing-usikker sammenligning">
    `expected === signature` i JS eller `expected == signature` i
    Python er sammenligninger med variabel timing. Brug henholdsvis `crypto.timingSafeEqual`
    eller `hmac.compare_digest`. Ydelsesforskellen
    er nul.
  </Accordion>

  <Accordion title="Forkert hemmelighed for værktøjsendepunkter">
    Direkte kald til værktøjsendepunkter signeres med **webhookhemmeligheden
    på organisationsniveau** (`GET /v1/webhook`) — ikke med en hemmelighed pr. endepunkt
    fra `/v1/developer/webhook-endpoints`. Genbrug den samme hjælpefunktion,
    men sørg for, at du giver den organisationshemmeligheden på værktøjsruter.
  </Accordion>

  <Accordion title="Forespørgselsstrenge på GET/DELETE-værktøjer">
    V2 signerer forespørgselsstrengen nøjagtigt, som den sendes. Den ældre header signerer
    den tomme brødtekst og aldrig forespørgselsstrengen, så hashing af URL'en for
    den ældre header vil aldrig matche.
  </Accordion>

  <Accordion title="Dubletter inden for tidsvinduet">
    Tidsstemplet stopper genafspilninger, der er ældre end 5 minutter, men ikke en anden kopi
    inden for det vindue. Leveringer til endepunkter sker mindst én gang, så et
    genforsøg af den samme hændelse kan også ankomme; fjern dubletter baseret på
    kuvertens `event_id`.
  </Accordion>

  <Accordion title="Returnerer ikke 401 ved mismatch">
    Hvis du returnerer 200 ved mislykket verificering, bliver handleren et mål
    for replay. Svar altid med en ikke-2xx-status, hvis verificeringen mislykkes.
  </Accordion>
</AccordionGroup>

---

## Næste trin

<CardGroup cols={2}>
  <Card title="Oversigt over webhooks" icon="bolt" href="/da/webhooks/overview">
    Leveringssemantik, genforsøg, kilde-IP'er.
  </Card>
  <Card title="Webhook-endepunkter" icon="plug" href="/da/webhooks/endpoints">
    Administrer flere URL'er, roter secrets.
  </Card>
  <Card title="Funktionsværktøjer" icon="screwdriver-wrench" href="/da/tools/overview">
    De to veje til værktøjskald og deres anmodningsformater.
  </Card>
  <Card title="Værktøjsintegrationer" icon="wrench" href="/da/guides/build-tool-integration">
    Byg en komplet værktøjsunderstøttet integration fra ende til ende.
  </Card>
</CardGroup>
