ThunderPhone 2.0 je tu.Začnite sami, že od 2 ¢/min.Preberite obvestilo

Operations

Preverjanje podpisov webhookov

Vsak webhook in zahteva orodja, ki ju ThunderPhone pošlje, sta podpisana s časovnim žigom. Preverite ju s tukajšnjim postopkom ali pomočnikom SDK ter zavrnite ponovljene zahteve, starejše od petih minut.

Vsaka zahteva, ki jo pošljemo na vaš strežnik (dostave webhookov, podedovani webhook z enim URL-jem in klici končnih točk orodij), vsebuje dve glavi podpisov. Obe uporabljata HMAC-SHA256 z isto skrivnostjo.

GlavaPodpisujeZaščita pred ponovitvijo
X-ThunderPhone-Signature-V2 (priporočeno)Časovni žig, metodo, pot, poizvedbo in teloZavrnite zahteve, starejše od 5 minut
X-ThunderPhone-SignatureSamo teloBrez: prestrežena zahteva se preverja za vedno

V novih integracijah preverjajte X-ThunderPhone-Signature-V2 in nanj preselite obstoječe integracije. X-ThunderPhone-Signature se še vedno pošilja z vsako zahtevo nespremenjen, zato preverjevalniki, zgrajeni na njem, še naprej delujejo. Preden ga prenehamo pošiljati, bomo objavili datum.

Podpis V2

X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
  • t je čas Unix v sekundah, ko smo poslali zahtevo. Ponovno poslana dostava se ob pošiljanju znova podpiše z lastnim t.
  • v1 je šestnajstiški HMAC-SHA256 spodnjega podpisanega niza, ustvarjen z vašim skrivnim ključem.

Vrednost je z vejicami ločen seznam parov key=value. Prezrite ključe, ki jih ne prepoznate, in sprejmite zahtevo, če se ujema kateri koli v1: med prihodnjo spremembo lahko dodamo drugi podpis.

Podpisani niz

Štiri polja, vsakemu sledi ena nova vrstica (\n, bajt 0x0A), razen zadnjemu:

<t>\n<METHOD>\n<path and query>\n<body>
PoljeVrednost
tVrednost t iz glave
METHODMetoda HTTP z velikimi črkami: POST, GET, PUT, PATCH ali DELETE
pot in poizvedbaCilj zahteve iz vrstice zahteve HTTP: pot, nato ? in niz poizvedbe, kadar obstaja. Brez sheme, gostitelja ali fragmenta. Uporabite ga natanko tako, kot je prispel, brez dekodiranja ali spreminjanja vrstnega reda parametrov
bodyNeobdelano telo zahteve. Klici orodij GET in DELETE nimajo telesa, zato se njihov podpisani niz konča z novo vrstico za potjo

Za preverjanje:

  1. Pred kakršnim koli razčlenjevanjem preberite glavo, metodo, neobdelano pot in poizvedbo ter neobdelano telo.
  2. Iz glave pridobite t in vsak v1.
  3. Zahtevo zavrnite, če je t več kot 300 sekund pred ali po vašem času.
  4. S svojim skrivnim ključem izračunajte šestnajstiški HMAC-SHA256 podpisanega niza.
  5. Primerjajte ga z v1 v konstantnem času. Če se nič ne ujema, odgovorite z 401.

Primer

Skrivni ključ whsec_example_4c1f2a9b. To zahtevo pošljemo ob 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"}

Podpisani niz so te štiri vrstice brez nove vrstice za telesom:

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

Njegov HMAC-SHA256 je 03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e. Preverite svojo implementacijo s tem ali ga ustvarite z openssl:

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

Klic orodja GET na /tools/lookup?customer_id=cust_123 v isti sekundi ima prazno telo. Njegov podpisani niz je 1767225600\nGET\n/tools/lookup?customer_id=cust_123\n, njegov podpis pa je 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.

S kompleti SDK

