ThunderPhone 2.0 er lanceret.Selvbetjening fra 2 cent/min.Læs mere om lanceringen

Operations

Bekræft webhook-signaturer

Hver webhook- og værktøjsanmodning, som ThunderPhone sender, er signeret. Bekræft signaturen én gang med opskriften her, og genbrug derefter den samme kontrol på alle de slutpunkter, du kører.

Alle anmodninger, vi sender til din server — webhook-leveringer og kald af værktøjsendepunkter — indeholder en HMAC-SHA256-signatur i headeren X-ThunderPhone-Signature. Få verificeringen rigtig én gang, og brug den samme hjælpefunktion i alle handlere.

Algoritmen

  1. Læs den anmodningsbody — de præcise bytes, vi POSTede til dig.
  2. Beregn hmac_sha256(secret, body).hexdigest().
  3. Sammenlign i konstant tid med X-ThunderPhone-Signature. (En naiv strengsammenligning afslører tidsoplysninger.)

Vi signerer præcis de bytes, vi transmitterer, så verificering af den rå body virker altid. Disse bytes er også payloadets kanoniske JSON-serialisering — nøgler sorteret alfabetisk, kompakte separatorer (, og : uden mellemrum), UTF-8. Det giver dig en anden, fuldt ækvivalent metode, når dit framework kun eksponerer parset JSON: serialisér kanonisk igen, og beregn HMAC for 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 upåvirket af særheder ved JSON-tal, der rundturseraliseres i nogle sprog.

Hvilken hemmelighed?

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.

Referenceimplementeringer

Alle fire verificerer den rå anmodningsbody:

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

Frameworkspecifik tilslutning

FastAPI
from fastapi import FastAPI, HTTPException, Request
 
app = FastAPI()
 
@app.post("/thunderphone-webhook")
async def hook(request: Request):
    body = await request.body()           # raw bytes, NOT request.json()
    sig = request.headers.get("X-ThunderPhone-Signature", "")
    if not verify(body, sig, SECRET):
        raise HTTPException(status_code=401)
 
    import json
    event = json.loads(body)
    # … dispatch on event["type"] …
    return {"ok": True}
Express
import express from "express";
 
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 sig = req.header("X-ThunderPhone-Signature") || "";
    if (!verify(req.body, sig, process.env.WEBHOOK_SECRET)) {
      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 JsonResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
 
 
@csrf_exempt
@require_POST
def hook(request):
    body = request.body  # raw bytes
    sig = request.headers.get("X-ThunderPhone-Signature", "")
    if not verify(body, sig, SECRET):
        return HttpResponseForbidden("invalid signature")
    event = json.loads(body)
    # … dispatch on event["type"] …
    return JsonResponse({"ok": True})

Verificering af værktøjskald

Når agenten kalder et af dine funktionsværktøjer direkte (værktøjet har et endpoint), indeholder anmodningen to ThunderPhone-headere sammen med dine konfigurerede endpoint.headers:

  • X-ThunderPhone-Call-ID — det numeriske id for det aktive opkald.
  • X-ThunderPhone-Signature — HMAC-SHA256, med din webhook-hemmelighed på organisationsniveau som nøgle, over de nøjagtige bytes i anmodningens brødtekst.

Den samme verify()-hjælper fungerer uændret med to forskelle:

  1. GET- / DELETE-værktøjer har ingen brødtekst. Argumenter sendes som forespørgselsparametre, og signaturen beregnes over den tomme bytestreng — altså verify(b"", sig, secret) (Python) eller verify(Buffer.alloc(0), sig, secret) (Node). Hash ikke forespørgselsstrengen.
  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-header. 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.post("/tools/search-appointments")
async def tool(request: Request):
    body = await request.body()  # b"" for GET/DELETE tools
    sig = request.headers.get("X-ThunderPhone-Signature", "")
    call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
    if not verify(body, sig, ORG_WEBHOOK_SECRET):
        raise HTTPException(status_code=401)
    args = json.loads(body)
    ...

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.

Almindelige faldgruber

Gen-serialisering med standardformatering

Hvis du parser brødteksten og dumper den igen med JSON-bibliotekets standardindstillinger (mellemrum efter , / :, nøgler i indsættelsesrækkefølge), produceres der andre bytes, og HMAC'en brydes. Verificer den rå brødtekst — eller hvis du skal gen-serialisere, 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 secret for værktøjsendepunkter

Direkte kald til værktøjsendepunkter signeres med webhook-secret på organisationsniveau (GET /v1/webhook) — ikke med et endpoint-specifikt secret fra /v1/developer/webhook-endpoints. Genbrug den samme verify() funktion, men sørg for at give den organisations-secretet på værktøjsruter.

Hashing af query-strengen på GET/DELETE-værktøjer

For værktøjsmetoder uden brødtekst dækker signaturen den tomme byte- streng, så der bevares én universel opskrift: HMAC den rå anmodningsbrødtekst, uanset hvad den er. Hashing af URL'en eller query-strengen vil aldrig matche.

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