ThunderPhone 2.0 este acum disponibil.Self-service, de la 2 ¢/min.Citiți anunțul

Operations

Verificați semnăturile webhook

Fiecare cerere webhook și de instrument trimisă de ThunderPhone este semnată cu un marcaj temporal. Verificați-o cu rețeta sau cu ajutorul SDK de aici și respingeți cererile retransmise mai vechi de cinci minute.

Fiecare cerere pe care o trimitem către serverul dumneavoastră (livrări webhook, webhook-ul vechi cu un singur URL și apeluri către endpoint-uri de instrumente) include două antete de semnătură. Ambele folosesc HMAC-SHA256 cu același secret.

AntetSemneazăProtecție împotriva reluării
X-ThunderPhone-Signature-V2 (recomandat)Marcaj temporal, metodă, cale, interogare și corpRespingeți cererile mai vechi de 5 minute
X-ThunderPhone-SignatureDoar corpulNiciuna: o cerere capturată se verifică pentru totdeauna

Verificați X-ThunderPhone-Signature-V2 în integrările noi și migrați integrările existente către acesta. X-ThunderPhone-Signature este încă trimis la fiecare cerere, neschimbat, astfel încât verificatorii bazați pe acesta continuă să funcționeze. Vom anunța o dată înainte de a înceta să îl mai trimitem.

Semnătura V2

X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
  • t este timpul Unix, în secunde, la care am trimis cererea. O livrare reîncercată este semnată din nou când este trimisă, cu propriul t.
  • v1 este HMAC-SHA256 hexazecimal al șirului semnat de mai jos, calculat cu secretul dvs.

Valoarea este o listă de perechi key=value separate prin virgule. Ignorați cheile pe care nu le recunoașteți și acceptați cererea dacă se potrivește orice v1: este posibil să adăugăm o a doua semnătură într-o modificare viitoare.

Șirul semnat

Patru câmpuri, fiecare urmat de un caracter de linie nouă (\n, octetul 0x0A), cu excepția ultimului:

<t>\n<METHOD>\n<path and query>\n<body>
CâmpValoare
tValoarea t din antet
METHODMetoda HTTP cu majuscule: POST, GET, PUT, PATCH sau DELETE
path and queryȚinta cererii din linia cererii HTTP: calea, apoi ? și șirul de interogare, dacă există. Fără schemă, gazdă sau fragment. Folosiți-o exact cum a fost primită, fără a o decoda sau a reordona parametrii
bodyCorpul brut al cererii. Apelurile de instrumente GET și DELETE nu au corp, astfel încât șirul lor semnat se termină cu caracterul de linie nouă de după cale

Pentru verificare:

  1. Înainte de orice analiză, citiți antetul, metoda, calea brută și interogarea, precum și corpul brut.
  2. Preluați t și fiecare v1 din antet.
  3. Respingeți cererea dacă t este cu mai mult de 300 de secunde înainte sau după ceasul dvs.
  4. Calculați HMAC-SHA256 hexazecimal al șirului semnat cu secretul dvs.
  5. Comparați-l cu v1 în timp constant. Răspundeți cu 401 dacă nu se potrivește nimic.

Exemplu complet

Secretul este whsec_example_4c1f2a9b. Trimitem această cerere la 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"}

Șirul semnat este format din aceste patru linii, fără caracter de linie nouă după corp:

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

HMAC-SHA256 al acestuia este 03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e. Verificați implementarea dvs. comparând-o cu acesta sau reproduceți-l cu openssl:

printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
  | openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'

Un apel de instrument GET către /tools/lookup?customer_id=cust_123 în aceeași secundă are corpul gol. Șirul său semnat este 1767225600\nGET\n/tools/lookup?customer_id=cust_123\n, iar semnătura sa este 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.

Cu SDK-urile

SDK-urile Python și TypeScript includ un ajutor (thunderphone 0.2.0 sau o versiune ulterioară, @thunderphone/sdk 0.2.0 sau o versiune ulterioară). Acesta verifică fereastra de 5 minute, compară în timp constant și acceptă o listă de secrete, astfel încât să le puteți roti fără întreruperi.

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

Fără un 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)
    );
  });
}

Conectarea frameworkului

Fiecare exemplu citește calea și interogarea brute, precum și corpul brut, apoi apelează funcția de ajutor din SDK.

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), precum și raw_path și query_string din ASGI (FastAPI) reprezintă ținta exact așa cum a fost primită. get_full_path() din Django și request.path din Flask decodifică mai întâi calea, ceea ce produce același șir pentru căile obișnuite. Dacă calea endpointului dumneavoastră conține caractere codificate procentual sau rezervate, precum %2F, ; sau =, citiți în schimb ținta brută furnizată de serverul dumneavoastră (de exemplu request.META["RAW_URI"] pe Gunicorn).

Ce secret?

Același secret semnează ambele anteturi.

SursăSecret
Endpoint webhook (/v1/developer/webhook-endpoints)secret per endpoint (48 de caractere hexazecimale) returnat o singură dată la creare
Webhook moștenit cu un singur URLsecret per organizație returnat la GET /v1/webhook
Apelare endpoint de instrumente (apel direct către endpoint.url al dumneavoastră)Secretul webhook la nivel de organizație (același ca pentru webhookul moștenit cu un singur URL) — nu un secret per endpoint

Stocați secretul în managerul dumneavoastră de secrete sau într-o variabilă de mediu — nu îl comiteți niciodată.

Verificarea apelurilor de instrumente

