ThunderPhone 2.0 on nyt julkaistu.Ota käyttöön itse – alkaen 2 ¢/min.Lue lisää julkistuksesta

Operations

Vahvista webhook-allekirjoitukset

Jokainen ThunderPhonen lähettämä webhook- ja työkalupyyntö allekirjoitetaan aikaleimalla. Vahvista se tässä olevalla esimerkillä tai SDK-aputoiminnolla ja hylkää yli viisi minuuttia vanhat uudelleenlähetetyt pyynnöt.

Jokaisessa palvelimellesi lähettämässämme pyynnössä (webhook-toimitukset, vanha yhden URL-osoitteen webhook sekä työkalupäätepisteen kutsut) on kaksi allekirjoitus- otsaketta. Molemmat ovat HMAC-SHA256-allekirjoituksia samalla salaisuudella.

OtsakeAllekirjoittaaUudelleenlähetyssuojaus
X-ThunderPhone-Signature-V2 (suositeltu)Aikaleiman, menetelmän, polun, kyselyn ja pyynnön rungonHylkää yli 5 minuuttia vanhat pyynnöt
X-ThunderPhone-SignatureVain pyynnön rungonEi mitään: kaapattu pyyntö voidaan vahvistaa ikuisesti

Vahvista X-ThunderPhone-Signature-V2 uusissa integraatioissa ja siirrä olemassa olevat integraatiot siihen. X-ThunderPhone-Signature lähetetään edelleen jokaisessa pyynnössä muuttumattomana, joten siihen perustuvat vahvistajat toimivat yhä. Ilmoitamme päivämäärän ennen kuin lopetamme sen lähettämisen.

Allekirjoitus V2

X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
  • t on Unix-aika sekunteina, jolloin lähetimme pyynnön. Uudelleen yritetty toimitus allekirjoitetaan uudelleen lähetyshetkellä omalla t-arvollaan.
  • v1 on alla olevan allekirjoitetun merkkijonon heksamuotoinen HMAC-SHA256, joka käyttää salaista avaintasi.

Arvo on pilkuilla eroteltu luettelo key=value-pareja. Ohita avaimet, joita et tunnista, ja hyväksy pyyntö, jos jokin v1 täsmää: voimme lisätä toisen allekirjoituksen tulevan muutoksen yhteydessä.

Allekirjoitettu merkkijono

Neljä kenttää, joista kutakin viimeistä lukuun ottamatta seuraa yksi rivinvaihto (\n, tavu 0x0A):

<t>\n<METHOD>\n<path and query>\n<body>
KenttäArvo
tOtsakkeen t-arvo
METHODHTTP-metodi isoilla kirjaimilla: POST, GET, PUT, PATCH tai DELETE
path and queryHTTP-pyyntörivillä oleva pyynnön kohde: ensin polku, sitten ? ja kyselymerkkijono, jos sellainen on. Ei skeemaa, isäntää tai fragmenttia. Käytä sitä täsmälleen siinä muodossa kuin se saapui, purkamatta sitä tai järjestämättä parametreja uudelleen
bodyRaaka pyyntörunko. GET- ja DELETE-työkalukutsuilla ei ole runkoa, joten niiden allekirjoitettu merkkijono päättyy polun jälkeiseen rivinvaihtoon

Vahvista näin:

  1. Lue ennen jäsentämistä otsake, metodi, raaka polku ja kysely sekä raaka pyyntörunko.
  2. Ota otsakkeesta t ja kaikki v1-arvot.
  3. Hylkää pyyntö, jos t on yli 300 sekuntia ennen tai jälkeen kellosi aikaa.
  4. Laske allekirjoitetun merkkijonon heksamuotoinen HMAC-SHA256 salaisella avaimellasi.
  5. Vertaa sitä v1-arvoon vakioajassa. Vastaa 401, jos mikään ei täsmää.

Esimerkki

Salainen avain whsec_example_4c1f2a9b. Lähetämme tämän pyynnön hetkellä 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"}

Allekirjoitettu merkkijono koostuu näistä neljästä rivistä ilman rivinvaihtoa rungon jälkeen:

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

Sen HMAC-SHA256 on 03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e. Tarkista toteutuksesi sitä vasten tai toista laskenta komennolla openssl:

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

Saman sekunnin aikana tehtävällä GET-työkalukutsulla osoitteeseen /tools/lookup?customer_id=cust_123 on tyhjä runko. Sen allekirjoitettu merkkijono on 1767225600\nGET\n/tools/lookup?customer_id=cust_123\n ja sen allekirjoitus on 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.

