ThunderPhone 2.0 је стигао.Почните самостално, већ од 2 ¢/мин.Прочитајте објаву

Operations

Проверите потписе вебхукова

Сваки захтев вебхука и алатке који ThunderPhone шаље потписан је временском ознаком. Проверите га помоћу овде наведеног примера или SDK помоћне функције и одбијте поновљене захтеве старије од пет минута.

Сваки захтев који шаљемо на Ваш сервер (испоруке веб-хука, застарели веб-хук са једним URL-ом и позиви крајњих тачака алатки) садржи два заглавља потписа. Оба користе HMAC-SHA256 са истом тајном.

ЗаглављеПотписујеЗаштита од поновљеног слања
X-ThunderPhone-Signature-V2 (препоручено)Временска ознака, метод, путања, упит и телоОдбијте захтеве старије од 5 минута
X-ThunderPhone-SignatureСамо телоНема: пресретнути захтев се може верификовати заувек

Верификујте X-ThunderPhone-Signature-V2 у новим интеграцијама и преместите постојеће на њега. X-ThunderPhone-Signature се и даље шаље уз сваки захтев, непромењен, тако да верификатори изграђени на њему настављају да раде. Објавићемо датум пре него што престанемо да га шаљемо.

Потпис V2

X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
  • t је Unix време, у секундама, када смо послали захтев. Поновљена испорука се поново потписује приликом слања, са сопственим t.
  • v1 је хексадецимални HMAC-SHA256 потписаног низа испод, са Вашом тајном као кључем.

Вредност је листа парова key=value раздвојених зарезима. Занемарите кључеве које не препознајете и прихватите захтев ако се било који v1 подудара: можемо додати други потпис током будуће измене.

Потписани низ

Четири поља, од којих свако прати један нови ред (\n, бајт 0x0A), осим последњег:

<t>\n<METHOD>\n<path and query>\n<body>
ПољеВредност
tВредност t из заглавља
METHODHTTP метод великим словима: POST, GET, PUT, PATCH или DELETE
path and queryЦиљ захтева из HTTP линије захтева: путања, затим ? и ниска упита када постоји. Без шеме, хоста или фрагмента. Користите га тачно онако како је примљен, без декодирања или промене редоследа параметара
bodyСирово тело захтева. Позиви алата GET и DELETE немају тело, па се њихов потписани низ завршава новим редом после путање

За проверу:

  1. Пре било каквог рашчлањивања, прочитајте заглавље, метод, сирову путању и упит, као и сирово тело.
  2. Преузмите t и сваки v1 из заглавља.
  3. Одбијте захтев ако је t више од 300 секунди пре или после Вашег сата.
  4. Израчунајте хексадецимални HMAC-SHA256 потписаног низа са Вашом тајном.
  5. Упоредите га са v1 у константном времену. Одговорите са 401 ако се ништа не подудара.

Пример

Тајна whsec_example_4c1f2a9b. Овај захтев шаљемо у 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"}

Потписани низ су ова четири реда, без новог реда после тела:

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

Његов HMAC-SHA256 је 03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e. Проверите своју имплементацију према њему или га репродукујте помоћу openssl:

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

Позив алата GET на /tools/lookup?customer_id=cust_123 у истој секунди има празно тело. Његов потписани низ је 1767225600\nGET\n/tools/lookup?customer_id=cust_123\n, а његов потпис је 3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.

Са SDK-овима

Python и TypeScript SDK-ови укључују помоћну функцију (thunderphone 0.2.0 или новији, @thunderphone/sdk 0.2.0 или новији). Она проверава прозор од 5 минута, пореди у константном времену и прихвата листу тајни како бисте могли да их ротирате без прекида рада.

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

Без 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)
    );
  });
}

Повезивање оквира

Сваки пример чита сирову путању и упит, као и сирово тело, а затим позива 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) и ASGI raw_path и query_string (FastAPI) представљају циљ тачно онако како је примљен. Django-ови get_full_path() и Flask-ов request.path најпре декодирају путању, што даје исти ниска знакова за уобичајене путање. Ако путања вашег крајњег одредишта садржи процентуално кодиране или резервисане знакове као што су %2F, ; или =, уместо тога прочитајте сирови циљ који ваш сервер пружа (на пример request.META["RAW_URI"] на Gunicorn-у).

Која тајна?

Иста тајна потписује оба заглавља.

ИзворТајна
Webhook крајња тачка (/v1/developer/webhook-endpoints)secret за сваку крајњу тачку (48 хексадецималних знакова) који се враћа једном при креирању
Застарели webhook са једним URL-омsecret за сваку организацију који се враћа при GET /v1/webhook
Позив крајње тачке алата (директан позив вашег endpoint.url)Webhook тајна на нивоу организације (иста као за застарели webhook са једним URL-ом) — не тајна за појединачну крајњу тачку

Чувајте тајну у менаџеру тајни или променљивој окружења — никада је не предајте у репозиторијум.

Верификација позива алата

