ThunderPhone 2.0 er lanceret.Selvbetjening fra 2 cent pr. minut.Læs mere om lanceringen

Operations

Verificer webhook-signaturer

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.

HeaderSignererBeskyttelse mod gentagelsesangreb
X-ThunderPhone-Signature-V2 (anbefalet)Tidsstempel, metode, sti, forespørgsel og brødtekstAfvis anmodninger, der er mere end 5 minutter gamle
X-ThunderPhone-SignatureKun brødtekstIngen: 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

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:

<t>\n<METHOD>\n<path and query>\n<body>
FeltVærdi
tt-værdien fra headeren
METHODHTTP-metoden med store bogstaver: POST, GET, PUT, PATCH eller DELETE
sti og forespørgselAnmodningsmå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
bodyDen 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):

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:

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:

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

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

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

Framework-integration

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

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

KildeHemmelighed
Webhook-endepunkt (/v1/developer/webhook-endpoints)secret pr. endepunkt (48 hextegn), som returneres én gang ved oprettelse
Ældre webhook med enkelt URLsecret pr. organisation, returneret ved GET /v1/webhook
Kald af værktøjsendepunkt (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 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.
@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 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:

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

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

Almindelige faldgruber

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.

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.

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.

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

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.

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.

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.

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.

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.


Næste trin