SDK:iden kanssa

Python- ja TypeScript-SDK:t sisältävät apufunktion (thunderphone 0.2.0 tai uudempi, @thunderphone/sdk 0.2.0 tai uudempi). Se tarkistaa 5 minuutin aikarajan, vertaa vakioajassa ja hyväksyy luettelon salaisia avaimia, joten voit kierrättää ne ilman käyttökatkoa.

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

Ilman SDK:ta

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

Kehysintegraatio

Jokainen esimerkki lukee raa'an polun ja kyselymerkkijonon sekä raa'an rungon ja kutsuu sitten SDK-apufunktiota.

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) sekä ASGI:n raw_path ja query_string (FastAPI) ovat allekirjoituksen kohde täsmälleen vastaanotetussa muodossa. Djangon get_full_path() ja Flaskin request.path purkavat polun ensin, mikä tuottaa tavallisille poluille saman merkkijonon. Jos päätepistepolkusi sisältää prosenttikoodattuja tai varattuja merkkejä, kuten %2F, ; tai =, lue sen sijaan palvelimesi tarjoama raaka kohde (esimerkiksi request.META["RAW_URI"] Gunicornissa).

Mikä salaisuus?

Sama salaisuus allekirjoittaa molemmat otsakkeet.

LähdeSalaisuus
Webhook-päätepiste (/v1/developer/webhook-endpoints)Päätepistekohtainen secret (48 heksamerkkiä), joka palautetaan kerran luotaessa
Vanha yhden URL-osoitteen webhookOrganisaatiokohtainen secret, joka palautetaan pyynnössä GET /v1/webhook
Työkalupäätepisteen kutsu (suora kutsu osoitteeseen endpoint.url)Organisaatiotason webhook-salaisuus (sama kuin vanhassa yhden URL-osoitteen webhookissa) — ei päätepistekohtainen salaisuus

Tallenna salaisuus salaisuuksien hallintaan tai ympäristömuuttujaan — älä koskaan commitoi sitä.

Työkalukutsujen varmistaminen

Kun agentti kutsuu yhtä funktiotyökaluistasi suoraan (työkalulla on endpoint), pyyntö sisältää seuraavat ThunderPhone-otsakkeet määrittämiesi endpoint.headers-asetusten lisäksi:

  • X-ThunderPhone-Call-ID — käynnissä olevan puhelun numeerinen tunniste.
  • X-ThunderPhone-Signature-V2 — yllä oleva aikaleimattu allekirjoitus, joka on muodostettu organisaatiotason webhook-salaisuudellasi.
  • X-ThunderPhone-Signature — vanha vain rungon kattava allekirjoitus samalla avaimella.

Kaksi asiaa poikkeaa webhookeista:

  1. GET- / DELETE-työkaluilla ei ole runkoa. Argumentit välitetään kyselyparametreina. V2-allekirjoitus kattaa ne polun ja kyselyn kautta, ja runkokenttä on tyhjä. Vahvista käyttäen raakaa kyselymerkkijonoa: parametrien jäsentäminen ja uuden kyselymerkkijonon muodostaminen voi muuttaa niiden järjestystä tai koodausta. Vanha otsake allekirjoittaa tyhjän tavujonon.
  2. Organisaatioilla, joille ei ole määritetty vanhaa webhookia, ei ole organisaatiosalaisuutta. Tässä tapauksessa työkalukutsut sisältävät vain X-ThunderPhone-Call-ID-otsakkeen eivätkä allekirjoitusotsakkeita. Määritä vanha webhook (PUT /v1/webhook) saadaksesi allekirjoitussalaisuuden tai todenna työkalukutsut omalla otsakkeellasi endpoint.headers-asetusten kautta.
@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)
    ...

Expressissä rungon jäsennin jättää req.body-arvon tyhjäksi objektiksi GET-pyynnössä, joten välitä Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).

Webhook-tilan työkalujen välitys (työkalut ilman endpoint-asetusta, jotka toimitetaan organisaatiosi webhookiin tyyppeinä telephony.tool / web.tool) on tavallinen allekirjoitettu webhook — yllä oleva vakiomenettely pätee. Katso molemmat pyyntömuodot kohdasta Funktiotyökalut.

Vanha allekirjoitus: X-ThunderPhone-Signature