Када гласовни агент директно позове један од ваших функционалних алата (алат има endpoint), захтев садржи ова ThunderPhone заглавља уз ваша конфигурисана endpoint.headers:

  • X-ThunderPhone-Call-ID — нумерички ИД активног позива.
  • X-ThunderPhone-Signature-V2 — горенаведени потпис са временском ознаком, потписан вашом тајном вредношћу веб-куке на нивоу организације.
  • X-ThunderPhone-Signature — застарели потпис само за тело, са истим кључем.

Две ствари се разликују у односу на веб-куке:

  1. GET / DELETE алати немају тело захтева. Аргументи се прослеђују као параметри упита. Потпис V2 их обухвата путем путање и упита, а поље тела је празно. Проверите помоћу сировог низа упита: рашчлањивање параметара и прављење новог низа упита може променити њихов редослед или кодирање. Застарело заглавље потписује празан низ бајтова.
  2. Организације без конфигурисане застареле веб-куке немају тајну вредност организације. У том случају позиви алата садрже само X-ThunderPhone-Call-ID и немају заглавља потписа. Конфигуришите застарелу веб-куку (PUT /v1/webhook) да бисте добили тајну вредност за потписивање или аутентификујте позиве алата сопственим заглављем преко 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)
    ...

У Express-у, рашчлањивач тела оставља req.body као празан објекат за GET, па проследите Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).

Отпрема алата у режиму веб-хука (алати без endpoint, испоручени на веб-хук ваше организације као telephony.tool / web.tool) је обичан потписани веб-хук — важи стандардни поступак изнад. Погледајте Алати функција за оба облика захтева.

Застарели потпис: X-ThunderPhone-Signature

Оригинално заглавље је хексадецимални HMAC-SHA256 само сировог тела, са истом тајном. Не садржи временску ознаку, тако да свако ко пресретне један потписани захтев може поново да га пошаље. И даље се шаље уз сваки захтев док интеграције прелазе на V2.

  1. Прочитајте сирово тело захтева — тачне бајтове које смо Вам послали.
  2. Израчунајте hmac_sha256(secret, body).hexdigest().
  3. Упоредите у константном времену са X-ThunderPhone-Signature.

Потписујемо тачно бајтове које преносимо. Ти бајтови су такође канонска JSON серијализација корисног садржаја — кључеви поређани абецедним редом, компактни раздвајачи (, и : без размака), UTF-8. Ако Ваш оквир приказује само рашчлањени JSON, поново га канонски серијализујте и израчунајте HMAC над њим:

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

Дајте предност сировом телу — то је један корак мање и отпорно је на особености поновног претварања JSON бројева у неким језицима. Исто важи за поље тела у 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

Уобичајене замке

Одступање часовника

Петоминутни период мери се у односу на часовник вашег сервера. Одржавајте га синхронизованим са NTP-ом; часовник који одступа више од 5 минута одбија сваки захтев.

Прокси мења путању

Потписана путања је она коју смо захтевали: путања URL-а који сте конфигурисали у ThunderPhone. Ако обрнути прокси или API мрежни пролаз уклони или дода префикс пре него што ваша апликација прими захтев, поново саставите путању коју смо послали (на пример, вратите префикс) пре верификације.

Поновна серијализација са подразумеваним форматирањем

Рашчлањивање тела и његово поновно серијализовање са подразумеваним поставкама ваше JSON библиотеке (размаци после , / :, кључеви у редоследу уметања) производи другачије бајтове и нарушава HMAC. Проверите сирово тело — или, ако морате поново да га серијализујете, тачно га ускладите са нашим канонским обликом: сортирани кључеви, сажети раздвајачи, UTF-8.

Оквир аутоматски парсира JSON

Express-ов express.json() посреднички софтвер троши ток тела и губите сирове бајтове. Користите express.raw() посебно на webhook рути или баферујте сирово тело у претходном посредничком софтверу. Исто важи за NestJS / Koa — погледајте њихову документацију о „сировом телу“.

Поређење небезбедно у погледу времена

expected === signature у JS или expected == signature у Python-у су поређења променљивог трајања. Користите crypto.timingSafeEqual односно hmac.compare_digest. Разлика у перформансама је занемарљива.

Погрешна тајна за крајње тачке алатки

Директни позиви крајњих тачака алатки потписују се помоћу тајне веб-хука на нивоу организације (GET /v1/webhook) — не помоћу тајне по крајњој тачки из /v1/developer/webhook-endpoints. Поново употребите исту помоћну функцију, али уверите се да јој прослеђујете тајну организације на путањама алатки.

Параметри упита у GET/DELETE алаткама

V2 потписује параметре упита тачно онако како су послати. Застарело заглавље потписује празно тело, а никада параметре упита, па се хеширање URL-а за застарело заглавље никада неће подударати.

Дупликати унутар периода

Временска ознака спречава понављања старија од 5 минута, а не другу копију унутар тог периода. Испоруке на крајње тачке су најмање једном, па може стићи и поновни покушај истог догађаја; дедуплицирајте према event_id омотача.

Невраћање 401 при неподударности

Враћање 200 при неуспешној верификацији чини обрађивач метом за поновљене захтеве. Увек одговорите статусом који није 2xx ако верификација не успе.


Следећи кораци