ThunderPhone 2.0 er lansert.Kom i gang selv – fra 2¢/min.Les mer om lanseringen

Operations

Verifiser webhook-signaturer

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.

HeaderSignererBeskyttelse mot replay-angrep
X-ThunderPhone-Signature-V2 (anbefalt)Tidsstempel, metode, sti, spørring og brødtekstAvvis forespørsler som er mer enn 5 minutter gamle
X-ThunderPhone-SignatureKun brødtekstIngen: 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

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:

<t>\n<METHOD>\n<path and query>\n<body>
FeltVerdi
tt-verdien fra overskriften
METHODHTTP-metoden med store bokstaver: POST, GET, PUT, PATCH eller DELETE
sti og spørringForespø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
bodyDen 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):

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:

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:

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

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
)
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
});

Uten et SDK

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)
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)
    );
  });
}

Rammeverksintegrasjon

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

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}
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);
  },
);
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})

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.

KildeHemmelighet
Webhook-endepunkt (/v1/developer/webhook-endpoints)secret per endepunkt (48 heksadesimale tegn) som returneres én gang ved opprettelse
Eldre webhook med én URLsecret per organisasjon som returneres ved GET /v1/webhook
Kall til verktøyendepunkt (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 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.
@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 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:

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

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 "")
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
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
require "openssl"
 
def verify(body, signature, secret)
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
  Rack::Utils.secure_compare(expected, signature.to_s)
end

Vanlige fallgruver

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.

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.

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.

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

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.

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.

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.

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.

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.


Neste steg