Când agentul invocă direct unul dintre instrumentele dumneavoastră de funcție (instrumentul are un endpoint), cererea include aceste antete ThunderPhone pe lângă endpoint.headers configurat:

  • X-ThunderPhone-Call-ID — ID-ul numeric al apelului activ.
  • X-ThunderPhone-Signature-V2 — semnătura cu marcaj temporal de mai sus, asociată cu secretul webhookului la nivel de organizație.
  • X-ThunderPhone-Signature — semnătura veche doar pentru corp, cu aceeași cheie.

Două aspecte diferă față de webhookuri:

  1. Instrumentele GET / DELETE nu au corp. Argumentele sunt transmise ca parametri de interogare. Semnătura V2 le acoperă prin cale și interogare, iar câmpul corpului este gol. Verificați folosind șirul de interogare brut: analizarea parametrilor și construirea unui nou șir de interogare le poate modifica ordinea sau codificarea. Antetul vechi semnează șirul de octeți gol.
  2. Organizațiile fără un webhook vechi configurat nu au un secret de organizație. În acest caz, apelurile instrumentelor includ doar X-ThunderPhone-Call-ID și niciun antet de semnătură. Configurați webhookul vechi (PUT /v1/webhook) pentru a obține un secret de semnare sau autentificați apelurile instrumentelor cu propriul antet prin 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)
    ...

În Express, un parser de corp lasă req.body ca obiect gol pentru un GET, așadar transmiteți Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).

Expedierea instrumentelor în modul webhook (instrumente fără un endpoint, livrate către webhook-ul organizației dumneavoastră ca telephony.tool / web.tool) este un webhook semnat obișnuit — se aplică rețeta standard de mai sus. Consultați Instrumente de funcții pentru ambele forme de solicitare.

Semnătură moștenită: X-ThunderPhone-Signature

Antetul original este HMAC-SHA256 în format hex al corpului brut, folosind același secret. Nu conține niciun marcaj temporal, astfel încât oricine capturează o cerere semnată o poate retransmite. Este trimis în continuare la fiecare cerere cât timp integrările migrează la V2.

  1. Citiți corpul de cerere brut — octeții exacți pe care vi i-am trimis.
  2. Calculați hmac_sha256(secret, body).hexdigest().
  3. Comparați în timp constant cu X-ThunderPhone-Signature.

Semnăm exact octeții pe care îi transmitem. Acești octeți reprezintă și serializarea JSON canonică a încărcăturii utile — chei sortate alfabetic, separatori compacți (, și : fără spații), UTF-8. Dacă frameworkul dumneavoastră expune doar JSON-ul analizat, reserializați canonic și calculați HMAC pentru acesta:

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

Preferați corpul brut — este cu un pas mai puțin și este imun la particularitățile de conversie dus-întors ale numerelor JSON în unele limbaje. Același lucru este valabil pentru câmpul de corp din 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

Capcane frecvente

Decalajul ceasului

Fereastra de 5 minute este măsurată în raport cu ceasul serverului dumneavoastră. Mențineți-l sincronizat cu NTP; un ceas decalibrat cu mai mult de 5 minute respinge fiecare cerere.

Un proxy rescrie calea

Calea semnată este cea pe care am solicitat-o: calea URL-ului pe care l-ați configurat în ThunderPhone. Dacă un proxy invers sau un gateway API elimină sau adaugă un prefix înainte ca aplicația dumneavoastră să vadă cererea, reconstruiți calea pe care am trimis-o (de exemplu, adăugați prefixul înapoi) înainte de verificare.

Reserializarea cu formatarea implicită

Analizarea corpului și serializarea lui din nou folosind setările implicite ale bibliotecii JSON (spații după , / :, chei în ordinea inserării) produce octeți diferiți și invalidează HMAC-ul. Verificați corpul brut — sau, dacă trebuie să îl reserializați, reproduceți exact forma noastră canonică: chei sortate, separatori compacți, UTF-8.

Frameworkul analizează automat JSON-ul

Middleware-ul express.json() din Express consumă fluxul corpului și pierdeți octeții bruți. Utilizați express.raw() în mod specific pe ruta webhookului sau stocați în buffer corpul brut într-un pre-middleware. La fel și pentru NestJS / Koa — consultați documentația lor despre „corpul brut”.

Comparație nesigură din perspectiva timpului

expected === signature în JS sau expected == signature în Python sunt comparații cu durată variabilă. Utilizați crypto.timingSafeEqual sau hmac.compare_digest, respectiv. Diferența de performanță este nulă.

Secret greșit pentru endpointurile instrumentelor

Apelurile directe către endpointurile instrumentelor sunt semnate cu secretul webhook la nivel de organizație (GET /v1/webhook) — nu cu vreun secret per-endpoint din /v1/developer/webhook-endpoints. Reutilizați același ajutor, dar asigurați-vă că îi furnizați secretul organizației pentru rutele instrumentelor.

Șiruri de interogare pentru instrumentele GET/DELETE

V2 semnează șirul de interogare exact așa cum a fost trimis. Antetul vechi semnează corpul gol și niciodată șirul de interogare, astfel încât hash-ul URL-ului pentru antetul vechi nu va corespunde niciodată.

Duplicate în interiorul ferestrei

Marca temporală oprește retransmiterile mai vechi de 5 minute, nu o a doua copie din interiorul acelei ferestre. Livrările către endpointuri sunt efectuate cel puțin o dată, astfel încât poate ajunge și o reîncercare a aceluiași eveniment; eliminați duplicatele folosind event_id din plic.

Nereturnarea codului 401 la nepotrivire

Returnarea codului 200 când verificarea eșuează transformă handlerul într-o țintă pentru reluări. Răspundeți întotdeauna cu un cod care nu este 2xx dacă verificarea eșuează.


Pașii următori