Kompleta SDK za Python in TypeScript vključujeta pomočnika (thunderphone 0.2.0 ali novejši, @thunderphone/sdk 0.2.0 ali novejši). Preverja 5-minutno okno, primerja v konstantnem času in sprejme seznam skrivnih ključev, da jih lahko zamenjate brez izpada.

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

Brez SDK-ja

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

Povezava z ogrodjem

Vsak primer prebere neobdelano pot in poizvedbo ter neobdelano telo, nato pa pokliče pomožno funkcijo SDK-ja.

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) ter ASGI raw_path in query_string (FastAPI) so ciljni naslov natanko takšni, kot so bili prejeti. Djangov get_full_path() in Flaskov request.path najprej dekodirata pot, kar za običajne poti ustvari enak niz. Če pot vaše končne točke vsebuje znake s kodiranjem odstotkov ali rezervirane znake, kot so %2F, ; ali =, namesto tega preberite neobdelani ciljni naslov, ki ga zagotavlja vaš strežnik (na primer request.META["RAW_URI"] v Gunicornu).

Katera skrivnost?

Obe glavi sta podpisani z isto skrivnostjo.

VirSkrivnost
Končna točka webhooka (/v1/developer/webhook-endpoints)secret za posamezno končno točko (48 šestnajstiških znakov), vrnjen enkrat ob ustvarjanju
Podedovani webhook z enim URL-jemsecret za posamezno organizacijo, vrnjen pri GET /v1/webhook
Klic končne točke orodja (neposredni klic vaše endpoint.url)Skrivnost webhooka na ravni organizacije (ista kot pri podedovanem webhooku z enim URL-jem) — ne skrivnost za posamezno končno točko

Skrivnost shranite v upravitelju skrivnosti ali spremenljivki okolja — nikoli je ne potrdite v repozitorij.

Preverjanje klicev orodij

Ko agent neposredno prikliče eno od vaših funkcijskih orodij (orodje ima endpoint), zahteva poleg vaših konfiguriranih endpoint.headers vključuje te glave ThunderPhone:

  • X-ThunderPhone-Call-ID — številčni ID aktivnega klica.
  • X-ThunderPhone-Signature-V2 — zgornji podpis s časovnim žigom, podpisan z vašo skrivnostjo spletnega kavlja na ravni organizacije.
  • X-ThunderPhone-Signature — zapuščeni podpis samo telesa z istim ključem.

Dve stvari se razlikujeta od spletnih kavljev:

  1. Orodja GET / DELETE nimajo telesa. Argumenti se prenašajo kot parametri poizvedbe. Podpis V2 jih zajema prek poti in poizvedbe, polje telesa pa je prazno. Preverite z neobdelanim nizom poizvedbe: razčlenjevanje parametrov in ustvarjanje novega niza poizvedbe lahko spremeni njihov vrstni red ali kodiranje. Zapuščena glava podpiše prazen niz bajtov.
  2. Organizacije brez konfiguriranega zapuščenega spletnega kavlja nimajo skrivnosti organizacije. V tem primeru klici orodij vsebujejo le X-ThunderPhone-Call-ID in nobene glave podpisa. Konfigurirajte zapuščeni spletni kavelj (PUT /v1/webhook), da pridobite skrivnost za podpisovanje, ali preverite pristnost klicev orodij z lastno glavo prek 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)
    ...

V Expressu razčlenjevalnik telesa pri GET pusti req.body kot prazen objekt, zato podajte Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).

Usmerjanje orodij v načinu webhooka (orodja brez endpoint, dostavljena na webhook vaše organizacije kot telephony.tool / web.tool) je običajen podpisan webhook — uporabite zgornji standardni postopek. Za oba formata zahtevkov glejte Funkcijska orodja.

Podedovani podpis: X-ThunderPhone-Signature

