ThunderPhone 2.0 är här.Kom igång på egen hand, från 2 ¢/min.Läs lanseringsnyheten

Operations

Verifiera webhook-signaturer

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.

HeaderSignerarSkydd mot återuppspelning
X-ThunderPhone-Signature-V2 (rekommenderas)Tidsstämpel, metod, sökväg, frågesträng och brödtextAvvisa begäranden som är äldre än 5 minuter
X-ThunderPhone-SignatureEndast brödtextInget: 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

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:

<t>\n<METHOD>\n<path and query>\n<body>
FältVärde
tt-värdet från rubriken
METHODHTTP-metoden med versaler: POST, GET, PUT, PATCH eller DELETE
sökväg och frågaBegä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
bodyDen 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):

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:

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:

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

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

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

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.

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) 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ällaHemlighet
Webhook-slutpunkt (/v1/developer/webhook-endpoints)secret per slutpunkt (48 hexadecimala tecken) returneras en gång vid skapande
Äldre webhook med en enda URLsecret per organisation returneras vid GET /v1/webhook
Anrop till verktygsslutpunkt (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 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.
@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 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:

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

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

Vanliga fallgropar

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.

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.

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.

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

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.

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.

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.

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.

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.


Nästa steg