Alkuperäinen otsake on pelkän raa'an rungon heksamuotoinen HMAC-SHA256, jossa käytetään samaa salaisuutta. Siinä ei ole aikaleimaa, joten kuka tahansa yhden allekirjoitetun pyynnön kaapannut voi toistaa sen. Se lähetetään edelleen jokaisen pyynnön mukana, kun integraatiot siirtyvät V2:een.

  1. Lue raaka pyynnön runko — täsmälleen ne tavut, jotka lähetimme sinulle.
  2. Laske hmac_sha256(secret, body).hexdigest().
  3. Vertaa sitä vakioaikaisesti arvoon X-ThunderPhone-Signature.

Allekirjoitamme täsmälleen ne tavut, jotka lähetämme. Nämä tavut ovat myös kuorman kanoninen JSON-sarjoitus — avaimet aakkosjärjestyksessä, tiiviit erottimet (, ja : ilman välilyöntejä), UTF-8. Jos kehys tarjoaa vain jäsennetyn JSONin, sarjoita se uudelleen kanonisesti ja laske sille HMAC:

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

Suosi raakaa runkoa — siinä on yksi vaihe vähemmän, eikä se ole altis joissakin kielissä esiintyville JSON-lukujen edestakaisen muunnoksen erityispiirteille. Sama pätee V2:n runkokenttään.

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

Yleiset sudenkuopat

Kellon ajan poikkeama

Viiden minuutin ikkuna mitataan palvelimesi kellon mukaan. Pidä se synkronoituna NTP:n avulla; yli viisi minuuttia väärässä oleva kello hylkää jokaisen pyynnön.

Välityspalvelin muuttaa polun

Allekirjoitettu polku on pyytämämme polku: ThunderPhoneen määrittämäsi URL-osoitteen polku. Jos käänteinen välityspalvelin tai API-yhdyskäytävä poistaa etuliitteen tai lisää sellaisen ennen kuin sovelluksesi näkee pyynnön, muodosta lähettämämme polku uudelleen (esimerkiksi lisää etuliite takaisin) ennen vahvistamista.

Uudelleensarjoittaminen oletusmuotoilulla

Rungon jäsentäminen ja uudelleensarjoittaminen JSON-kirjastosi oletusasetuksilla (välilyönnit merkkien , / : jälkeen, lisäysjärjestyksessä olevat avaimet) tuottaa erilaiset tavut ja rikkoo HMACin. Tarkista raaka runko — tai jos sinun on sarjoitava se uudelleen, vastaa kanonista muotoamme tarkalleen: lajitellut avaimet, tiiviit erottimet, UTF-8.

Kehys jäsentää JSONin automaattisesti

Expressin express.json()-middleware kuluttaa rungon tietovirran ja menetät raakatavut. Käytä express.raw()-middlewarea nimenomaan webhook-reitillä tai puskuroi raakarunko esiväliohjelmassa. Sama koskee NestJS:ää / Koaa — tarkista niiden "raaka runko" -dokumentaatio.

Ajoitusturvaton vertailu

expected === signature JS:ssä tai expected == signature Pythonissa ovat ajoituksesta riippuvia vertailuja. Käytä vastaavasti crypto.timingSafeEqual- tai hmac.compare_digest-funktiota. Suorituskykyero on olematon.

Väärä salaisuus työkalupäätepisteille

Suorat työkalupäätepistekutsut allekirjoitetaan organisaatiotason webhook-salaisuudella (GET /v1/webhook) — ei millään päätepistekohtaisella salaisuudella, joka on peräisin polusta /v1/developer/webhook-endpoints. Käytä samaa apufunktiota uudelleen, mutta varmista, että välität sille organisaation salaisuuden työkalureiteillä.

Kyselymerkkijonot GET/DELETE-työkaluissa

V2 allekirjoittaa kyselymerkkijonon täsmälleen lähetetyssä muodossa. Vanha otsake allekirjoittaa tyhjän rungon, ei koskaan kyselymerkkijonoa, joten URL-osoitteen tiivistäminen vanhaa otsaketta varten ei koskaan täsmää.

Kaksoiskappaleet ikkunan sisällä

Aikaleima estää yli viisi minuuttia vanhat toistot, ei toista kopiota tämän ikkunan sisällä. Päätepistetoimitukset tapahtuvat vähintään kerran, joten myös saman tapahtuman uudelleenyritys voi saapua; poista kaksoiskappaleet kuoren event_id:n perusteella.

401-vastauksen palauttamatta jättäminen, kun allekirjoitus ei täsmää

200-vastauksen palauttaminen epäonnistuneen vahvistuksen jälkeen tekee käsittelijästä uudelleenlähetyshyökkäyksen kohteen. Palauta aina muu kuin 2xx-vastaus, jos vahvistus epäonnistuu.


Seuraavat vaiheet