Izvirna glava je šestnajstiški HMAC-SHA256 samo neobdelanega telesa z istim skrivnim ključem. Ne vsebuje časovnega žiga, zato lahko vsakdo, ki prestreže eno podpisano zahtevo, to ponovno predvaja. Med prehodom integracij na V2 se še vedno pošilja ob vsaki zahtevi.

  1. Preberite neobdelano telo zahteve — natančne bajte, ki smo vam jih poslali.
  2. Izračunajte hmac_sha256(secret, body).hexdigest().
  3. Primerjajte v konstantnem času z X-ThunderPhone-Signature.

Podpišemo natančno tiste bajte, ki jih prenesemo. Ti bajti so tudi kanonična serializacija JSON koristnega tovora — ključi so razvrščeni po abecedi, ločila so strnjena (, in : brez presledkov), kodiranje pa je UTF-8. Če vaše ogrodje omogoča le razčlenjeni JSON, ga znova kanonično serializirajte in nad njim izračunajte HMAC:

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

Raje uporabite neobdelano telo — to je en korak manj in je odporno na posebnosti ponovnega pretvarjanja števil JSON v nekaterih jezikih. Enako velja za polje telesa v 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

Pogoste pasti

Urni zamik

5-minutno okno se meri glede na uro vašega strežnika. Ohranite jo sinhronizirano z NTP; ura, ki odstopa za več kot 5 minut, zavrne vsako zahtevo.

Posredniški strežnik prepiše pot

Podpisana pot je tista, ki smo jo zahtevali: pot URL-ja, ki ste ga konfigurirali v ThunderPhone. Če povratni posredniški strežnik ali prehod API odstrani ali doda predpono, preden vaša aplikacija vidi zahtevo, pred preverjanjem znova sestavite pot, ki smo jo poslali (na primer znova dodajte predpono).

Ponovno serializiranje s privzetim oblikovanjem

Razčlenjevanje telesa in njegovo ponovno izpisovanje s privzetimi nastavitvami vaše knjižnice JSON (presledki za , / :, ključi v vrstnem redu vstavljanja) ustvari različne bajte in prekine HMAC. Preverite surovo telo — ali če ga morate znova serializirati, natančno uporabite našo kanonično obliko: razvrščeni ključi, strnjena ločila, UTF-8.

Ogrodje samodejno razčleni JSON

Vmesna programska oprema express.json() v Expressu porabi tok telesa in izgubite neobdelane bajte. Posebej za pot webhooka uporabite express.raw() ali shranite neobdelano telo v predhodni vmesni programski opremi. Enako velja za NestJS / Koa — preverite njihovo dokumentacijo za »raw body«.

Primerjava, nevarna glede časa izvajanja

expected === signature v JS ali expected == signature v Pythonu sta primerjavi s spremenljivim časom izvajanja. Uporabite crypto.timingSafeEqual oziroma hmac.compare_digest. Razlike v zmogljivosti ni.

Napačna skrivnost za končne točke orodij

Neposredni klici končnih točk orodij so podpisani z skrivnostjo webhooka na ravni organizacije (GET /v1/webhook) — ne s katero koli skrivnostjo posamezne končne točke iz /v1/developer/webhook-endpoints. Ponovno uporabite istega pomočnika, vendar poskrbite, da mu na poteh orodij posredujete skrivnost organizacije.

Poizvedbeni nizi pri orodjih GET/DELETE

V2 podpiše poizvedbeni niz natanko tako, kot je bil poslan. Podedovana glava podpiše prazno telo in nikoli poizvedbenega niza, zato se zgoščevanje URL-ja za podedovano glavo nikoli ne bo ujemalo.

Dvojniki znotraj okna

Časovni žig prepreči ponovitve, starejše od 5 minut, ne pa druge kopije znotraj tega okna. Dostave na končne točke so vsaj enkratne, zato lahko prispe tudi ponovni poskus istega dogodka; odstranite podvojitve na podlagi vrednosti event_id ovojnice.

Nevračanje odgovora 401 ob neujemanju

Vračanje 200 ob neuspelem preverjanju spremeni upravljalnik v cilj za ponovitvene napade. Če preverjanje ne uspe, vedno odgovorite s kodo, ki ni 2xx.


Naslednji koraki